mirror of
https://github.com/purrgrammer/grimoire.git
synced 2026-04-11 07:56:50 +02:00
Implement NIP-60 wallet decryption and balance display
Add comprehensive NIP-60 Cashu wallet functionality: - Create wallet-utils.ts with NIP-60 data structures and helpers: * TypeScript interfaces for WalletConfig, UnspentTokens, Transaction * Decryption functions for wallet config, tokens, and history * Balance calculation and formatting utilities * Transaction sorting and mint display helpers - Enhance WalletViewer component with full decryption support: * Integrate with applesauce-accounts for NIP-44 decryption * Auto-decrypt wallet events when active account is available * Display actual balance with breakdown by mint * Show transaction history with type icons and timestamps * Visual status indicators (locked/unlocked states) * Loading states during decryption * Error handling for decryption failures - Features: * Privacy-first: only decrypt for own wallet * Reactive updates when wallet events change * Support for multiple mints per wallet * Transaction type indicators (mint, melt, send, receive) * Human-readable timestamps and amounts This enables users to view their NIP-60 Cashu wallet balance and transaction history directly in Grimoire, with full NIP-44 encryption support through applesauce-accounts.
This commit is contained in:
@@ -3,12 +3,14 @@ import { useGrimoire } from "@/core/state";
|
||||
import {
|
||||
Wallet,
|
||||
Lock,
|
||||
Download,
|
||||
Unlock,
|
||||
AlertCircle,
|
||||
Coins,
|
||||
History,
|
||||
ArrowUpRight,
|
||||
ArrowDownLeft,
|
||||
Zap,
|
||||
} from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
@@ -17,8 +19,21 @@ import {
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import { useMemo, useState } from "react";
|
||||
import type { NostrEvent } from "nostr-tools";
|
||||
import { useMemo, useState, useEffect } from "react";
|
||||
import accountManager from "@/services/accounts";
|
||||
import {
|
||||
decryptWalletConfig,
|
||||
decryptUnspentTokens,
|
||||
decryptTransactionHistory,
|
||||
calculateBalance,
|
||||
getTotalBalance,
|
||||
formatBalance,
|
||||
sortTransactions,
|
||||
getMintDisplayName,
|
||||
type WalletConfig,
|
||||
type UnspentTokens,
|
||||
type Transaction,
|
||||
} from "@/lib/wallet-utils";
|
||||
|
||||
export interface WalletViewerProps {
|
||||
pubkey: string;
|
||||
@@ -36,7 +51,13 @@ export interface WalletViewerProps {
|
||||
export function WalletViewer({ pubkey }: WalletViewerProps) {
|
||||
const { state } = useGrimoire();
|
||||
const eventStore = useEventStore();
|
||||
const [unlocked, setUnlocked] = useState(false);
|
||||
|
||||
// State for decrypted wallet data
|
||||
const [walletConfig, setWalletConfig] = useState<WalletConfig | null>(null);
|
||||
const [unspentTokens, setUnspentTokens] = useState<UnspentTokens[]>([]);
|
||||
const [transactions, setTransactions] = useState<Transaction[]>([]);
|
||||
const [decryptionError, setDecryptionError] = useState<string | null>(null);
|
||||
const [isDecrypting, setIsDecrypting] = useState(false);
|
||||
|
||||
// Resolve $me alias
|
||||
const resolvedPubkey =
|
||||
@@ -84,6 +105,87 @@ export function WalletViewer({ pubkey }: WalletViewerProps) {
|
||||
// Check if wallet exists
|
||||
const walletExists = walletConfigEvent !== undefined;
|
||||
|
||||
// Get active account from accountManager
|
||||
const activeAccount = use$(accountManager.active$);
|
||||
|
||||
// Decrypt wallet data when events are available (only for own wallet)
|
||||
useEffect(() => {
|
||||
if (!isOwnWallet || !activeAccount?.nip44) return;
|
||||
if (!walletConfigEvent) return;
|
||||
|
||||
const decryptWalletData = async () => {
|
||||
setIsDecrypting(true);
|
||||
setDecryptionError(null);
|
||||
|
||||
try {
|
||||
// Decrypt wallet config
|
||||
if (walletConfigEvent) {
|
||||
const config = await decryptWalletConfig(
|
||||
walletConfigEvent,
|
||||
activeAccount,
|
||||
);
|
||||
if (config) {
|
||||
setWalletConfig(config);
|
||||
}
|
||||
}
|
||||
|
||||
// Decrypt token events
|
||||
if (tokenEvents && tokenEvents.length > 0) {
|
||||
const decryptedTokens: UnspentTokens[] = [];
|
||||
for (const event of tokenEvents) {
|
||||
const tokens = await decryptUnspentTokens(event, activeAccount);
|
||||
if (tokens) {
|
||||
decryptedTokens.push(tokens);
|
||||
}
|
||||
}
|
||||
setUnspentTokens(decryptedTokens);
|
||||
}
|
||||
|
||||
// Decrypt history events
|
||||
if (historyEvents && historyEvents.length > 0) {
|
||||
const allTransactions: Transaction[] = [];
|
||||
for (const event of historyEvents) {
|
||||
const history = await decryptTransactionHistory(
|
||||
event,
|
||||
activeAccount,
|
||||
);
|
||||
if (history && history.transactions) {
|
||||
allTransactions.push(...history.transactions);
|
||||
}
|
||||
}
|
||||
setTransactions(sortTransactions(allTransactions));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Decryption error:", error);
|
||||
setDecryptionError(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "Failed to decrypt wallet data",
|
||||
);
|
||||
} finally {
|
||||
setIsDecrypting(false);
|
||||
}
|
||||
};
|
||||
|
||||
decryptWalletData();
|
||||
}, [
|
||||
isOwnWallet,
|
||||
activeAccount,
|
||||
walletConfigEvent,
|
||||
tokenEvents,
|
||||
historyEvents,
|
||||
]);
|
||||
|
||||
// Calculate balance
|
||||
const balanceByMint = useMemo(() => {
|
||||
if (unspentTokens.length === 0) return new Map();
|
||||
return calculateBalance(unspentTokens);
|
||||
}, [unspentTokens]);
|
||||
|
||||
const totalBalance = useMemo(() => {
|
||||
return getTotalBalance(balanceByMint);
|
||||
}, [balanceByMint]);
|
||||
|
||||
if (!resolvedPubkey) {
|
||||
return (
|
||||
<div className="p-6">
|
||||
@@ -183,14 +285,17 @@ export function WalletViewer({ pubkey }: WalletViewerProps) {
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Lock className="h-4 w-4" />
|
||||
{isOwnWallet && walletConfig ? (
|
||||
<Unlock className="h-4 w-4 text-green-500" />
|
||||
) : (
|
||||
<Lock className="h-4 w-4" />
|
||||
)}
|
||||
Wallet Status
|
||||
</CardTitle>
|
||||
{isOwnWallet && !unlocked && (
|
||||
<Button size="sm" variant="outline" disabled>
|
||||
<Download className="h-4 w-4 mr-2" />
|
||||
Unlock Wallet
|
||||
</Button>
|
||||
{isOwnWallet && walletConfig && (
|
||||
<div className="text-xs text-green-500 font-medium">
|
||||
✓ Unlocked
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<CardDescription>
|
||||
@@ -198,24 +303,37 @@ export function WalletViewer({ pubkey }: WalletViewerProps) {
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<Alert>
|
||||
<Lock className="h-4 w-4" />
|
||||
<AlertDescription>
|
||||
{isOwnWallet ? (
|
||||
<>
|
||||
<strong>Wallet Encrypted:</strong> Your wallet data is
|
||||
encrypted with NIP-44. Decryption functionality will be added
|
||||
in a future update.
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<strong>Privacy Protected:</strong> This wallet's data is
|
||||
encrypted and cannot be viewed without the owner's private
|
||||
key.
|
||||
</>
|
||||
)}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
{isOwnWallet && walletConfig && (
|
||||
<Alert>
|
||||
<Unlock className="h-4 w-4" />
|
||||
<AlertDescription>
|
||||
<strong>Wallet Unlocked:</strong> Your wallet has been decrypted
|
||||
and is ready to use. Balance and transaction history are now
|
||||
visible.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{isOwnWallet && !walletConfig && !isDecrypting && (
|
||||
<Alert>
|
||||
<Lock className="h-4 w-4" />
|
||||
<AlertDescription>
|
||||
<strong>Wallet Locked:</strong> Your wallet data is encrypted
|
||||
with NIP-44.{" "}
|
||||
{activeAccount?.nip44 ? "Decrypting..." : "Sign in to decrypt."}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{!isOwnWallet && (
|
||||
<Alert>
|
||||
<Lock className="h-4 w-4" />
|
||||
<AlertDescription>
|
||||
<strong>Privacy Protected:</strong> This wallet's data is
|
||||
encrypted and cannot be viewed without the owner's private key.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-2 gap-4 text-sm">
|
||||
<div>
|
||||
@@ -250,11 +368,21 @@ export function WalletViewer({ pubkey }: WalletViewerProps) {
|
||||
: "Unknown"}
|
||||
</div>
|
||||
</div>
|
||||
{walletConfig && walletConfig.mints && (
|
||||
<div className="col-span-2">
|
||||
<div className="text-muted-foreground">Configured Mints</div>
|
||||
<div className="font-mono text-xs space-y-1 mt-1">
|
||||
{walletConfig.mints.map((mint) => (
|
||||
<div key={mint}>✓ {getMintDisplayName(mint)}</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Balance Card (Encrypted) */}
|
||||
{/* Balance Card */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
@@ -266,21 +394,78 @@ export function WalletViewer({ pubkey }: WalletViewerProps) {
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex items-center justify-center p-8 border-2 border-dashed rounded-lg">
|
||||
<div className="text-center space-y-2">
|
||||
<Lock className="h-8 w-8 mx-auto text-muted-foreground" />
|
||||
<p className="text-sm text-muted-foreground">Balance encrypted</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{tokenEvents && tokenEvents.length > 0
|
||||
? `${tokenEvents.length} token event(s) found`
|
||||
: "No token events"}
|
||||
</p>
|
||||
{!isOwnWallet ? (
|
||||
<div className="flex items-center justify-center p-8 border-2 border-dashed rounded-lg">
|
||||
<div className="text-center space-y-2">
|
||||
<Lock className="h-8 w-8 mx-auto text-muted-foreground" />
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Balance encrypted
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Only the owner can view this wallet
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : isDecrypting ? (
|
||||
<div className="flex items-center justify-center p-8">
|
||||
<div className="text-center space-y-2">
|
||||
<Unlock className="h-8 w-8 mx-auto text-muted-foreground animate-pulse" />
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Decrypting wallet...
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
) : decryptionError ? (
|
||||
<Alert variant="destructive">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertDescription>{decryptionError}</AlertDescription>
|
||||
</Alert>
|
||||
) : unspentTokens.length === 0 ? (
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
<Coins className="h-8 w-8 mx-auto mb-2 opacity-50" />
|
||||
<p className="text-sm">No funds available</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{/* Total Balance */}
|
||||
<div className="text-center p-6 bg-muted rounded-lg">
|
||||
<div className="text-sm text-muted-foreground mb-2">
|
||||
Total Balance
|
||||
</div>
|
||||
<div className="text-3xl font-bold">
|
||||
{formatBalance(totalBalance)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Balance by Mint */}
|
||||
{balanceByMint.size > 1 && (
|
||||
<div className="space-y-2">
|
||||
<div className="text-sm font-medium text-muted-foreground">
|
||||
By Mint
|
||||
</div>
|
||||
{Array.from(balanceByMint.entries()).map(
|
||||
([mint, balance]) => (
|
||||
<div
|
||||
key={mint}
|
||||
className="flex justify-between items-center p-3 bg-muted rounded"
|
||||
>
|
||||
<div className="text-sm font-mono text-muted-foreground">
|
||||
{getMintDisplayName(mint)}
|
||||
</div>
|
||||
<div className="text-sm font-semibold">
|
||||
{formatBalance(balance)}
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Transaction History (Encrypted) */}
|
||||
{/* Transaction History */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
@@ -290,7 +475,7 @@ export function WalletViewer({ pubkey }: WalletViewerProps) {
|
||||
<CardDescription>Recent wallet activity</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{historyEvents && historyEvents.length > 0 ? (
|
||||
{!isOwnWallet && historyEvents && historyEvents.length > 0 ? (
|
||||
<div className="flex items-center justify-center p-8 border-2 border-dashed rounded-lg">
|
||||
<div className="text-center space-y-2">
|
||||
<Lock className="h-8 w-8 mx-auto text-muted-foreground" />
|
||||
@@ -298,15 +483,65 @@ export function WalletViewer({ pubkey }: WalletViewerProps) {
|
||||
Transaction history encrypted
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{historyEvents.length} transaction(s) found
|
||||
Only the owner can view this wallet
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
) : transactions.length === 0 ? (
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
<History className="h-8 w-8 mx-auto mb-2 opacity-50" />
|
||||
<p className="text-sm">No transaction history</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{transactions.slice(0, 10).map((tx, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="flex items-center justify-between p-3 bg-muted rounded hover:bg-muted/80 transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
{tx.type === "mint" && (
|
||||
<Zap className="h-4 w-4 text-green-500" />
|
||||
)}
|
||||
{tx.type === "melt" && (
|
||||
<Zap className="h-4 w-4 text-orange-500" />
|
||||
)}
|
||||
{tx.type === "send" && (
|
||||
<ArrowUpRight className="h-4 w-4 text-red-500" />
|
||||
)}
|
||||
{tx.type === "receive" && (
|
||||
<ArrowDownLeft className="h-4 w-4 text-green-500" />
|
||||
)}
|
||||
<div>
|
||||
<div className="text-sm font-medium capitalize">
|
||||
{tx.type}
|
||||
</div>
|
||||
{tx.memo && (
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{tx.memo}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<div
|
||||
className={`text-sm font-semibold ${tx.type === "send" || tx.type === "melt" ? "text-red-500" : "text-green-500"}`}
|
||||
>
|
||||
{tx.type === "send" || tx.type === "melt" ? "-" : "+"}
|
||||
{formatBalance(tx.amount)}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{new Date(tx.timestamp * 1000).toLocaleDateString()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{transactions.length > 10 && (
|
||||
<div className="text-center text-sm text-muted-foreground pt-2">
|
||||
Showing 10 of {transactions.length} transactions
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
221
src/lib/wallet-utils.ts
Normal file
221
src/lib/wallet-utils.ts
Normal file
@@ -0,0 +1,221 @@
|
||||
/**
|
||||
* NIP-60 Cashu Wallet Utilities
|
||||
* Helpers for working with encrypted wallet data on Nostr
|
||||
*/
|
||||
|
||||
import type { NostrEvent } from "nostr-tools";
|
||||
import type { ISigner } from "applesauce-signers";
|
||||
|
||||
/**
|
||||
* NIP-60 Wallet Configuration (kind:17375)
|
||||
* Stored as encrypted JSON in event content
|
||||
*/
|
||||
export interface WalletConfig {
|
||||
/** Wallet private key (for signing Cashu operations) */
|
||||
privkey: string;
|
||||
/** Array of mint URLs this wallet uses */
|
||||
mints: string[];
|
||||
/** Optional wallet metadata */
|
||||
name?: string;
|
||||
description?: string;
|
||||
unit?: string; // e.g., "sat", "usd"
|
||||
relays?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* NIP-60 Unspent Token Data (kind:7375)
|
||||
* Contains Cashu proofs that can be spent
|
||||
*/
|
||||
export interface UnspentTokens {
|
||||
/** Mint URL these proofs are from */
|
||||
mint: string;
|
||||
/** Array of Cashu proofs (ecash tokens) */
|
||||
proofs: CashuProof[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Cashu Proof (ecash token)
|
||||
* Represents a spendable token with a specific amount
|
||||
*/
|
||||
export interface CashuProof {
|
||||
/** Unique identifier for this proof */
|
||||
id: string;
|
||||
/** Amount in base units (sats) */
|
||||
amount: number;
|
||||
/** Secret blinding factor */
|
||||
secret: string;
|
||||
/** Proof signature from mint */
|
||||
C: string;
|
||||
/** Keyset ID from mint */
|
||||
keyset_id?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* NIP-60 Transaction History (kind:7376)
|
||||
* Records of past wallet operations
|
||||
*/
|
||||
export interface TransactionHistory {
|
||||
/** Array of transaction records */
|
||||
transactions: Transaction[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Transaction Record
|
||||
*/
|
||||
export interface Transaction {
|
||||
/** Transaction type */
|
||||
type: "mint" | "melt" | "send" | "receive";
|
||||
/** Amount in base units */
|
||||
amount: number;
|
||||
/** Mint URL */
|
||||
mint: string;
|
||||
/** Unix timestamp */
|
||||
timestamp: number;
|
||||
/** Optional memo/note */
|
||||
memo?: string;
|
||||
/** Optional related event ID (for sends/receives) */
|
||||
event_id?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decrypts a NIP-60 wallet config event
|
||||
* @param event kind:17375 wallet config event
|
||||
* @param signer Account signer with nip44 support
|
||||
* @returns Decrypted wallet configuration
|
||||
*/
|
||||
export async function decryptWalletConfig(
|
||||
event: NostrEvent,
|
||||
signer: ISigner,
|
||||
): Promise<WalletConfig | null> {
|
||||
if (!signer.nip44) {
|
||||
throw new Error("Signer does not support NIP-44 encryption");
|
||||
}
|
||||
|
||||
try {
|
||||
const decrypted = await signer.nip44.decrypt(event.pubkey, event.content);
|
||||
return JSON.parse(decrypted) as WalletConfig;
|
||||
} catch (error) {
|
||||
console.error("Failed to decrypt wallet config:", error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Decrypts a NIP-60 unspent tokens event
|
||||
* @param event kind:7375 unspent tokens event
|
||||
* @param signer Account signer with nip44 support
|
||||
* @returns Decrypted unspent tokens
|
||||
*/
|
||||
export async function decryptUnspentTokens(
|
||||
event: NostrEvent,
|
||||
signer: ISigner,
|
||||
): Promise<UnspentTokens | null> {
|
||||
if (!signer.nip44) {
|
||||
throw new Error("Signer does not support NIP-44 encryption");
|
||||
}
|
||||
|
||||
try {
|
||||
const decrypted = await signer.nip44.decrypt(event.pubkey, event.content);
|
||||
return JSON.parse(decrypted) as UnspentTokens;
|
||||
} catch (error) {
|
||||
console.error("Failed to decrypt unspent tokens:", error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Decrypts a NIP-60 transaction history event
|
||||
* @param event kind:7376 transaction history event
|
||||
* @param signer Account signer with nip44 support
|
||||
* @returns Decrypted transaction history
|
||||
*/
|
||||
export async function decryptTransactionHistory(
|
||||
event: NostrEvent,
|
||||
signer: ISigner,
|
||||
): Promise<TransactionHistory | null> {
|
||||
if (!signer.nip44) {
|
||||
throw new Error("Signer does not support NIP-44 encryption");
|
||||
}
|
||||
|
||||
try {
|
||||
const decrypted = await signer.nip44.decrypt(event.pubkey, event.content);
|
||||
return JSON.parse(decrypted) as TransactionHistory;
|
||||
} catch (error) {
|
||||
console.error("Failed to decrypt transaction history:", error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculates total balance from all unspent token events
|
||||
* @param tokenEvents Array of decrypted UnspentTokens
|
||||
* @returns Total balance in base units (sats) grouped by mint
|
||||
*/
|
||||
export function calculateBalance(
|
||||
tokenEvents: UnspentTokens[],
|
||||
): Map<string, number> {
|
||||
const balanceByMint = new Map<string, number>();
|
||||
|
||||
for (const tokens of tokenEvents) {
|
||||
const currentBalance = balanceByMint.get(tokens.mint) || 0;
|
||||
const tokenSum = tokens.proofs.reduce(
|
||||
(sum, proof) => sum + proof.amount,
|
||||
0,
|
||||
);
|
||||
balanceByMint.set(tokens.mint, currentBalance + tokenSum);
|
||||
}
|
||||
|
||||
return balanceByMint;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets total balance across all mints
|
||||
* @param balanceByMint Map of mint URLs to balances
|
||||
* @returns Total balance in base units (sats)
|
||||
*/
|
||||
export function getTotalBalance(balanceByMint: Map<string, number>): number {
|
||||
let total = 0;
|
||||
for (const balance of balanceByMint.values()) {
|
||||
total += balance;
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats balance for display
|
||||
* @param sats Balance in satoshis
|
||||
* @param unit Unit to display ("sat" or "btc")
|
||||
* @returns Formatted balance string
|
||||
*/
|
||||
export function formatBalance(
|
||||
sats: number,
|
||||
unit: "sat" | "btc" = "sat",
|
||||
): string {
|
||||
if (unit === "btc") {
|
||||
return (sats / 100_000_000).toFixed(8) + " BTC";
|
||||
}
|
||||
return sats.toLocaleString() + " sats";
|
||||
}
|
||||
|
||||
/**
|
||||
* Sorts transactions by timestamp (most recent first)
|
||||
* @param transactions Array of transactions
|
||||
* @returns Sorted array
|
||||
*/
|
||||
export function sortTransactions(transactions: Transaction[]): Transaction[] {
|
||||
return [...transactions].sort((a, b) => b.timestamp - a.timestamp);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets a short mint display name from URL
|
||||
* @param mintUrl Full mint URL
|
||||
* @returns Shortened display name
|
||||
*/
|
||||
export function getMintDisplayName(mintUrl: string): string {
|
||||
try {
|
||||
const url = new URL(mintUrl);
|
||||
return url.hostname;
|
||||
} catch {
|
||||
return mintUrl;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user