cashu wallet (beta) (testnet)

This commit is contained in:
Danny Morabito 2025-03-25 22:30:00 +01:00
parent 9a125e3111
commit 985c1494b5
Signed by: dannym
GPG key ID: 7CC8056A5A04557E
8 changed files with 637 additions and 24 deletions

View file

@ -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 formatDateTime from '@utils/formatDateTime';
import { LitElement, css, html } from 'lit'; 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 { classMap } from 'lit/directives/class-map.js';
import { ndk } from '@/ndk';
import '@components/MarkdownContent'; import '@components/MarkdownContent';
@customElement('arx-forum-post') @customElement('arx-forum-post')
@ -14,6 +18,16 @@ export class ForumPost extends LitElement {
@property({ type: String }) content = ''; @property({ type: String }) content = '';
@property({ type: Boolean }) isHighlighted = false; @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 = [ static override styles = [
css` css`
.post { .post {
@ -171,14 +185,24 @@ export class ForumPost extends LitElement {
} }
private _handleZap() { private _handleZap() {
alert('Zapping is not yet implemented'); // setting to false and then to true forces the dialog to open, even when it wasn't closed correctly
this.dispatchEvent( this.zapAmountDialogOpen = false;
new CustomEvent('zap', { setTimeout(() => {
detail: { postId: this.id, npub: this.npub }, this.zapAmountDialogOpen = true;
bubbles: true, }, 0);
composed: true, }
}),
); 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() { private _handleDownzap() {
@ -206,6 +230,16 @@ export class ForumPost extends LitElement {
}; };
return html` return html`
<arx-prompt
promptText="Zap amount"
placeholder="Enter zap amount"
@save=${this._doZap}
@cancel=${() => {
this.zapAmountDialogOpen = false;
}}
showInput
.open=${this.zapAmountDialogOpen}
></arx-prompt>
<div class=${classMap(postClasses)} id="post-${this.id}"> <div class=${classMap(postClasses)} id="post-${this.id}">
<div class="post__sidebar"> <div class="post__sidebar">
<arx-nostr-profile <arx-nostr-profile
@ -248,7 +282,7 @@ export class ForumPost extends LitElement {
></iconify-icon> ></iconify-icon>
</arx-button> </arx-button>
<arx-button label="Zap" @click=${this._handleZap} disabled> <arx-button label="Zap" @click=${this._handleZap}>
<iconify-icon <iconify-icon
slot="prefix" slot="prefix"
icon="mdi:lightning-bolt" icon="mdi:lightning-bolt"

View file

@ -0,0 +1,100 @@
import { StateController } from '@lit-app/state';
import { LitElement, css, html } from 'lit';
import { customElement, property } from 'lit/decorators.js';
import { type WalletHistory, wallet } from '@/wallet';
import formatDateTime from '@/utils/formatDateTime';
import satsComma from '@/utils/satsComma';
import '@components/General/Card';
import '@components/General/Input';
@customElement('arx-wallet-transaction-line')
export class WalletTransactionLine extends LitElement {
@property({ type: Object }) transaction!: WalletHistory;
override connectedCallback(): void {
super.connectedCallback();
new StateController(this, wallet);
}
static override styles = css`
:host {
display: block;
width: 100%;
border-bottom: 1px solid var(--color-base-200);
container-type: inline-size;
}
.transaction {
display: grid;
grid-template-columns: 200px 1fr auto;
justify-content: space-between;
align-items: center;
padding: 10px 0;
}
.in {
color: var(--color-success);
}
.out {
color: var(--color-error);
}
.amount {
text-align: right;
font-family: var(--font-mono);
font-weight: bold;
margin-right: 10px;
}
.date {
color: var(--color-secondary);
font-size: 0.9em;
}
.profiles {
display: flex;
gap: 10px;
}
.sender {
order: 1;
}
.recipient {
order: 2;
}
@container (max-width: 400px) {
.transaction {
grid-template-columns: 1fr;
}
.amount {
text-align: left;
}
}
`;
override render() {
return html`<div class="transaction ${this.transaction.direction}">
<span class="amount">
${this.transaction.direction === 'in' ? '+' : '-'} ${satsComma(this.transaction.amount)}
sats
</span>
<span class="date">${formatDateTime(this.transaction.created_at * 1000)}</span>
<div class="profiles">
<arx-nostr-profile
pubkey=${this.transaction.senderPubkey}
class="sender"
></arx-nostr-profile>
<arx-nostr-profile
pubkey=${this.transaction.recipientPubkey}
class="recipient"
></arx-nostr-profile>
</div>
</div>`;
}
}

View file

@ -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`
<div class="widget-header">
<h3 class="widget-title">
<span class="bitcoin-icon"></span> Bitcoin Wallet
</h3>
</div>
<arx-fieldset .legend=${when(
this.loading,
() => 'Loading...',
() => `${satsComma(wallet.balance)} sats`,
)}>
${when(
wallet.sortedHistory.length > 0,
() => html`
Latest Transaction:
<arx-wallet-transaction-line .transaction=${wallet.sortedHistory[0]}></arx-wallet-transaction-line>
`,
() => 'No transactions yet',
)}
</arx-fieldset>
`;
}
}

View file

@ -9,6 +9,7 @@ import '@components/AppGrid';
import '@components/General/Card'; import '@components/General/Card';
import '@components/NostrAvatar'; import '@components/NostrAvatar';
import '@components/Widgets/BitcoinBlockWidget'; import '@components/Widgets/BitcoinBlockWidget';
import '@components/Widgets/WalletWidget';
@customElement('arx-eve-home') @customElement('arx-eve-home')
export class Home extends LitElement { export class Home extends LitElement {
@ -83,6 +84,10 @@ export class Home extends LitElement {
title: 'Bitcoin Block', title: 'Bitcoin Block',
content: literal`arx-bitcoin-block-widget`, content: literal`arx-bitcoin-block-widget`,
}, },
{
title: 'Bitcoin Wallet',
content: literal`arx-wallet-widget`,
},
]; ];
async loadProperties() { async loadProperties() {

78
src/routes/Wallet.ts Normal file
View file

@ -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`
<arx-card>
<h1>Wallet</h1>
<hr />
<h2>You have ${wallet.balance} sats</h2>
<arx-input
label="Token"
.value=${this.inputToken}
@change=${(e: ArxInputChangeEvent) => {
if (!e.detail.value) return;
this.inputToken = e.detail.value;
}}
type="text"
id="token"
></arx-input>
<arx-button label="Receive" @click=${this.doReceiveToken}></arx-button>
<div class="transaction-list">
<h3>Transaction History</h3>
${wallet.sortedHistory.map(
(h) => html`<arx-wallet-transaction-line .transaction=${h}></arx-wallet-transaction-line>`,
)}
</div>
</arx-card>
`;
}
}

View file

@ -7,6 +7,7 @@ import '@routes/Arbor/TopicView';
import '@routes/Home'; import '@routes/Home';
import '@routes/Profile'; import '@routes/Profile';
import '@routes/Settings'; import '@routes/Settings';
import '@routes/Wallet';
import { spread } from '@open-wc/lit-helpers'; import { spread } from '@open-wc/lit-helpers';
import { LitElement, css } from 'lit'; import { LitElement, css } from 'lit';
@ -65,6 +66,11 @@ export default class EveRouter extends LitElement {
params: {}, params: {},
component: literal`arx-settings`, component: literal`arx-settings`,
}, },
{
pattern: 'wallet',
params: {},
component: literal`arx-wallet-route`,
},
{ {
pattern: '404', pattern: '404',
params: {}, params: {},

View file

@ -43,6 +43,8 @@
--border: 2px; --border: 2px;
--depth: 1; --depth: 1;
--noise: 1; --noise: 1;
--font-mono: 'SF Mono', 'Monaco', 'Inconsolata', 'Fira Mono', 'Droid Sans Mono', 'Source Code Pro', monospace;
} }
body.dark { body.dark {

306
src/wallet.ts Normal file
View file

@ -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<NDKEventId, [string, string][]> = 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<NDKEvent> {
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();