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
|
@@ -0,0 +1,127 @@
|
|
|
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 { decodeTokenFromEmojiString, dumbTokenSelection, encodeTokenToEmoji, unlockTokenContent } from "../tokens.js";
|
|
6
|
+
import { HiddenContentSymbol, unixNow } 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).events).toEqual([b]);
|
|
24
|
+
});
|
|
25
|
+
it("should select 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).events).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 ignore locked tokens", 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
|
+
await unlockTokenContent(a, user);
|
|
54
|
+
const bDraft = await factory.create(WalletTokenBlueprint, {
|
|
55
|
+
mint: "https://money.com",
|
|
56
|
+
proofs: [{ secret: "B", C: "B", id: "B", amount: 50 }],
|
|
57
|
+
});
|
|
58
|
+
bDraft.created_at -= 60 * 60 * 7;
|
|
59
|
+
const b = await user.signEvent(bDraft);
|
|
60
|
+
// manually remove the hidden content to lock it again
|
|
61
|
+
Reflect.deleteProperty(b, HiddenContentSymbol);
|
|
62
|
+
expect(dumbTokenSelection([a, b], 20).events).toEqual([a]);
|
|
63
|
+
});
|
|
64
|
+
it("should ignore duplicate proofs", async () => {
|
|
65
|
+
const a = await user.signEvent(await factory.create(WalletTokenBlueprint, {
|
|
66
|
+
mint: "https://money.com",
|
|
67
|
+
proofs: [{ secret: "A", C: "A", id: "A", amount: 100 }],
|
|
68
|
+
}));
|
|
69
|
+
// create a second event with the same proofs
|
|
70
|
+
const b = await user.signEvent(await factory.create(WalletTokenBlueprint, {
|
|
71
|
+
mint: "https://money.com",
|
|
72
|
+
proofs: [{ secret: "A", C: "A", id: "A", amount: 100 }],
|
|
73
|
+
}));
|
|
74
|
+
expect(() => dumbTokenSelection([a, b], 150)).toThrow();
|
|
75
|
+
});
|
|
76
|
+
it("should include duplicate token events and ignore duplicate proofs", async () => {
|
|
77
|
+
const A = { secret: "A", C: "A", id: "A", amount: 100 };
|
|
78
|
+
const a = await user.signEvent({
|
|
79
|
+
...(await factory.create(WalletTokenBlueprint, {
|
|
80
|
+
mint: "https://money.com",
|
|
81
|
+
proofs: [A],
|
|
82
|
+
})),
|
|
83
|
+
// make event older
|
|
84
|
+
created_at: unixNow() - 100,
|
|
85
|
+
});
|
|
86
|
+
// create a second event with the same proofs
|
|
87
|
+
const a2 = await user.signEvent({
|
|
88
|
+
...(await factory.create(WalletTokenBlueprint, {
|
|
89
|
+
mint: "https://money.com",
|
|
90
|
+
proofs: [A],
|
|
91
|
+
})),
|
|
92
|
+
// make event older
|
|
93
|
+
created_at: a.created_at - 200,
|
|
94
|
+
});
|
|
95
|
+
const B = { secret: "B", C: "B", id: "B", amount: 50 };
|
|
96
|
+
const b = await user.signEvent(await factory.create(WalletTokenBlueprint, {
|
|
97
|
+
mint: "https://money.com",
|
|
98
|
+
proofs: [B],
|
|
99
|
+
}));
|
|
100
|
+
const result = dumbTokenSelection([a, a2, b], 150);
|
|
101
|
+
expect(result.events.map((e) => e.id)).toEqual(expect.arrayContaining([a.id, a2.id, b.id]));
|
|
102
|
+
expect(result.proofs).toEqual([A, B]);
|
|
103
|
+
});
|
|
104
|
+
});
|
|
105
|
+
describe("encodeTokenToEmoji", () => {
|
|
106
|
+
it("should encode token into emoji string", () => {
|
|
107
|
+
const token = "cashuBo2FteBtodHRwczovL3Rlc3RudXQuY2FzaHUuc3BhY2VhdWNzYXRhdIGiYWlIAJofKTJT5B5hcIGkYWEBYXN4QDdlZDBkMzk3NGQ5ZWM2OTc2YTAzYmZmYjdkMTA4NzIzZTBiMDRjMzRhNDc3MjlmNjMwOGJlODc3OTA2NTY0NDVhY1ghA36iYyOHCe4CnTxzORbcXFVeAbkMUFE6FqPWInujnAOcYWSjYWVYIJmHRwCQ0Uopkd3P5xb0MdcWQEaZz9hXWtcn-FMhZj8LYXNYIF4X9ybXxg5Pp0KSowfu4y_Aovo9iy3TXlLSaKyVJzz2YXJYIC_UFkoC5U9BpSgBTGUQgsjfz_emv5xykDiavZUfRN8E";
|
|
108
|
+
expect(encodeTokenToEmoji(token).length).toBeGreaterThan(token.length);
|
|
109
|
+
});
|
|
110
|
+
});
|
|
111
|
+
const emoji = "🥜󠅓󠅑󠅣󠅘󠅥󠄲󠅟󠄢󠄶󠅤󠅕󠄲󠅤󠅟󠅔󠄸󠅂󠅧󠅓󠅪󠅟󠅦󠄼󠄣󠅂󠅜󠅓󠄣󠅂󠅥󠅔󠅈󠅁󠅥󠅉󠄢󠄶󠅪󠅑󠄸󠅅󠅥󠅓󠄣󠄲󠅘󠅉󠄢󠅆󠅘󠅔󠅇󠄾󠅪󠅉󠅈󠅂󠅘󠅔󠄹󠄷󠅙󠅉󠅇󠅜󠄹󠄱󠄺󠅟󠅖󠄻󠅄󠄺󠅄󠄥󠄲󠄥󠅘󠅓󠄹󠄷󠅛󠅉󠅇󠄵󠄲󠅉󠅈󠄾󠄤󠅁󠄴󠅔󠅜󠅊󠄴󠄲󠅛󠄽󠅪󠅛󠄣󠄾󠄷󠅁󠄥󠅊󠅇󠄽󠄢󠄿󠅄󠅓󠄢󠅉󠅄󠄱󠅪󠅉󠅝󠅊󠅝󠅉󠅚󠅔󠅛󠄽󠅄󠄱󠄤󠄾󠅪󠄹󠅪󠅊󠅄󠄲󠅙󠄽󠄴󠅂󠅚󠄽󠅪󠅂󠅘󠄾󠄴󠅓󠄣󠄽󠅚󠅜󠅝󠄾󠅚󠄽󠅧󠄿󠄷󠄺󠅜󠄿󠄴󠅓󠄣󠄿󠅄󠄱󠄢󠄾󠅄󠅉󠄠󠄾󠄴󠅆󠅘󠅉󠄡󠅗󠅘󠄱󠄣󠄦󠅙󠅉󠅩󠄿󠄸󠄳󠅕󠄤󠄳󠅞󠅄󠅨󠅪󠄿󠅂󠅒󠅓󠅈󠄶󠅆󠅕󠄱󠅒󠅛󠄽󠅅󠄶󠄵󠄦󠄶󠅡󠅀󠅇󠄹󠅞󠅥󠅚󠅞󠄱󠄿󠅓󠅉󠅇󠅃󠅚󠅉󠅇󠅆󠅉󠄹󠄺󠅝󠄸󠅂󠅧󠄳󠅁󠄠󠅅󠅟󠅠󠅛󠅔󠄣󠅀󠄥󠅨󠅒󠄠󠄽󠅔󠅓󠅇󠅁󠄵󠅑󠅊󠅪󠄩󠅘󠅈󠅇󠅤󠅓󠅞󠄝󠄶󠄽󠅘󠅊󠅚󠄨󠄼󠅉󠅈󠄾󠅉󠄹󠄶󠄤󠅈󠄩󠅩󠅒󠅈󠅨󠅗󠄥󠅀󠅠󠄠󠄻󠅃󠅟󠅧󠅖󠅥󠄤󠅩󠅏󠄱󠅟󠅦󠅟󠄩󠅙󠅩󠄣󠅄󠅈󠅜󠄼󠅃󠅑󠄻󠅩󠅆󠄺󠅪󠅪󠄢󠅉󠅈󠄺󠅉󠄹󠄳󠅏󠅅󠄶󠅛󠅟󠄳󠄥󠅅󠄩󠄲󠅠󠅃󠅗󠄲󠅄󠄷󠅅󠅁󠅗󠅣󠅚󠅖󠅪󠅏󠅕󠅝󠅦󠄥󠅨󠅩󠅛󠄴󠅙󠅑󠅦󠅊󠅅󠅖󠅂󠄾󠄨󠄵";
|
|
112
|
+
describe("decodeTokenFromEmojiString", () => {
|
|
113
|
+
it("should decode single emoji", () => {
|
|
114
|
+
expect(decodeTokenFromEmojiString(emoji)).toEqual(expect.objectContaining({
|
|
115
|
+
mint: "https://testnut.cashu.space",
|
|
116
|
+
proofs: [expect.any(Object)],
|
|
117
|
+
unit: "sat",
|
|
118
|
+
}));
|
|
119
|
+
});
|
|
120
|
+
it("should decode an emoji in text", () => {
|
|
121
|
+
expect(decodeTokenFromEmojiString("the money is in the emoji, " + emoji + " you can redeem it using cashu.me")).toEqual(expect.objectContaining({
|
|
122
|
+
mint: "https://testnut.cashu.space",
|
|
123
|
+
proofs: [expect.any(Object)],
|
|
124
|
+
unit: "sat",
|
|
125
|
+
}));
|
|
126
|
+
});
|
|
127
|
+
});
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { Token } from "@cashu/cashu-ts";
|
|
2
|
+
import { Observable } from "rxjs";
|
|
3
|
+
/** Preset speeds for the animated qr code */
|
|
4
|
+
export declare const ANIMATED_QR_INTERVAL: {
|
|
5
|
+
SLOW: number;
|
|
6
|
+
MEDIUM: number;
|
|
7
|
+
FAST: number;
|
|
8
|
+
};
|
|
9
|
+
/** Presets for fragment length for animated qr code */
|
|
10
|
+
export declare const ANIMATED_QR_FRAGMENTS: {
|
|
11
|
+
SHORT: number;
|
|
12
|
+
MEDIUM: number;
|
|
13
|
+
LONG: number;
|
|
14
|
+
};
|
|
15
|
+
export type SendAnimatedOptions = {
|
|
16
|
+
/**
|
|
17
|
+
* The interval between the parts ( 150 - 500 )
|
|
18
|
+
* @default 150
|
|
19
|
+
*/
|
|
20
|
+
interval?: number;
|
|
21
|
+
/**
|
|
22
|
+
* max fragment length ( 50 - 200 )
|
|
23
|
+
* @default 100
|
|
24
|
+
*/
|
|
25
|
+
fragmentLength?: number;
|
|
26
|
+
};
|
|
27
|
+
/** Creates an observable that iterates through a multi-part animated qr code */
|
|
28
|
+
export declare function sendAnimated(token: Token | string, options?: SendAnimatedOptions): Observable<string>;
|
|
29
|
+
/** Creates an observable that completes with decoded token */
|
|
30
|
+
export declare function receiveAnimated(input: Observable<string>): Observable<string | number>;
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { getEncodedTokenV4 } from "@cashu/cashu-ts";
|
|
2
|
+
import { UR, URDecoder, UREncoder } from "@gandlaf21/bc-ur/dist/lib/es6/index.js";
|
|
3
|
+
import { defer, filter, interval, map, Observable, shareReplay } from "rxjs";
|
|
4
|
+
/** Preset speeds for the animated qr code */
|
|
5
|
+
export const ANIMATED_QR_INTERVAL = {
|
|
6
|
+
SLOW: 500,
|
|
7
|
+
MEDIUM: 250,
|
|
8
|
+
FAST: 150,
|
|
9
|
+
};
|
|
10
|
+
/** Presets for fragment length for animated qr code */
|
|
11
|
+
export const ANIMATED_QR_FRAGMENTS = {
|
|
12
|
+
SHORT: 50,
|
|
13
|
+
MEDIUM: 100,
|
|
14
|
+
LONG: 150,
|
|
15
|
+
};
|
|
16
|
+
/** Creates an observable that iterates through a multi-part animated qr code */
|
|
17
|
+
export function sendAnimated(token, options) {
|
|
18
|
+
// start the stream as soon as there is subscriber
|
|
19
|
+
return defer(() => {
|
|
20
|
+
let str = typeof token === "string" ? token : getEncodedTokenV4(token);
|
|
21
|
+
let utf8 = new TextEncoder();
|
|
22
|
+
let buffer = utf8.encode(str);
|
|
23
|
+
let ur = UR.from(buffer);
|
|
24
|
+
let encoder = new UREncoder(ur, options?.fragmentLength ?? 100, 0);
|
|
25
|
+
return interval(options?.interval ?? ANIMATED_QR_INTERVAL.FAST).pipe(map(() => encoder.nextPart()));
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
/** An operator that decodes UR, emits progress percent and completes with final result or error */
|
|
29
|
+
function urDecoder() {
|
|
30
|
+
return (source) => new Observable((observer) => {
|
|
31
|
+
const decoder = new URDecoder();
|
|
32
|
+
return source.subscribe((part) => {
|
|
33
|
+
decoder.receivePart(part);
|
|
34
|
+
if (decoder.isComplete() && decoder.isSuccess()) {
|
|
35
|
+
// emit progress
|
|
36
|
+
const progress = decoder.estimatedPercentComplete();
|
|
37
|
+
observer.next(progress);
|
|
38
|
+
// emit result
|
|
39
|
+
const ur = decoder.resultUR();
|
|
40
|
+
const decoded = ur.decodeCBOR();
|
|
41
|
+
const utf8 = new TextDecoder();
|
|
42
|
+
const tokenStr = utf8.decode(decoded);
|
|
43
|
+
observer.next(tokenStr);
|
|
44
|
+
// complete
|
|
45
|
+
observer.complete();
|
|
46
|
+
}
|
|
47
|
+
else if (decoder.isError()) {
|
|
48
|
+
// emit error
|
|
49
|
+
const reason = decoder.resultError();
|
|
50
|
+
observer.error(new Error(reason));
|
|
51
|
+
}
|
|
52
|
+
else {
|
|
53
|
+
// emit progress
|
|
54
|
+
const progress = decoder.estimatedPercentComplete();
|
|
55
|
+
observer.next(progress);
|
|
56
|
+
}
|
|
57
|
+
});
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
/** Creates an observable that completes with decoded token */
|
|
61
|
+
export function receiveAnimated(input) {
|
|
62
|
+
return input.pipe(
|
|
63
|
+
// convert to lower case
|
|
64
|
+
map((str) => str.toLowerCase()),
|
|
65
|
+
// filter out non UR parts
|
|
66
|
+
filter((str) => str.startsWith("ur:bytes")),
|
|
67
|
+
// decode UR and complete
|
|
68
|
+
urDecoder(),
|
|
69
|
+
// only run one decoder
|
|
70
|
+
shareReplay(1));
|
|
71
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { HiddenContentSigner } from "applesauce-core/helpers";
|
|
2
|
+
import { NostrEvent } from "nostr-tools";
|
|
3
|
+
export declare const WALLET_HISTORY_KIND = 7376;
|
|
4
|
+
export type HistoryDirection = "in" | "out";
|
|
5
|
+
export type HistoryContent = {
|
|
6
|
+
/** The direction of the transaction, in = received, out = sent */
|
|
7
|
+
direction: HistoryDirection;
|
|
8
|
+
/** The amount of the transaction */
|
|
9
|
+
amount: number;
|
|
10
|
+
/** An array of token event ids created */
|
|
11
|
+
created: string[];
|
|
12
|
+
/** The mint that was spent from */
|
|
13
|
+
mint?: string;
|
|
14
|
+
/** The fee paid */
|
|
15
|
+
fee?: number;
|
|
16
|
+
};
|
|
17
|
+
export declare const HistoryContentSymbol: unique symbol;
|
|
18
|
+
/** returns an array of redeemed event ids in a history event */
|
|
19
|
+
export declare function getHistoryRedeemed(history: NostrEvent): string[];
|
|
20
|
+
/** Checks if the history contents are locked */
|
|
21
|
+
export declare function isHistoryContentLocked(history: NostrEvent): boolean;
|
|
22
|
+
/** Returns the parsed content of a 7376 history event */
|
|
23
|
+
export declare function getHistoryContent(history: NostrEvent): HistoryContent | undefined;
|
|
24
|
+
/** Decrypts a wallet history event */
|
|
25
|
+
export declare function unlockHistoryContent(history: NostrEvent, signer: HiddenContentSigner): Promise<HistoryContent>;
|
|
26
|
+
export declare function lockHistoryContent(history: NostrEvent): void;
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { getHiddenTags, getOrComputeCachedValue, isETag, isHiddenContentLocked, isHiddenTagsLocked, lockHiddenTags, unlockHiddenTags, } from "applesauce-core/helpers";
|
|
2
|
+
export const WALLET_HISTORY_KIND = 7376;
|
|
3
|
+
export const HistoryContentSymbol = Symbol.for("history-content");
|
|
4
|
+
/** returns an array of redeemed event ids in a history event */
|
|
5
|
+
export function getHistoryRedeemed(history) {
|
|
6
|
+
return history.tags.filter((t) => isETag(t) && t[3] === "redeemed").map((t) => t[1]);
|
|
7
|
+
}
|
|
8
|
+
/** Checks if the history contents are locked */
|
|
9
|
+
export function isHistoryContentLocked(history) {
|
|
10
|
+
return isHiddenTagsLocked(history);
|
|
11
|
+
}
|
|
12
|
+
/** Returns the parsed content of a 7376 history event */
|
|
13
|
+
export function getHistoryContent(history) {
|
|
14
|
+
if (isHistoryContentLocked(history))
|
|
15
|
+
return undefined;
|
|
16
|
+
return getOrComputeCachedValue(history, HistoryContentSymbol, () => {
|
|
17
|
+
const tags = getHiddenTags(history);
|
|
18
|
+
if (!tags)
|
|
19
|
+
throw new Error("History event is locked");
|
|
20
|
+
const direction = tags.find((t) => t[0] === "direction")?.[1];
|
|
21
|
+
if (!direction)
|
|
22
|
+
throw new Error("History event missing direction");
|
|
23
|
+
const amountStr = tags.find((t) => t[0] === "amount")?.[1];
|
|
24
|
+
if (!amountStr)
|
|
25
|
+
throw new Error("History event missing amount");
|
|
26
|
+
const amount = parseInt(amountStr);
|
|
27
|
+
if (!Number.isFinite(amount))
|
|
28
|
+
throw new Error("Failed to parse amount");
|
|
29
|
+
const mint = tags.find((t) => t[0] === "mint")?.[1];
|
|
30
|
+
const feeStr = tags.find((t) => t[0] === "fee")?.[1];
|
|
31
|
+
const fee = feeStr ? parseInt(feeStr) : undefined;
|
|
32
|
+
const created = tags.filter((t) => isETag(t) && t[3] === "created").map((t) => t[1]);
|
|
33
|
+
return { direction, amount, created, mint, fee };
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
/** Decrypts a wallet history event */
|
|
37
|
+
export async function unlockHistoryContent(history, signer) {
|
|
38
|
+
if (isHiddenContentLocked(history))
|
|
39
|
+
await unlockHiddenTags(history, signer);
|
|
40
|
+
return getHistoryContent(history);
|
|
41
|
+
}
|
|
42
|
+
export function lockHistoryContent(history) {
|
|
43
|
+
Reflect.deleteProperty(history, HistoryContentSymbol);
|
|
44
|
+
lockHiddenTags(history);
|
|
45
|
+
}
|
package/dist/helpers/index.d.ts
CHANGED
package/dist/helpers/index.js
CHANGED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { Proof, Token } from "@cashu/cashu-ts";
|
|
2
|
+
import { HiddenContentSigner } from "applesauce-core/helpers";
|
|
3
|
+
import { NostrEvent } from "nostr-tools";
|
|
4
|
+
/** Internal method for creating a unique id for each proof */
|
|
5
|
+
export declare function getProofUID(proof: Proof): string;
|
|
6
|
+
/** Internal method to filter out duplicate proofs */
|
|
7
|
+
export declare function ignoreDuplicateProofs(seen?: Set<string>): (proof: Proof) => boolean;
|
|
8
|
+
export declare const WALLET_TOKEN_KIND = 7375;
|
|
9
|
+
export type TokenContent = {
|
|
10
|
+
/** Cashu mint for the proofs */
|
|
11
|
+
mint: string;
|
|
12
|
+
/** Cashu proofs */
|
|
13
|
+
proofs: {
|
|
14
|
+
amount: number;
|
|
15
|
+
secret: string;
|
|
16
|
+
C: string;
|
|
17
|
+
id: string;
|
|
18
|
+
}[];
|
|
19
|
+
/** The cashu unit */
|
|
20
|
+
unit?: string;
|
|
21
|
+
/** tokens that were destroyed in the creation of this token (helps on wallet state transitions) */
|
|
22
|
+
del: string[];
|
|
23
|
+
};
|
|
24
|
+
export declare const TokenContentSymbol: unique symbol;
|
|
25
|
+
/**
|
|
26
|
+
* Returns the decrypted and parsed details of a 7375 token event
|
|
27
|
+
* @throws
|
|
28
|
+
*/
|
|
29
|
+
export declare function getTokenContent(token: NostrEvent): TokenContent | undefined;
|
|
30
|
+
/** Returns if token details are locked */
|
|
31
|
+
export declare function isTokenContentLocked(token: NostrEvent): boolean;
|
|
32
|
+
/** Decrypts a k:7375 token event */
|
|
33
|
+
export declare function unlockTokenContent(token: NostrEvent, signer: HiddenContentSigner): Promise<TokenContent>;
|
|
34
|
+
/** Removes the unencrypted hidden content */
|
|
35
|
+
export declare function lockTokenContent(token: NostrEvent): void;
|
|
36
|
+
/**
|
|
37
|
+
* Gets the totaled amount of proofs in a token event
|
|
38
|
+
* @param token The token event to calculate the total
|
|
39
|
+
*/
|
|
40
|
+
export declare function getTokenProofsTotal(token: NostrEvent): number | undefined;
|
|
41
|
+
/**
|
|
42
|
+
* Selects oldest tokens and proofs that total up to more than the min amount
|
|
43
|
+
* @throws
|
|
44
|
+
*/
|
|
45
|
+
export declare function dumbTokenSelection(tokens: NostrEvent[], minAmount: number, mint?: string): {
|
|
46
|
+
events: NostrEvent[];
|
|
47
|
+
proofs: Proof[];
|
|
48
|
+
};
|
|
49
|
+
/**
|
|
50
|
+
* Returns a decoded cashu token inside an unicode emoji
|
|
51
|
+
* @see https://github.com/cashubtc/cashu.me/blob/1194a7b9ee2f43305e38304de7bef8839601ff4d/src/components/ReceiveTokenDialog.vue#L387
|
|
52
|
+
*/
|
|
53
|
+
export declare function decodeTokenFromEmojiString(str: string): Token | undefined;
|
|
54
|
+
/**
|
|
55
|
+
* Encodes a token into an emoji char
|
|
56
|
+
* @see https://github.com/cashubtc/cashu.me/blob/1194a7b9ee2f43305e38304de7bef8839601ff4d/src/components/SendTokenDialog.vue#L710
|
|
57
|
+
*/
|
|
58
|
+
export declare function encodeTokenToEmoji(token: Token | string, emoji?: string): string;
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
import { getDecodedToken, getEncodedToken } from "@cashu/cashu-ts";
|
|
2
|
+
import { getHiddenContent, getOrComputeCachedValue, isHiddenContentLocked, isHiddenTagsLocked, lockHiddenContent, unlockHiddenContent, } from "applesauce-core/helpers";
|
|
3
|
+
/** Internal method for creating a unique id for each proof */
|
|
4
|
+
export function getProofUID(proof) {
|
|
5
|
+
return proof.id + proof.amount + proof.C + proof.secret;
|
|
6
|
+
}
|
|
7
|
+
/** Internal method to filter out duplicate proofs */
|
|
8
|
+
export function ignoreDuplicateProofs(seen = new Set()) {
|
|
9
|
+
return (proof) => {
|
|
10
|
+
const id = getProofUID(proof);
|
|
11
|
+
if (seen.has(id))
|
|
12
|
+
return false;
|
|
13
|
+
else {
|
|
14
|
+
seen.add(id);
|
|
15
|
+
return true;
|
|
16
|
+
}
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
export const WALLET_TOKEN_KIND = 7375;
|
|
20
|
+
export const TokenContentSymbol = Symbol.for("token-content");
|
|
21
|
+
/**
|
|
22
|
+
* Returns the decrypted and parsed details of a 7375 token event
|
|
23
|
+
* @throws
|
|
24
|
+
*/
|
|
25
|
+
export function getTokenContent(token) {
|
|
26
|
+
if (isHiddenTagsLocked(token))
|
|
27
|
+
return undefined;
|
|
28
|
+
return getOrComputeCachedValue(token, TokenContentSymbol, () => {
|
|
29
|
+
const plaintext = getHiddenContent(token);
|
|
30
|
+
if (!plaintext)
|
|
31
|
+
throw new Error("Token is locked");
|
|
32
|
+
const details = JSON.parse(plaintext);
|
|
33
|
+
if (!details.mint)
|
|
34
|
+
throw new Error("Token missing mint");
|
|
35
|
+
if (!details.proofs)
|
|
36
|
+
throw new Error("Token missing proofs");
|
|
37
|
+
if (!details.del)
|
|
38
|
+
details.del = [];
|
|
39
|
+
return details;
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
/** Returns if token details are locked */
|
|
43
|
+
export function isTokenContentLocked(token) {
|
|
44
|
+
return isHiddenContentLocked(token);
|
|
45
|
+
}
|
|
46
|
+
/** Decrypts a k:7375 token event */
|
|
47
|
+
export async function unlockTokenContent(token, signer) {
|
|
48
|
+
if (isHiddenContentLocked(token))
|
|
49
|
+
await unlockHiddenContent(token, signer);
|
|
50
|
+
return getTokenContent(token);
|
|
51
|
+
}
|
|
52
|
+
/** Removes the unencrypted hidden content */
|
|
53
|
+
export function lockTokenContent(token) {
|
|
54
|
+
Reflect.deleteProperty(token, TokenContentSymbol);
|
|
55
|
+
lockHiddenContent(token);
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Gets the totaled amount of proofs in a token event
|
|
59
|
+
* @param token The token event to calculate the total
|
|
60
|
+
*/
|
|
61
|
+
export function getTokenProofsTotal(token) {
|
|
62
|
+
if (isTokenContentLocked(token))
|
|
63
|
+
return undefined;
|
|
64
|
+
const content = getTokenContent(token);
|
|
65
|
+
return content.proofs.reduce((t, p) => t + p.amount, 0);
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Selects oldest tokens and proofs that total up to more than the min amount
|
|
69
|
+
* @throws
|
|
70
|
+
*/
|
|
71
|
+
export function dumbTokenSelection(tokens, minAmount, mint) {
|
|
72
|
+
// sort newest to oldest
|
|
73
|
+
const sorted = tokens
|
|
74
|
+
.filter((token) => !isTokenContentLocked(token) && (mint ? getTokenContent(token).mint === mint : true))
|
|
75
|
+
.sort((a, b) => b.created_at - a.created_at);
|
|
76
|
+
let amount = 0;
|
|
77
|
+
const seen = new Set();
|
|
78
|
+
const selectedTokens = [];
|
|
79
|
+
const selectedProofs = [];
|
|
80
|
+
while (amount < minAmount) {
|
|
81
|
+
const token = sorted.pop();
|
|
82
|
+
if (!token)
|
|
83
|
+
throw new Error("Insufficient funds");
|
|
84
|
+
const proofs = getTokenContent(token).proofs.filter(ignoreDuplicateProofs(seen));
|
|
85
|
+
const total = proofs.reduce((t, p) => t + p.amount, 0);
|
|
86
|
+
selectedTokens.push(token);
|
|
87
|
+
selectedProofs.push(...proofs);
|
|
88
|
+
amount += total;
|
|
89
|
+
}
|
|
90
|
+
return { events: selectedTokens, proofs: selectedProofs };
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* Returns a decoded cashu token inside an unicode emoji
|
|
94
|
+
* @see https://github.com/cashubtc/cashu.me/blob/1194a7b9ee2f43305e38304de7bef8839601ff4d/src/components/ReceiveTokenDialog.vue#L387
|
|
95
|
+
*/
|
|
96
|
+
export function decodeTokenFromEmojiString(str) {
|
|
97
|
+
try {
|
|
98
|
+
let decoded = [];
|
|
99
|
+
const chars = Array.from(str);
|
|
100
|
+
if (!chars.length)
|
|
101
|
+
return undefined;
|
|
102
|
+
const fromVariationSelector = function (char) {
|
|
103
|
+
const codePoint = char.codePointAt(0);
|
|
104
|
+
if (codePoint === undefined)
|
|
105
|
+
return null;
|
|
106
|
+
// Handle Variation Selectors (VS1-VS16): U+FE00 to U+FE0F
|
|
107
|
+
if (codePoint >= 0xfe00 && codePoint <= 0xfe0f) {
|
|
108
|
+
// Maps FE00->0, FE01->1, ..., FE0F->15
|
|
109
|
+
const byteValue = codePoint - 0xfe00;
|
|
110
|
+
return String.fromCharCode(byteValue);
|
|
111
|
+
}
|
|
112
|
+
// Handle Variation Selectors Supplement (VS17-VS256): U+E0100 to U+E01EF
|
|
113
|
+
if (codePoint >= 0xe0100 && codePoint <= 0xe01ef) {
|
|
114
|
+
// Maps E0100->16, E0101->17, ..., E01EF->255
|
|
115
|
+
const byteValue = codePoint - 0xe0100 + 16;
|
|
116
|
+
return String.fromCharCode(byteValue);
|
|
117
|
+
}
|
|
118
|
+
// No Variation Selector
|
|
119
|
+
return null;
|
|
120
|
+
};
|
|
121
|
+
// Check all input chars for peanut data
|
|
122
|
+
for (const char of chars) {
|
|
123
|
+
let byte = fromVariationSelector(char);
|
|
124
|
+
if (byte === null && decoded.length > 0) {
|
|
125
|
+
break;
|
|
126
|
+
}
|
|
127
|
+
else if (byte === null) {
|
|
128
|
+
continue;
|
|
129
|
+
}
|
|
130
|
+
decoded.push(byte); // got some
|
|
131
|
+
}
|
|
132
|
+
// Switch out token if we found peanut data
|
|
133
|
+
let decodedString = decoded.join("");
|
|
134
|
+
return getDecodedToken(decodedString);
|
|
135
|
+
}
|
|
136
|
+
catch (error) {
|
|
137
|
+
return undefined;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
/**
|
|
141
|
+
* Encodes a token into an emoji char
|
|
142
|
+
* @see https://github.com/cashubtc/cashu.me/blob/1194a7b9ee2f43305e38304de7bef8839601ff4d/src/components/SendTokenDialog.vue#L710
|
|
143
|
+
*/
|
|
144
|
+
export function encodeTokenToEmoji(token, emoji = "🥜") {
|
|
145
|
+
return (emoji +
|
|
146
|
+
Array.from(typeof token === "string" ? token : getEncodedToken(token))
|
|
147
|
+
.map((char) => {
|
|
148
|
+
const byteValue = char.charCodeAt(0);
|
|
149
|
+
// For byte values 0-15, use Variation Selectors (VS1-VS16): U+FE00 to U+FE0F
|
|
150
|
+
if (byteValue >= 0 && byteValue <= 15) {
|
|
151
|
+
return String.fromCodePoint(0xfe00 + byteValue);
|
|
152
|
+
}
|
|
153
|
+
// For byte values 16-255, use Variation Selectors Supplement (VS17-VS256): U+E0100 to U+E01EF
|
|
154
|
+
if (byteValue >= 16 && byteValue <= 255) {
|
|
155
|
+
return String.fromCodePoint(0xe0100 + (byteValue - 16));
|
|
156
|
+
}
|
|
157
|
+
return "";
|
|
158
|
+
})
|
|
159
|
+
.join(""));
|
|
160
|
+
}
|
package/dist/helpers/wallet.d.ts
CHANGED
|
@@ -1,13 +1,15 @@
|
|
|
1
1
|
import { HiddenContentSigner } from "applesauce-core/helpers";
|
|
2
2
|
import { NostrEvent } from "nostr-tools";
|
|
3
3
|
export declare const WALLET_KIND = 17375;
|
|
4
|
+
export declare const WALLET_BACKUP_KIND = 375;
|
|
4
5
|
export declare const WalletPrivateKeySymbol: unique symbol;
|
|
5
6
|
export declare const WalletMintsSymbol: unique symbol;
|
|
6
7
|
/** Returns if a wallet is locked */
|
|
7
8
|
export declare function isWalletLocked(wallet: NostrEvent): boolean;
|
|
8
9
|
/** Unlocks a wallet and returns the hidden tags */
|
|
9
10
|
export declare function unlockWallet(wallet: NostrEvent, signer: HiddenContentSigner): Promise<string[][]>;
|
|
11
|
+
export declare function lockWallet(wallet: NostrEvent): void;
|
|
10
12
|
/** Returns the wallets mints */
|
|
11
13
|
export declare function getWalletMints(wallet: NostrEvent): string[];
|
|
12
14
|
/** Returns the wallets private key as a string */
|
|
13
|
-
export declare function getWalletPrivateKey(wallet: NostrEvent):
|
|
15
|
+
export declare function getWalletPrivateKey(wallet: NostrEvent): Uint8Array | undefined;
|
package/dist/helpers/wallet.js
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { hexToBytes } from "@noble/hashes/utils";
|
|
2
|
+
import { getHiddenTags, getOrComputeCachedValue, isHiddenTagsLocked, lockHiddenTags, unlockHiddenTags, } from "applesauce-core/helpers";
|
|
2
3
|
export const WALLET_KIND = 17375;
|
|
4
|
+
export const WALLET_BACKUP_KIND = 375;
|
|
3
5
|
export const WalletPrivateKeySymbol = Symbol.for("wallet-private-key");
|
|
4
6
|
export const WalletMintsSymbol = Symbol.for("wallet-mints");
|
|
5
7
|
/** Returns if a wallet is locked */
|
|
@@ -10,6 +12,11 @@ export function isWalletLocked(wallet) {
|
|
|
10
12
|
export async function unlockWallet(wallet, signer) {
|
|
11
13
|
return await unlockHiddenTags(wallet, signer);
|
|
12
14
|
}
|
|
15
|
+
export function lockWallet(wallet) {
|
|
16
|
+
Reflect.deleteProperty(wallet, WalletPrivateKeySymbol);
|
|
17
|
+
Reflect.deleteProperty(wallet, WalletMintsSymbol);
|
|
18
|
+
lockHiddenTags(wallet);
|
|
19
|
+
}
|
|
13
20
|
/** Returns the wallets mints */
|
|
14
21
|
export function getWalletMints(wallet) {
|
|
15
22
|
return getOrComputeCachedValue(wallet, WalletMintsSymbol, () => {
|
|
@@ -26,8 +33,6 @@ export function getWalletPrivateKey(wallet) {
|
|
|
26
33
|
if (!tags)
|
|
27
34
|
throw new Error("Wallet is locked");
|
|
28
35
|
const key = tags.find((t) => t[0] === "privkey" && t[1])?.[1];
|
|
29
|
-
|
|
30
|
-
throw new Error("Wallet missing private key");
|
|
31
|
-
return key;
|
|
36
|
+
return key ? hexToBytes(key) : undefined;
|
|
32
37
|
});
|
|
33
38
|
}
|
package/dist/index.d.ts
CHANGED
|
@@ -1,2 +1,5 @@
|
|
|
1
|
-
export * as
|
|
1
|
+
export * as Actions from "./actions/index.js";
|
|
2
|
+
export * as Blueprints from "./blueprints/index.js";
|
|
2
3
|
export * as Helpers from "./helpers/index.js";
|
|
4
|
+
export * as Queries from "./queries/index.js";
|
|
5
|
+
export * from "./operations/index.js";
|
package/dist/index.js
CHANGED
|
@@ -1,2 +1,5 @@
|
|
|
1
|
-
export * as
|
|
1
|
+
export * as Actions from "./actions/index.js";
|
|
2
|
+
export * as Blueprints from "./blueprints/index.js";
|
|
2
3
|
export * as Helpers from "./helpers/index.js";
|
|
4
|
+
export * as Queries from "./queries/index.js";
|
|
5
|
+
export * from "./operations/index.js";
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { setWalletBackupContent } from "../wallet.js";
|
|
3
|
+
import { EventFactory } from "applesauce-factory";
|
|
4
|
+
import { generateSecretKey } from "nostr-tools";
|
|
5
|
+
import { unixNow } from "applesauce-core/helpers";
|
|
6
|
+
import { WALLET_BACKUP_KIND } from "../../../helpers/wallet.js";
|
|
7
|
+
import { FakeUser } from "../../../__tests__/fake-user.js";
|
|
8
|
+
import { WalletBlueprint } from "../../../blueprints/wallet.js";
|
|
9
|
+
const user = new FakeUser();
|
|
10
|
+
const factory = new EventFactory({ signer: user });
|
|
11
|
+
describe("setWalletBackupContent", () => {
|
|
12
|
+
it("should throw if kind is not wallet kind", async () => {
|
|
13
|
+
const note = user.note();
|
|
14
|
+
await expect(setWalletBackupContent(note)({ kind: WALLET_BACKUP_KIND, tags: [], created_at: unixNow(), content: "" }, factory.context)).rejects.toThrow();
|
|
15
|
+
});
|
|
16
|
+
it("should throw if pubkey does not match", async () => {
|
|
17
|
+
const wallet = await factory.sign(await factory.create(WalletBlueprint, [], generateSecretKey()));
|
|
18
|
+
const user2 = new FakeUser();
|
|
19
|
+
await expect(setWalletBackupContent(wallet)({ kind: WALLET_BACKUP_KIND, tags: [], created_at: unixNow(), content: "" }, { signer: user2 })).rejects.toThrow();
|
|
20
|
+
});
|
|
21
|
+
it("should copy the content of the wallet event", async () => {
|
|
22
|
+
const wallet = await factory.sign(await factory.create(WalletBlueprint, [], generateSecretKey()));
|
|
23
|
+
expect(await setWalletBackupContent(wallet)({ kind: WALLET_BACKUP_KIND, tags: [], created_at: unixNow(), content: "" }, factory.context)).toEqual(expect.objectContaining({ content: wallet.content }));
|
|
24
|
+
});
|
|
25
|
+
});
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { EventOperation } from "applesauce-factory";
|
|
2
|
+
import { HistoryContent } from "../../helpers/history.js";
|
|
3
|
+
import { EventPointer } from "nostr-tools/nip19";
|
|
4
|
+
/** Sets the encrypted tags of a wallet history event */
|
|
5
|
+
export declare function setHistoryContent(content: HistoryContent): EventOperation;
|
|
6
|
+
/** Sets the "redeemed" tags on a wallet history event */
|
|
7
|
+
export declare function setHistoryRedeemed(redeemed: (string | EventPointer)[]): EventOperation;
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { modifyHiddenTags, modifyPublicTags } from "applesauce-factory/operations/event";
|
|
2
|
+
import { includeHistoryCreatedTags, includeHistoryRedeemedTags, setHistoryAmountTag, setHistoryDirectionTag, setHistoryFeeTag, setHistoryMintTag, } from "../tag/history.js";
|
|
3
|
+
/** Sets the encrypted tags of a wallet history event */
|
|
4
|
+
export function setHistoryContent(content) {
|
|
5
|
+
const operations = [
|
|
6
|
+
setHistoryDirectionTag(content.direction),
|
|
7
|
+
setHistoryAmountTag(content.amount),
|
|
8
|
+
includeHistoryCreatedTags(content.created),
|
|
9
|
+
];
|
|
10
|
+
if (content.fee !== undefined)
|
|
11
|
+
operations.push(setHistoryFeeTag(content.fee));
|
|
12
|
+
if (content.mint !== undefined)
|
|
13
|
+
operations.push(setHistoryMintTag(content.mint));
|
|
14
|
+
return modifyHiddenTags(...operations);
|
|
15
|
+
}
|
|
16
|
+
/** Sets the "redeemed" tags on a wallet history event */
|
|
17
|
+
export function setHistoryRedeemed(redeemed) {
|
|
18
|
+
return modifyPublicTags(includeHistoryRedeemedTags(redeemed));
|
|
19
|
+
}
|