${keyed(
this.currentRoute.params,
diff --git a/src/style.css b/src/style.css
index a6a8efb..bb02569 100644
--- a/src/style.css
+++ b/src/style.css
@@ -43,6 +43,8 @@
--border: 2px;
--depth: 1;
--noise: 1;
+
+ --font-mono: 'SF Mono', 'Monaco', 'Inconsolata', 'Fira Mono', 'Droid Sans Mono', 'Source Code Pro', monospace;
}
body.dark {
diff --git a/src/wallet.ts b/src/wallet.ts
new file mode 100644
index 0000000..3d1fc09
--- /dev/null
+++ b/src/wallet.ts
@@ -0,0 +1,306 @@
+import { CashuMint, CashuWallet, type Proof, type SendOptions } from '@cashu/cashu-ts';
+import { bytesToHex } from '@noble/ciphers/utils';
+import { secp256k1 } from '@noble/curves/secp256k1';
+import { NDKEvent, type NDKEventId, NDKKind, type NDKTag, type NDKUser } from '@nostr-dev-kit/ndk';
+import { getSigner, ndk } from './ndk';
+
+import { State, property } from '@lit-app/state';
+
+export type WalletHistory = {
+ id: string;
+ amount: number;
+ created_at: number;
+ direction: 'in' | 'out';
+ tokenEventId: NDKEventId;
+ nutZapEventId?: NDKEventId;
+ senderPubkey?: string;
+ recipientPubkey: string;
+};
+
+class Wallet extends State {
+ static mintUrl = 'https://testnut.cashu.space'; // TODO: this should be user controlled
+
+ private walletEvent: NDKEvent | null = null;
+ private cashuWallet: CashuWallet = new CashuWallet(new CashuMint(Wallet.mintUrl));
+
+ private proofs: Proof[] = [];
+ private eventIdToProofIdMap: Map = new Map();
+
+ private isHandlingZapEvent = false;
+ private zapEventsQueue: NDKEvent[] = [];
+ private latestHistoryTimestamp = 0;
+
+ @property() public balance = 0;
+ @property() public history: WalletHistory[] = [];
+ @property() public sortedHistory: WalletHistory[] = [];
+
+ async loadWallet() {
+ if (this.walletEvent) return;
+ await getSigner();
+ const user = await ndk.signer!.user()!;
+ const privateWalletEvent = await ndk.fetchEvent({
+ kinds: [17375 as NDKKind],
+ authors: [user.pubkey],
+ });
+ if (!privateWalletEvent) {
+ const walletPrivateKey = secp256k1.utils.randomPrivateKey();
+ const walletPublicKey = secp256k1.getPublicKey(walletPrivateKey);
+ const newPrivateWalletEvent = new NDKEvent(ndk);
+ newPrivateWalletEvent.kind = NDKKind.CashuWallet;
+ newPrivateWalletEvent.content = await ndk.signer!.encrypt(
+ user,
+ JSON.stringify([
+ ['privkey', bytesToHex(walletPrivateKey)],
+ ['mint', Wallet.mintUrl],
+ ]),
+ 'nip44',
+ );
+ const publicWalletEvent = new NDKEvent(ndk);
+ publicWalletEvent.kind = NDKKind.CashuMintList;
+ publicWalletEvent.content = '';
+ publicWalletEvent.tags = [
+ ['mint', Wallet.mintUrl, 'sat'],
+ ['pubkey', bytesToHex(walletPublicKey)],
+ ];
+ await newPrivateWalletEvent.sign();
+ await newPrivateWalletEvent.publish();
+ await publicWalletEvent.sign();
+ await publicWalletEvent.publish();
+ this.walletEvent = newPrivateWalletEvent;
+ } else this.walletEvent = privateWalletEvent;
+
+ const walletHistory = await ndk.fetchEvents({
+ kinds: [NDKKind.CashuWalletTx],
+ authors: [user.pubkey],
+ since: this.walletEvent?.created_at,
+ });
+
+ for (const event of walletHistory) {
+ await this.processHistoryEvent(event);
+ }
+
+ const proofsEvents = await ndk.fetchEvents({
+ kinds: [NDKKind.CashuToken],
+ authors: [user.pubkey],
+ });
+
+ for (const proofsEvent of proofsEvents) {
+ const {
+ mint,
+ proofs = [],
+ del = [],
+ } = JSON.parse(await ndk.signer!.decrypt(user, proofsEvent.content, 'nip44')) as {
+ mint: string;
+ proofs: Proof[];
+ del?: NDKEventId[];
+ };
+
+ if (mint !== this.cashuWallet.mint.mintUrl) continue;
+
+ for (const eventId of del) {
+ const toDeletes = this.eventIdToProofIdMap.get(eventId);
+ if (toDeletes)
+ for (const proof of toDeletes)
+ this.proofs = this.proofs.filter((p) => p.secret !== proof[0] || p.C !== proof[1]);
+ this.eventIdToProofIdMap.delete(eventId);
+ }
+
+ for (const proof of proofs) this.proofs.push(proof);
+
+ this.eventIdToProofIdMap.set(
+ proofsEvent.id,
+ proofs.map((p) => [p.secret, p.C]),
+ );
+ }
+
+ ndk
+ .subscribe({
+ kinds: [NDKKind.Nutzap],
+ '#p': [user.pubkey],
+ since: this.latestHistoryTimestamp,
+ })
+ .on('event', async (event) => {
+ this.zapEventsQueue.push(event);
+ this.handleZapEvent();
+ });
+
+ ndk
+ .subscribe({
+ kinds: [NDKKind.CashuWalletTx],
+ authors: [user.pubkey],
+ since: this.latestHistoryTimestamp,
+ })
+ .on('event', async (event) => {
+ await this.processHistoryEvent(event);
+ });
+
+ this.recalculateBalance();
+ }
+
+ private async processHistoryEvent(event: NDKEvent) {
+ const user = await ndk.signer!.user()!;
+ const parsedContent = JSON.parse(await ndk.signer!.decrypt(user, event.content, 'nip44'));
+ const direction = parsedContent.find((p: [string, string]) => p[0] === 'direction')?.[1];
+ const amount = parsedContent.find((p: [string, string]) => p[0] === 'amount')?.[1];
+ const tokenEventId = parsedContent.find((p: [string, string]) => p[0] === 'e')?.[1];
+ const nutZapEventId = event.tags.find((t: NDKTag) => t[0] === 'e')?.[1];
+ const senderPubkey = event.tags.find((t: NDKTag) => t[0] === 'p')?.[1];
+ if (!nutZapEventId || !senderPubkey) return;
+ this.history.push({
+ id: event.id,
+ amount: Number.parseInt(amount),
+ created_at: event.created_at ?? 0,
+ direction: direction as 'in' | 'out',
+ tokenEventId,
+ nutZapEventId: nutZapEventId as NDKEventId,
+ senderPubkey,
+ recipientPubkey: event.pubkey,
+ });
+ this.latestHistoryTimestamp = Math.max(this.latestHistoryTimestamp, event.created_at ?? 0);
+ this.sortedHistory = [...this.history.sort((a, b) => b.created_at - a.created_at)];
+ }
+
+ private async handleZapEvent() {
+ if (this.isHandlingZapEvent) return;
+ const event = this.zapEventsQueue.shift();
+ if (!event) return;
+ this.isHandlingZapEvent = true;
+ const proofs = JSON.parse(event.tags.find((t) => t[0] === 'proof')?.[1] ?? '[]') as Proof[];
+ this.proofs.push(...proofs);
+
+ const newProofsEvent = await this.createProofsEvent();
+ await this.createHistoryEvent(
+ newProofsEvent,
+ this.proofs.reduce((acc, curr) => acc + curr.amount, 0),
+ 'in',
+ );
+ this.recalculateBalance();
+ this.isHandlingZapEvent = false;
+ }
+
+ private async createProofsEvent(): Promise {
+ const newProofsEvent = new NDKEvent(ndk);
+ newProofsEvent.kind = NDKKind.CashuToken;
+ newProofsEvent.content = await ndk.signer?.encrypt(
+ await ndk.signer!.user()!,
+ JSON.stringify({
+ mint: this.cashuWallet.mint.mintUrl,
+ proofs: this.proofs,
+ del: Array.from(this.eventIdToProofIdMap.keys()),
+ }),
+ 'nip44',
+ )!;
+ await newProofsEvent.sign();
+ await newProofsEvent.publish();
+ return newProofsEvent;
+ }
+
+ recalculateBalance() {
+ this.proofs = this.proofs.filter(
+ (p, index, self) => index === self.findIndex((t) => t.secret === p.secret && t.C === p.C),
+ );
+ this.balance = this.proofs.reduce((acc, curr) => acc + curr.amount, 0);
+ }
+
+ private async createHistoryEvent(newProofsEvent: NDKEvent, amount: number, direction: 'in' | 'out') {
+ const historyEvent = new NDKEvent(ndk);
+ historyEvent.kind = NDKKind.CashuWalletTx;
+ historyEvent.content = await ndk.signer?.encrypt(
+ await ndk.signer!.user()!,
+ JSON.stringify([
+ ['direction', direction],
+ ['amount', amount.toString()],
+ ['e', newProofsEvent.id],
+ ]),
+ 'nip44',
+ )!;
+ historyEvent.tags = [
+ ['e', newProofsEvent.id],
+ ['p', newProofsEvent.pubkey],
+ ];
+ await historyEvent.sign();
+ await historyEvent.publish();
+ }
+
+ /** @throws Error if token is invalid */
+ async receiveToken(token: string) {
+ const proofs = await this.cashuWallet.receive(token);
+ if (proofs.length === 0) throw new Error('Invalid token');
+ for (const proof of proofs) this.proofs.push(proof);
+ const newProofsEvent = await this.createProofsEvent();
+ const amount = proofs.reduce((acc, curr) => acc + curr.amount, 0);
+ await this.createHistoryEvent(newProofsEvent, amount, 'in');
+ this.recalculateBalance();
+ }
+
+ private async createToken(amount: number, recipient?: NDKUser) {
+ if (amount < 10) throw new Error('Amount must be greater than 10');
+ const sendOptions: SendOptions = {
+ includeFees: true,
+ };
+ if (recipient) {
+ const mintListEvent = await ndk.fetchEvent({
+ kinds: [NDKKind.CashuMintList],
+ authors: [recipient.pubkey],
+ });
+ if (!mintListEvent) throw new Error('Recipient has no mint list');
+ const pubkey = mintListEvent.tags.find((t) => t[0] === 'pubkey')?.[1];
+ if (!pubkey) throw new Error('Recipient has no mint list');
+ sendOptions.pubkey = pubkey;
+ }
+ await this.cashuWallet.getKeySets();
+ const sendResult = await this.cashuWallet.send(amount, this.proofs, sendOptions);
+ this.proofs = sendResult.keep;
+ const proofsEvent = new NDKEvent(ndk);
+ proofsEvent.kind = NDKKind.CashuToken;
+ proofsEvent.content = await ndk.signer?.encrypt(
+ await ndk.signer!.user()!,
+ JSON.stringify({
+ mint: this.cashuWallet.mint.mintUrl,
+ proofs: this.proofs,
+ del: Array.from(this.eventIdToProofIdMap.keys()),
+ }),
+ 'nip44',
+ )!;
+ await proofsEvent.sign();
+ await proofsEvent.publish();
+ this.recalculateBalance();
+ return sendResult.send;
+ }
+
+ async nutZap(amount: number, recipient: NDKUser, eventId?: NDKEventId, message?: string) {
+ const token = await this.createToken(amount, recipient);
+ const zapEvent = new NDKEvent(ndk);
+ zapEvent.kind = NDKKind.Nutzap;
+ zapEvent.content = message ?? '';
+ zapEvent.tags = [
+ ['proof', JSON.stringify(token)],
+ ['u', Wallet.mintUrl],
+ ['p', recipient.pubkey],
+ ];
+ if (eventId) zapEvent.tags.push(['e', eventId]);
+ await zapEvent.sign();
+ await zapEvent.publish();
+
+ const spendingHistoryEvent = new NDKEvent(ndk);
+ spendingHistoryEvent.kind = NDKKind.CashuWalletTx;
+ const historyContent = [
+ ['direction', 'out'],
+ ['amount', amount.toString()],
+ ];
+ if (eventId) historyContent.push(['e', eventId]);
+ spendingHistoryEvent.content = await ndk.signer?.encrypt(
+ await ndk.signer!.user()!,
+ JSON.stringify(historyContent),
+ 'nip44',
+ )!;
+ spendingHistoryEvent.tags = [
+ ['e', zapEvent.id],
+ ['p', recipient.pubkey],
+ ];
+ await spendingHistoryEvent.sign();
+ await spendingHistoryEvent.publish();
+ }
+}
+
+export const wallet = new Wallet();