cashu.service.ts raw

   1  import { Injectable } from '@angular/core';
   2  import {
   3    CashuMint as Mint,
   4    CashuWallet as Wallet,
   5    getDecodedToken,
   6    getEncodedTokenV4,
   7    Token,
   8    Proof,
   9    CheckStateEnum,
  10  } from '@cashu/cashu-ts';
  11  import { StorageService, CashuMint_DECRYPTED, CashuProof } from '@common';
  12  import {
  13    CashuReceiveResult,
  14    CashuSendResult,
  15    DecodedCashuToken,
  16    CashuMintInfo,
  17    CashuMintQuote,
  18    CashuMintResult,
  19    MintQuoteState,
  20  } from './types';
  21  
  22  interface CachedWallet {
  23    wallet: Wallet;
  24    mint: Mint;
  25    mintId: string;
  26  }
  27  
  28  /**
  29   * Angular service for managing Cashu ecash wallets
  30   */
  31  @Injectable({
  32    providedIn: 'root',
  33  })
  34  export class CashuService {
  35    private wallets = new Map<string, CachedWallet>();
  36  
  37    constructor(private storageService: StorageService) {}
  38  
  39    /**
  40     * Get all Cashu mints from storage
  41     */
  42    getMints(): CashuMint_DECRYPTED[] {
  43      const sessionData =
  44        this.storageService.getBrowserSessionHandler().browserSessionData;
  45      return sessionData?.cashuMints ?? [];
  46    }
  47  
  48    /**
  49     * Get a single Cashu mint by ID
  50     */
  51    getMint(mintId: string): CashuMint_DECRYPTED | undefined {
  52      return this.getMints().find((m) => m.id === mintId);
  53    }
  54  
  55    /**
  56     * Get a mint by URL
  57     */
  58    getMintByUrl(mintUrl: string): CashuMint_DECRYPTED | undefined {
  59      const normalizedUrl = mintUrl.replace(/\/$/, '');
  60      return this.getMints().find((m) => m.mintUrl === normalizedUrl);
  61    }
  62  
  63    /**
  64     * Add a new Cashu mint connection
  65     */
  66    async addMint(name: string, mintUrl: string): Promise<CashuMint_DECRYPTED> {
  67      // Test the mint connection first
  68      await this.testMintConnection(mintUrl);
  69  
  70      // Add to storage
  71      return await this.storageService.addCashuMint({
  72        name,
  73        mintUrl,
  74        unit: 'sat',
  75      });
  76    }
  77  
  78    /**
  79     * Delete a Cashu mint connection
  80     */
  81    async deleteMint(mintId: string): Promise<void> {
  82      // Remove from cache
  83      this.wallets.delete(mintId);
  84      await this.storageService.deleteCashuMint(mintId);
  85    }
  86  
  87    /**
  88     * Get or create a wallet for a mint
  89     */
  90    private async getWallet(mintId: string): Promise<CachedWallet> {
  91      // Check cache
  92      const cached = this.wallets.get(mintId);
  93      if (cached) {
  94        return cached;
  95      }
  96  
  97      // Get mint data from storage
  98      const mintData = this.getMint(mintId);
  99      if (!mintData) {
 100        throw new Error('Mint not found');
 101      }
 102  
 103      // Create mint and wallet instances
 104      const mint = new Mint(mintData.mintUrl);
 105      const wallet = new Wallet(mint, { unit: mintData.unit || 'sat' });
 106  
 107      // Load mint keys
 108      await wallet.loadMint();
 109  
 110      // Cache it
 111      const cachedWallet: CachedWallet = {
 112        wallet,
 113        mint,
 114        mintId,
 115      };
 116      this.wallets.set(mintId, cachedWallet);
 117  
 118      return cachedWallet;
 119    }
 120  
 121    /**
 122     * Test a mint connection by fetching its info
 123     */
 124    async testMintConnection(mintUrl: string): Promise<CashuMintInfo> {
 125      const normalizedUrl = mintUrl.replace(/\/$/, '');
 126      const mint = new Mint(normalizedUrl);
 127      const info = await mint.getInfo();
 128      return {
 129        name: info.name,
 130        description: info.description,
 131        version: info.version,
 132        contact: info.contact?.map((c) => ({ method: c.method, info: c.info })),
 133        nuts: info.nuts,
 134      };
 135    }
 136  
 137    /**
 138     * Decode a Cashu token without claiming it
 139     */
 140    decodeToken(token: string): DecodedCashuToken | null {
 141      try {
 142        const decoded = getDecodedToken(token);
 143        const proofs = decoded.proofs;
 144        const amount = proofs.reduce((sum, p) => sum + p.amount, 0);
 145  
 146        return {
 147          mint: decoded.mint,
 148          unit: decoded.unit || 'sat',
 149          amount,
 150          proofs,
 151        };
 152      } catch {
 153        return null;
 154      }
 155    }
 156  
 157    /**
 158     * Receive a Cashu token
 159     * This validates and claims the proofs, then stores them
 160     */
 161    async receive(token: string): Promise<CashuReceiveResult> {
 162      // Decode the token
 163      const decoded = this.decodeToken(token);
 164      if (!decoded) {
 165        throw new Error('Invalid token format');
 166      }
 167  
 168      // Check if we have this mint
 169      let mintData = this.getMintByUrl(decoded.mint);
 170  
 171      // If we don't have this mint, add it automatically
 172      if (!mintData) {
 173        // Use the mint URL as the name initially
 174        const urlObj = new URL(decoded.mint);
 175        mintData = await this.storageService.addCashuMint({
 176          name: urlObj.hostname,
 177          mintUrl: decoded.mint,
 178          unit: decoded.unit || 'sat',
 179        });
 180      }
 181  
 182      // Get the wallet for this mint
 183      const { wallet } = await this.getWallet(mintData.id);
 184  
 185      // Receive the token (this swaps proofs with the mint)
 186      const receivedProofs = await wallet.receive(token);
 187  
 188      // Convert to our proof format with timestamp
 189      const now = new Date().toISOString();
 190      const newProofs: CashuProof[] = receivedProofs.map((p: Proof) => ({
 191        id: p.id,
 192        amount: p.amount,
 193        secret: p.secret,
 194        C: p.C,
 195        receivedAt: now,
 196      }));
 197  
 198      // Merge with existing proofs
 199      const existingProofs = mintData!.proofs || [];
 200      const allProofs = [...existingProofs, ...newProofs];
 201  
 202      // Update storage
 203      await this.storageService.updateCashuMintProofs(mintData!.id, allProofs);
 204  
 205      // Calculate received amount
 206      const amount = newProofs.reduce((sum, p) => sum + p.amount, 0);
 207  
 208      return {
 209        amount,
 210        mintUrl: decoded.mint,
 211        mintId: mintData!.id,
 212      };
 213    }
 214  
 215    /**
 216     * Send Cashu tokens
 217     * Creates an encoded token from existing proofs
 218     */
 219    async send(mintId: string, amount: number): Promise<CashuSendResult> {
 220      const mintData = this.getMint(mintId);
 221      if (!mintData) {
 222        throw new Error('Mint not found');
 223      }
 224  
 225      // Check we have enough balance
 226      const balance = this.getBalance(mintId);
 227      if (balance < amount) {
 228        throw new Error(`Insufficient balance. Have ${balance} sats, need ${amount} sats`);
 229      }
 230  
 231      // Get the wallet
 232      const { wallet } = await this.getWallet(mintId);
 233  
 234      // Convert our proofs to the format cashu-ts expects
 235      const proofs: Proof[] = mintData.proofs.map((p) => ({
 236        id: p.id,
 237        amount: p.amount,
 238        secret: p.secret,
 239        C: p.C,
 240      }));
 241  
 242      // Send - this returns send proofs and keep proofs (change)
 243      const { send, keep } = await wallet.send(amount, proofs);
 244  
 245      // Create the token to share
 246      const token: Token = {
 247        mint: mintData.mintUrl,
 248        proofs: send,
 249        unit: mintData.unit || 'sat',
 250      };
 251      const encodedToken = getEncodedTokenV4(token);
 252  
 253      // Update our stored proofs to only keep the change (new proofs from mint)
 254      const now = new Date().toISOString();
 255      const keepProofs: CashuProof[] = keep.map((p: Proof) => ({
 256        id: p.id,
 257        amount: p.amount,
 258        secret: p.secret,
 259        C: p.C,
 260        receivedAt: now,
 261      }));
 262  
 263      await this.storageService.updateCashuMintProofs(mintId, keepProofs);
 264  
 265      return {
 266        token: encodedToken,
 267        amount,
 268      };
 269    }
 270  
 271    /**
 272     * Check if any proofs have been spent
 273     * Removes spent proofs from storage
 274     */
 275    async checkProofsSpent(mintId: string): Promise<number> {
 276      const mintData = this.getMint(mintId);
 277      if (!mintData) {
 278        throw new Error('Mint not found');
 279      }
 280  
 281      if (mintData.proofs.length === 0) {
 282        return 0;
 283      }
 284  
 285      const { wallet } = await this.getWallet(mintId);
 286  
 287      // Only the secret field is needed for checking proof states
 288      const proofsToCheck = mintData.proofs.map((p) => ({ secret: p.secret })) as any;
 289  
 290      // Check which proofs are spent using v3 API
 291      const proofStates = await wallet.checkProofsStates(proofsToCheck);
 292  
 293      // Filter out spent proofs
 294      const unspentProofs: CashuProof[] = [];
 295      let removedAmount = 0;
 296  
 297      for (let i = 0; i < mintData.proofs.length; i++) {
 298        if (proofStates[i].state !== CheckStateEnum.SPENT) {
 299          unspentProofs.push(mintData.proofs[i]);
 300        } else {
 301          removedAmount += mintData.proofs[i].amount;
 302        }
 303      }
 304  
 305      // Update storage if any were spent
 306      if (removedAmount > 0) {
 307        await this.storageService.updateCashuMintProofs(mintId, unspentProofs);
 308      }
 309  
 310      return removedAmount;
 311    }
 312  
 313    /**
 314     * Create a mint quote (Lightning invoice) for depositing sats
 315     * Returns a Lightning invoice that when paid will allow minting tokens
 316     */
 317    async createMintQuote(mintId: string, amount: number): Promise<CashuMintQuote> {
 318      const mintData = this.getMint(mintId);
 319      if (!mintData) {
 320        throw new Error('Mint not found');
 321      }
 322  
 323      if (amount <= 0) {
 324        throw new Error('Amount must be greater than 0');
 325      }
 326  
 327      const { wallet } = await this.getWallet(mintId);
 328  
 329      // Create a mint quote - this returns a Lightning invoice
 330      const quote = await wallet.createMintQuote(amount);
 331  
 332      return {
 333        quoteId: quote.quote,
 334        invoice: quote.request,
 335        amount: amount,
 336        state: quote.state as MintQuoteState,
 337        expiry: quote.expiry,
 338      };
 339    }
 340  
 341    /**
 342     * Check the status of a mint quote
 343     * Returns the current state (UNPAID, PAID, ISSUED)
 344     */
 345    async checkMintQuote(mintId: string, quoteId: string): Promise<CashuMintQuote> {
 346      const mintData = this.getMint(mintId);
 347      if (!mintData) {
 348        throw new Error('Mint not found');
 349      }
 350  
 351      const { wallet } = await this.getWallet(mintId);
 352  
 353      // Check the quote status
 354      const quote = await wallet.checkMintQuote(quoteId);
 355  
 356      return {
 357        quoteId: quote.quote,
 358        invoice: quote.request,
 359        amount: 0, // Amount not returned in check response
 360        state: quote.state as MintQuoteState,
 361        expiry: quote.expiry,
 362      };
 363    }
 364  
 365    /**
 366     * Mint tokens after paying the Lightning invoice
 367     * This claims the tokens and stores them
 368     */
 369    async mintTokens(mintId: string, amount: number, quoteId: string): Promise<CashuMintResult> {
 370      const mintData = this.getMint(mintId);
 371      if (!mintData) {
 372        throw new Error('Mint not found');
 373      }
 374  
 375      const { wallet } = await this.getWallet(mintId);
 376  
 377      // Mint the proofs
 378      const mintedProofs = await wallet.mintProofs(amount, quoteId);
 379  
 380      // Convert to our proof format with timestamp
 381      const now = new Date().toISOString();
 382      const newProofs: CashuProof[] = mintedProofs.map((p: Proof) => ({
 383        id: p.id,
 384        amount: p.amount,
 385        secret: p.secret,
 386        C: p.C,
 387        receivedAt: now,
 388      }));
 389  
 390      // Merge with existing proofs
 391      const existingProofs = mintData.proofs || [];
 392      const allProofs = [...existingProofs, ...newProofs];
 393  
 394      // Update storage
 395      await this.storageService.updateCashuMintProofs(mintId, allProofs);
 396  
 397      // Calculate minted amount
 398      const mintedAmount = newProofs.reduce((sum, p) => sum + p.amount, 0);
 399  
 400      return {
 401        amount: mintedAmount,
 402        mintId: mintId,
 403      };
 404    }
 405  
 406    /**
 407     * Get balance for a specific mint (in satoshis)
 408     */
 409    getBalance(mintId: string): number {
 410      const mintData = this.getMint(mintId);
 411      if (!mintData) {
 412        return 0;
 413      }
 414      return mintData.proofs.reduce((sum, p) => sum + p.amount, 0);
 415    }
 416  
 417    /**
 418     * Get proofs for a specific mint
 419     */
 420    getProofs(mintId: string): CashuProof[] {
 421      const mintData = this.getMint(mintId);
 422      if (!mintData) {
 423        return [];
 424      }
 425      return mintData.proofs;
 426    }
 427  
 428    /**
 429     * Get total balance across all mints (in satoshis)
 430     */
 431    getTotalBalance(): number {
 432      const mints = this.getMints();
 433      return mints.reduce((sum, m) => sum + this.getBalance(m.id), 0);
 434    }
 435  
 436    /**
 437     * Get cached total balance (same as getTotalBalance for Cashu since it's all local)
 438     */
 439    getCachedTotalBalance(): number {
 440      return this.getTotalBalance();
 441    }
 442  
 443    /**
 444     * Format a balance for display (Cashu uses satoshis, not millisatoshis)
 445     */
 446    formatBalance(sats: number | undefined): string {
 447      if (sats === undefined) return '—';
 448      return sats.toLocaleString('en-US');
 449    }
 450  }
 451