cashu wallet (beta) (testnet)
This commit is contained in:
parent
9a125e3111
commit
985c1494b5
8 changed files with 637 additions and 24 deletions
|
@ -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`
|
||||
<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="post__sidebar">
|
||||
<arx-nostr-profile
|
||||
|
@ -248,7 +282,7 @@ export class ForumPost extends LitElement {
|
|||
></iconify-icon>
|
||||
</arx-button>
|
||||
|
||||
<arx-button label="Zap" @click=${this._handleZap} disabled>
|
||||
<arx-button label="Zap" @click=${this._handleZap}>
|
||||
<iconify-icon
|
||||
slot="prefix"
|
||||
icon="mdi:lightning-bolt"
|
||||
|
|
100
src/components/WalletTransactionLine.ts
Normal file
100
src/components/WalletTransactionLine.ts
Normal 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>`;
|
||||
}
|
||||
}
|
82
src/components/Widgets/WalletWidget.ts
Normal file
82
src/components/Widgets/WalletWidget.ts
Normal 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>
|
||||
`;
|
||||
}
|
||||
}
|
|
@ -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() {
|
||||
|
|
78
src/routes/Wallet.ts
Normal file
78
src/routes/Wallet.ts
Normal 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>
|
||||
`;
|
||||
}
|
||||
}
|
|
@ -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: {},
|
||||
|
|
|
@ -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 {
|
||||
|
|
306
src/wallet.ts
Normal file
306
src/wallet.ts
Normal 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();
|
Loading…
Add table
Reference in a new issue