diff --git a/src/components/Arbor/ForumPost.ts b/src/components/Arbor/ForumPost.ts
index 280c5b1..3558b53 100644
--- a/src/components/Arbor/ForumPost.ts
+++ b/src/components/Arbor/ForumPost.ts
@@ -1,8 +1,12 @@
+import { wallet } from '@/wallet';
+import { StateController } from '@lit-app/state';
+import type { NDKUser } from '@nostr-dev-kit/ndk';
import formatDateTime from '@utils/formatDateTime';
import { LitElement, css, html } from 'lit';
-import { customElement, property } from 'lit/decorators.js';
+import { customElement, property, state } from 'lit/decorators.js';
import { classMap } from 'lit/directives/class-map.js';
+import { ndk } from '@/ndk';
import '@components/MarkdownContent';
@customElement('arx-forum-post')
@@ -14,6 +18,16 @@ export class ForumPost extends LitElement {
@property({ type: String }) content = '';
@property({ type: Boolean }) isHighlighted = false;
+ @state() private zapAmountDialogOpen = false;
+ @state() public authorProfile: NDKUser | undefined = undefined;
+
+ override connectedCallback(): void {
+ super.connectedCallback();
+ this.authorProfile = ndk.getUser({ pubkey: this.npub });
+ new StateController(this, wallet);
+ wallet.loadWallet();
+ }
+
static override styles = [
css`
.post {
@@ -171,14 +185,24 @@ export class ForumPost extends LitElement {
}
private _handleZap() {
- alert('Zapping is not yet implemented');
- this.dispatchEvent(
- new CustomEvent('zap', {
- detail: { postId: this.id, npub: this.npub },
- bubbles: true,
- composed: true,
- }),
- );
+ // setting to false and then to true forces the dialog to open, even when it wasn't closed correctly
+ this.zapAmountDialogOpen = false;
+ setTimeout(() => {
+ this.zapAmountDialogOpen = true;
+ }, 0);
+ }
+
+ private async _doZap(e: Event) {
+ if (!(e instanceof CustomEvent)) return;
+ e.preventDefault();
+ const zapAmount = Number.parseInt(e.detail.value);
+ if (Number.isNaN(zapAmount) || zapAmount <= 10) {
+ alert('Zap amount must be greater or equal to 10');
+ return;
+ }
+ await wallet.nutZap(zapAmount, this.authorProfile!, this.id);
+
+ this.zapAmountDialogOpen = false;
}
private _handleDownzap() {
@@ -206,6 +230,16 @@ export class ForumPost extends LitElement {
};
return html`
+ {
+ this.zapAmountDialogOpen = false;
+ }}
+ showInput
+ .open=${this.zapAmountDialogOpen}
+ >
`;
+ }
+}
diff --git a/src/components/Widgets/WalletWidget.ts b/src/components/Widgets/WalletWidget.ts
new file mode 100644
index 0000000..492b895
--- /dev/null
+++ b/src/components/Widgets/WalletWidget.ts
@@ -0,0 +1,82 @@
+import satsComma from '@/utils/satsComma';
+import { wallet } from '@/wallet';
+import '@components/General/Fieldset';
+import '@components/LoadingView';
+import '@components/WalletTransactionLine';
+import { StateController } from '@lit-app/state';
+import { LitElement, css, html } from 'lit';
+import { customElement, state } from 'lit/decorators.js';
+import { when } from 'lit/directives/when.js';
+
+@customElement('arx-wallet-widget')
+export class WalletWidget extends LitElement {
+ @state()
+ public loading = false;
+
+ override connectedCallback(): void {
+ super.connectedCallback();
+ new StateController(this, wallet);
+ }
+
+ override async firstUpdated() {
+ this.loading = true;
+ await wallet.loadWallet();
+ this.loading = false;
+ }
+
+ static override styles = [
+ css`
+ :host {
+ display: block;
+ }
+
+ .widget-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ margin-bottom: 12px;
+ border-bottom: var(--border) solid var(--color-base-300);
+ padding-bottom: 8px;
+ }
+
+ .widget-title {
+ font-size: 1.2rem;
+ font-weight: 600;
+ margin: 0;
+ color: var(--color-base-content);
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ }
+
+ .bitcoin-icon {
+ color: var(--color-warning);
+ font-size: 1.4rem;
+ }
+ `,
+ ];
+
+ override render() {
+ return html`
+
+
'Loading...',
+ () => `${satsComma(wallet.balance)} sats`,
+ )}>
+ ${when(
+ wallet.sortedHistory.length > 0,
+ () => html`
+ Latest Transaction:
+
+ `,
+ () => 'No transactions yet',
+ )}
+
+ `;
+ }
+}
diff --git a/src/routes/Home.ts b/src/routes/Home.ts
index 40787d9..3950b8e 100644
--- a/src/routes/Home.ts
+++ b/src/routes/Home.ts
@@ -9,6 +9,7 @@ import '@components/AppGrid';
import '@components/General/Card';
import '@components/NostrAvatar';
import '@components/Widgets/BitcoinBlockWidget';
+import '@components/Widgets/WalletWidget';
@customElement('arx-eve-home')
export class Home extends LitElement {
@@ -83,6 +84,10 @@ export class Home extends LitElement {
title: 'Bitcoin Block',
content: literal`arx-bitcoin-block-widget`,
},
+ {
+ title: 'Bitcoin Wallet',
+ content: literal`arx-wallet-widget`,
+ },
];
async loadProperties() {
@@ -184,21 +189,21 @@ export class Home extends LitElement {
override render() {
return html`
-
-
-
-
-
-
Welcome, ${this.username}
-
-
-
+
+
+
+
+
+
Welcome, ${this.username}
+
-
`;
diff --git a/src/routes/Wallet.ts b/src/routes/Wallet.ts
new file mode 100644
index 0000000..8d3d54d
--- /dev/null
+++ b/src/routes/Wallet.ts
@@ -0,0 +1,78 @@
+import { wallet } from '@/wallet';
+import type { ArxInputChangeEvent } from '@components/General/Input';
+import { StateController } from '@lit-app/state';
+import { LitElement, css, html } from 'lit';
+import { customElement } from 'lit/decorators.js';
+
+import '@components/General/Card';
+import '@components/General/Input';
+import '@components/WalletTransactionLine';
+
+@customElement('arx-wallet-route')
+export class WalletRoute extends LitElement {
+ static override styles = css`
+ h1 {
+ text-align: center;
+ }
+
+ .transaction-list {
+ background-color: var(--color-base-100);
+ border-radius: var(--radius-card);
+ padding: 20px;
+ box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
+ }
+
+ .transaction-list h3 {
+ color: var(--color-base-content);
+ margin-bottom: 20px;
+ }
+
+ arx-wallet-transaction-line:last-child {
+ border-bottom: none;
+ }
+ `;
+
+ private inputToken = '';
+
+ override async firstUpdated() {
+ await wallet.loadWallet();
+ }
+
+ async doReceiveToken() {
+ console.log('clicked', Date.now());
+ await wallet.receiveToken(this.inputToken);
+ this.inputToken = '';
+ }
+
+ override connectedCallback(): void {
+ super.connectedCallback();
+ new StateController(this, wallet);
+ }
+
+ override render() {
+ return html`
+
+ Wallet
+
+ You have ${wallet.balance} sats
+ {
+ if (!e.detail.value) return;
+ this.inputToken = e.detail.value;
+ }}
+ type="text"
+ id="token"
+ >
+
+
+
Transaction History
+ ${wallet.sortedHistory.map(
+ (h) => html`
`,
+ )}
+
+
+ `;
+ }
+}
diff --git a/src/routes/router.ts b/src/routes/router.ts
index 65f68e7..ab7a796 100644
--- a/src/routes/router.ts
+++ b/src/routes/router.ts
@@ -7,6 +7,7 @@ import '@routes/Arbor/TopicView';
import '@routes/Home';
import '@routes/Profile';
import '@routes/Settings';
+import '@routes/Wallet';
import { spread } from '@open-wc/lit-helpers';
import { LitElement, css } from 'lit';
@@ -65,6 +66,11 @@ export default class EveRouter extends LitElement {
params: {},
component: literal`arx-settings`,
},
+ {
+ pattern: 'wallet',
+ params: {},
+ component: literal`arx-wallet-route`,
+ },
{
pattern: '404',
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();