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} + >
- + + + ${this.transaction.direction === 'in' ? '+' : '-'} ${satsComma(this.transaction.amount)} + sats + + ${formatDateTime(this.transaction.created_at * 1000)} +
+ + +
+
`; + } +} 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` +
+

+ Bitcoin Wallet +

+
+ '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}

+
-
- ${map(this.widgets, (widget) => html`<${widget.content}>`)} + + +
+ ${map(this.widgets, (widget) => html`<${widget.content}>`)}
`; 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();