Skip to content

Commit

Permalink
add tokens and history helpers and queries
Browse files Browse the repository at this point in the history
  • Loading branch information
hzrd149 committed Mar 10, 2025
1 parent e176601 commit ad83de5
Show file tree
Hide file tree
Showing 10 changed files with 197 additions and 1 deletion.
5 changes: 5 additions & 0 deletions .changeset/giant-dragons-learn.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"applesauce-wallet": minor
---

Add history event helpers
5 changes: 5 additions & 0 deletions .changeset/young-steaks-design.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"applesauce-wallet": minor
---

Add token event helpers
5 changes: 5 additions & 0 deletions packages/core/src/event-store/event-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,13 +49,18 @@ export class EventStore {
/** A method used to verify new events before added them */
verifyEvent?: (event: NostrEvent) => boolean;

/** A stream of events that have been updated */
updates: Observable<NostrEvent>;

constructor() {
this.database = new Database();

this.database.onBeforeInsert = (event) => {
// reject events that are invalid
if (this.verifyEvent && this.verifyEvent(event) === false) throw new Error("Invalid event");
};

this.updates = this.database.updated;
}

// delete state
Expand Down
2 changes: 1 addition & 1 deletion packages/core/src/helpers/hidden-content.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ export function getHiddenContent(event: NostrEvent | EventTemplate): string | un

/** Checks if the hidden tags are locked */
export function isHiddenContentLocked(event: NostrEvent | UnsignedEvent): boolean {
return hasHiddenContent(event) && getHiddenContent(event) === undefined;
return getHiddenContent(event) === undefined;
}

/** Returns either nip04 or nip44 encryption methods depending on event kind */
Expand Down
51 changes: 51 additions & 0 deletions packages/wallet/src/helpers/history.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import {
getHiddenTags,
getOrComputeCachedValue,
HiddenContentSigner,
isETag,
unlockHiddenTags,
} from "applesauce-core/helpers";
import { NostrEvent } from "nostr-tools";

export const WALLET_HISTORY_KIND = 7376;

export type HistoryDetails = {
/** The direction of the transaction, in = received, out = sent */
direction: "in" | "out";
/** The amount of the transaction */
amount: number;
/** An array of token event ids created */
created: string[];
};

export const HistoryDetailsSymbol = Symbol.for("history-details");

/** returns an array of redeemed event ids in a history event */
export function getHistoryRedeemed(history: NostrEvent): string[] {
return history.tags.filter((t) => isETag(t) && t[3] === "redeemed").map((t) => t[1]);
}

/** Returns the parsed details of a 7376 history event */
export function getHistoryDetails(history: NostrEvent): HistoryDetails {
return getOrComputeCachedValue(history, HistoryDetailsSymbol, () => {
const tags = getHiddenTags(history);
if (!tags) throw new Error("History event is locked");

const direction = tags.find((t) => t[0] === "direction")?.[1] as "in" | "out" | undefined;
if (!direction) throw new Error("History event missing direction");
const amountStr = tags.find((t) => t[0] === "amount")?.[1];
if (!amountStr) throw new Error("History event missing amount");
const amount = parseInt(amountStr);
if (!Number.isFinite(amount)) throw new Error("Failed to parse amount");

const created = tags.filter((t) => isETag(t) && t[3] === "created").map((t) => t[1]);

return { direction, amount, created };
});
}

/** Decrypts a wallet history event */
export async function unlockHistoryDetails(history: NostrEvent, signer: HiddenContentSigner) {
await unlockHiddenTags(history, signer);
return getHistoryDetails(history);
}
2 changes: 2 additions & 0 deletions packages/wallet/src/helpers/index.ts
Original file line number Diff line number Diff line change
@@ -1 +1,3 @@
export * from "./wallet.js";
export * from "./tokens.js";
export * from "./history.js";
48 changes: 48 additions & 0 deletions packages/wallet/src/helpers/tokens.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import {
getHiddenContent,
getOrComputeCachedValue,
HiddenContentSigner,
isHiddenContentLocked,
unlockHiddenContent,
} from "applesauce-core/helpers";
import { NostrEvent } from "nostr-tools";

export const WALLET_TOKEN_KIND = 7375;

export type TokenDetails = {
/** Cashu mint for the proofs */
mint: string;
/** Cashu proofs */
proofs: { amount: number; secret: string; C: string; id: string }[];
/** tokens that were destroyed in the creation of this token (helps on wallet state transitions) */
del: string[];
};

export const TokenDetailsSymbol = Symbol.for("token-details");

/** Returns the decrypted and parsed details of a 7375 token event */
export function getTokenDetails(token: NostrEvent): TokenDetails {
return getOrComputeCachedValue(token, TokenDetailsSymbol, () => {
const plaintext = getHiddenContent(token);
if (!plaintext) throw new Error("Token is locked");

const details = JSON.parse(plaintext) as TokenDetails;

if (!details.mint) throw new Error("Token missing mint");
if (!details.proofs) details.proofs = [];
if (!details.del) details.del = [];

return details;
});
}

/** Returns if token details are locked */
export function isTokenDetailsLocked(token: NostrEvent): boolean {
return isHiddenContentLocked(token);
}

/** Decrypts a k:7375 token event */
export async function unlockTokenDetails(token: NostrEvent, signer: HiddenContentSigner): Promise<TokenDetails> {
if (isHiddenContentLocked(token)) await unlockHiddenContent(token, signer);
return getTokenDetails(token);
}
14 changes: 14 additions & 0 deletions packages/wallet/src/queries/history.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { Query } from "applesauce-core";
import { getHistoryRedeemed, WALLET_HISTORY_KIND } from "../helpers/history.js";
import { scan } from "rxjs";

/** Query that returns an array of redeemed event ids for a wallet */
export function WalletRedeemedQuery(pubkey: string): Query<string[]> {
return {
key: pubkey,
run: (events) =>
events
.filters({ kinds: [WALLET_HISTORY_KIND], authors: [pubkey] })
.pipe(scan((ids, history) => [...ids, ...getHistoryRedeemed(history)], [] as string[])),
};
}
2 changes: 2 additions & 0 deletions packages/wallet/src/queries/index.ts
Original file line number Diff line number Diff line change
@@ -1 +1,3 @@
export * from "./wallet.js";
export * from "./history.js";
export * from "./tokens.js";
64 changes: 64 additions & 0 deletions packages/wallet/src/queries/tokens.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import { Query } from "applesauce-core";
import { combineLatest, filter, map } from "rxjs";
import { NostrEvent } from "nostr-tools";

import { getTokenDetails, isTokenDetailsLocked, WALLET_TOKEN_KIND } from "../helpers/tokens.js";

/** A query that subscribes to all token events for a wallet, passing locked will filter by token locked status */
export function WalletTokensQuery(pubkey: string, locked: boolean | undefined): Query<NostrEvent[]> {
return {
key: pubkey + locked,
run: (events) => {
const updates = events.updates.pipe(filter((e) => e.kind === WALLET_TOKEN_KIND && e.pubkey === pubkey));
const timeline = events.timeline({ kinds: [WALLET_TOKEN_KIND], authors: [pubkey] });

return combineLatest([updates, timeline]).pipe(
map(([_, tokens]) => {
if (locked !== undefined) return tokens.filter((t) => isTokenDetailsLocked(t) === locked);
else return tokens;
}),
);
},
};
}

/** A query that returns the visible balance of a wallet for each mint */
export function WalletBalanceQuery(pubkey: string): Query<Record<string, number>> {
return {
key: pubkey,
run: (events) => {
const updates = events.updates.pipe(filter((e) => e.kind === WALLET_TOKEN_KIND && e.pubkey === pubkey));
const timeline = events.timeline({ kinds: [WALLET_TOKEN_KIND], authors: [pubkey] });

return combineLatest([updates, timeline]).pipe(
map(([_, tokens]) => {
const deleted = new Set<string>();

return (
tokens
// count the tokens from newest to oldest (so del gets applied correctly)
.reverse()
.reduce(
(totals, token) => {
// skip this event if it a newer event says its deleted
if (deleted.has(token.id)) return totals;
// skip if token is locked
if (isTokenDetailsLocked(token)) return totals;

const details = getTokenDetails(token);
if (!details) return totals;

// add deleted ids
for (const id of details.del) deleted.add(id);

const total = details.proofs.reduce((t, p) => t + p.amount, 0);
return { ...totals, [details.mint]: (totals[details.mint] ?? 0) + total };
},
{} as Record<string, number>,
)
);
}),
);
},
};
}

0 comments on commit ad83de5

Please sign in to comment.