From 93664b54fa4637efa9ad125eb02a72a51efc7f24 Mon Sep 17 00:00:00 2001 From: Shroominic Date: Fri, 4 Jul 2025 16:07:52 -0300 Subject: [PATCH 01/17] plan --- keyset_currency_refactor_plan.md | 310 +++++++++++++++++++++++++++++++ 1 file changed, 310 insertions(+) create mode 100644 keyset_currency_refactor_plan.md diff --git a/keyset_currency_refactor_plan.md b/keyset_currency_refactor_plan.md new file mode 100644 index 0000000..ff8eab6 --- /dev/null +++ b/keyset_currency_refactor_plan.md @@ -0,0 +1,310 @@ +# Multi-Keyset and Multi-Currency Refactor Plan + +## Overview + +This document outlines a comprehensive plan to implement proper multi-keyset and multi-currency support in the sixty_nuts Cashu wallet implementation. Currently, the wallet has partial support for multiple keysets and currencies, but it's not fully implemented. This refactor will enable: + +- Support for multiple keysets per mint (different currencies) +- Dynamic denomination fetching from mints +- Easy switching between keysets/currencies +- Universal keyset swap function +- Granular keyset specification for operations +- Proper balance display per keyset/currency + +## Current State Analysis + +### What's Currently Implemented + +1. **Partial Multi-Mint Support**: The wallet can handle multiple mints but treats them mostly independently +2. **Single Currency Per Wallet**: Currency is set at wallet initialization and applies globally +3. **Hardcoded Denominations**: Powers of 2 (1, 2, 4, 8, 16, ...) are hardcoded in `_calculate_optimal_denominations` +4. **Basic Keyset Handling**: Keysets are fetched but only filtered by currency and active status +5. **Token Serialization**: Supports multiple token formats (V3/V4) with currency information + +### What's Missing + +1. **Keyset Management**: No proper tracking of keysets across mints and currencies +2. **Dynamic Denominations**: Denominations should be fetched from mint keysets +3. **Currency Switching**: No way to work with multiple currencies in the same wallet +4. **Keyset Selection**: Operations don't allow specifying which keyset to use +5. **Multi-Currency Balance**: Balance display doesn't break down by currency + +## Architecture Changes + +### 1. Data Model Updates + +#### Enhanced Keyset Storage + +```python +@dataclass +class KeysetInfo: + """Complete keyset information.""" + id: str + mint_url: str + unit: CurrencyUnit + active: bool + input_fee_ppk: int = 0 + keys: dict[str, str] = field(default_factory=dict) # amount -> pubkey + denominations: list[int] = field(default_factory=list) # available denominations + +@dataclass +class WalletState: + """Enhanced wallet state with keyset tracking.""" + balance_by_keyset: dict[str, int] # keyset_id -> balance + proofs: list[ProofDict] + keysets: dict[str, KeysetInfo] # keyset_id -> KeysetInfo + proof_to_event_id: dict[str, str] | None = None +``` + +#### Enhanced ProofDict + +```python +class ProofDict(TypedDict): + """Enhanced proof with keyset information.""" + id: str # keyset_id + amount: int + secret: str + C: str + mint: str + unit: NotRequired[CurrencyUnit] # currency unit for this proof +``` + +### 2. Core Component Updates + +#### Wallet Class Enhancements + +```python +class Wallet: + def __init__(self, ...): + # Remove single currency restriction + self.default_unit: CurrencyUnit = "sat" # default unit for operations + self.keysets: dict[str, KeysetInfo] = {} # keyset_id -> KeysetInfo + self.mint_keysets: dict[str, list[str]] = {} # mint_url -> [keyset_ids] +``` + +## Implementation Tasks + +### Phase 1: Foundation (Core Infrastructure) + +#### Task 1.1: Update Data Models + +- [ ] Create `KeysetInfo` dataclass with full keyset information +- [ ] Update `WalletState` to include keyset tracking and multi-currency balances +- [ ] Add `unit` field to `ProofDict` for currency tracking +- [ ] Update type hints throughout the codebase + +#### Task 1.2: Keyset Management System + +- [ ] Create `KeysetManager` class for centralized keyset operations +- [ ] Implement keyset caching with expiration +- [ ] Add methods for keyset validation and comparison +- [ ] Implement keyset rotation detection + +#### Task 1.3: Dynamic Denomination System + +- [ ] Remove hardcoded denomination lists +- [ ] Implement denomination fetching from keyset keys +- [ ] Create denomination optimization algorithm that respects available denominations +- [ ] Add denomination validation against keyset + +### Phase 2: Multi-Currency Support + +#### Task 2.1: Currency-Aware Operations + +- [ ] Update `_calculate_optimal_denominations` to use keyset-specific denominations +- [ ] Modify proof selection to be currency-aware +- [ ] Implement currency conversion detection and prevention +- [ ] Add currency validation to all operations + +#### Task 2.2: Balance Management + +- [ ] Implement `get_balance_by_currency()` method +- [ ] Update `fetch_wallet_state()` to calculate per-currency balances +- [ ] Add `get_balance_by_keyset()` for fine-grained balance tracking +- [ ] Update CLI balance display to show breakdown by currency + +#### Task 2.3: Keyset Selection Logic + +- [ ] Implement intelligent keyset selection based on: + - Available balance + - Fees + - Currency requirements + - Active status +- [ ] Add fallback logic for inactive keysets +- [ ] Implement keyset preference system + +### Phase 3: Keyset Operations + +#### Task 3.1: Universal Keyset Swap Function + +```python +async def swap_keyset( + self, + proofs: list[ProofDict], + target_keyset_id: str, + *, + amount: int | None = None, +) -> list[ProofDict]: + """Universal function to swap proofs to a different keyset.""" +``` + +- [ ] Implement cross-currency swaps (via Lightning) +- [ ] Implement same-currency keyset swaps (direct swap) +- [ ] Handle fees for both swap types +- [ ] Add atomic swap guarantees + +#### Task 3.2: Granular Operation Control + +- [ ] Add `keyset_id` parameter to: + - `mint()` - mint to specific keyset + - `melt()` - melt from specific keyset + - `send()` - send from specific keyset + - `swap()` - swap within specific keyset +- [ ] Implement parameter validation +- [ ] Add keyset availability checks + +#### Task 3.3: Cross-Mint Currency Exchange + +- [ ] Implement `exchange_currency()` method +- [ ] Support for cross-mint transfers with currency conversion +- [ ] Calculate exchange rates based on Lightning quotes +- [ ] Implement fee optimization for exchanges + +### Phase 4: API Updates + +#### Task 4.1: Mint API Integration + +- [ ] Update `get_keysets()` to fetch all keysets (not just active) +- [ ] Implement keyset filtering by currency and active status +- [ ] Add keyset metadata caching +- [ ] Implement keyset update notifications + +#### Task 4.2: Token Format Updates + +- [ ] Ensure V4 tokens properly encode currency information +- [ ] Update token parsing to extract currency data +- [ ] Implement backward compatibility for older tokens +- [ ] Add currency validation on token redemption + +### Phase 5: CLI Enhancements + +#### Task 5.1: Balance Display + +``` +$ cashu balance +Total Balance: 1500 sat, 10.50 usd, 9.20 eur + +By Mint: +├── https://mint1.example.com +│ ├── sat: 1000 (keyset: 00a1b2...) +│ └── usd: 10.50 (keyset: 00c3d4...) +└── https://mint2.example.com + ├── sat: 500 (keyset: 00e5f6...) + └── eur: 9.20 (keyset: 00g7h8...) +``` + +#### Task 5.2: Currency Operations + +- [ ] Add `--unit` flag to all relevant commands +- [ ] Implement `cashu exchange` command for currency swaps +- [ ] Add `cashu keysets` command to list available keysets +- [ ] Update help text with currency examples + +#### Task 5.3: Keyset Management Commands + +- [ ] `cashu keysets list` - show all keysets +- [ ] `cashu keysets info ` - detailed keyset information +- [ ] `cashu keysets rotate` - handle keyset rotation +- [ ] `cashu keysets set-default ` - set default keyset + +### Phase 6: Testing & Migration + +#### Task 6.1: Test Suite Updates + +- [ ] Add multi-currency unit tests +- [ ] Add keyset swap integration tests +- [ ] Test cross-mint currency exchanges +- [ ] Add denomination edge case tests + +#### Task 6.2: Migration Strategy + +- [ ] Create migration script for existing wallets +- [ ] Handle legacy single-currency proofs +- [ ] Implement gradual migration path +- [ ] Add rollback capability + +#### Task 6.3: Documentation + +- [ ] Update API documentation +- [ ] Create currency exchange examples +- [ ] Document keyset management +- [ ] Add troubleshooting guide + +## Implementation Order + +1. **Week 1-2**: Phase 1 - Foundation + - Update data models + - Implement keyset management + - Dynamic denomination system + +2. **Week 3-4**: Phase 2 - Multi-Currency Support + - Currency-aware operations + - Balance management + - Keyset selection logic + +3. **Week 5-6**: Phase 3 - Keyset Operations + - Universal swap function + - Granular operation control + - Cross-mint exchanges + +4. **Week 7**: Phase 4 - API Updates + - Mint API integration + - Token format updates + +5. **Week 8**: Phase 5 - CLI Enhancements + - Balance display + - Currency operations + - Keyset management commands + +6. **Week 9-10**: Phase 6 - Testing & Migration + - Test suite updates + - Migration strategy + - Documentation + +## Technical Considerations + +### Performance + +- Keyset information should be cached to avoid frequent API calls +- Balance calculations should be optimized for wallets with many proofs +- Denomination selection algorithm must be efficient + +### Security + +- Keyset validation must be thorough to prevent attacks +- Currency swap operations must be atomic +- Private keys must never be exposed during swaps + +### Compatibility + +- Maintain backward compatibility with existing tokens +- Support gradual adoption of new features +- Ensure compatibility with other Cashu implementations + +### Error Handling + +- Clear error messages for currency mismatches +- Graceful handling of unavailable keysets +- Rollback mechanisms for failed swaps + +## Future Enhancements + +1. **Automatic Currency Arbitrage**: Detect and execute profitable currency swaps +2. **Keyset Reputation System**: Track keyset reliability and performance +3. **Multi-Signature Keysets**: Support for keysets requiring multiple signatures +4. **Currency Hedging**: Automatic balancing across currencies +5. **Keyset Discovery**: Automatic discovery of new keysets from mints + +## Conclusion + +This refactor will transform the sixty_nuts wallet from a single-currency system to a fully-featured multi-currency wallet with proper keyset management. The phased approach ensures that each component is properly tested before moving to the next phase, minimizing risk and ensuring a smooth transition for users. From e060a6c31086e18b735b23d47683e8484740ad48 Mon Sep 17 00:00:00 2001 From: Shroominic Date: Wed, 9 Jul 2025 23:38:09 -0400 Subject: [PATCH 02/17] wip pls test lol --- sixty_nuts/TODO.md | 29 ++ sixty_nuts/cli.py | 924 ++++++++++++++++++++++++++++++++---- sixty_nuts/crypto.py | 42 +- sixty_nuts/denominations.py | 147 ++++++ sixty_nuts/mint.py | 30 +- sixty_nuts/types.py | 226 ++++++++- sixty_nuts/wallet.py | 465 +++++++++++++----- 7 files changed, 1594 insertions(+), 269 deletions(-) create mode 100644 sixty_nuts/TODO.md create mode 100644 sixty_nuts/denominations.py diff --git a/sixty_nuts/TODO.md b/sixty_nuts/TODO.md new file mode 100644 index 0000000..d4b8cd7 --- /dev/null +++ b/sixty_nuts/TODO.md @@ -0,0 +1,29 @@ +# todo + +## minting + +nuts mint should request any non defined data +but wallet mint should always select one if not defined +mint and keyset selection +no timeout if timeout=None on wallet mint but cli nuts mint sets a timeout of 3 minutes + +## mint and keysets + +a wallet has all the mint instances open and each mint has +all the active keysets loaded with the currency information + +## send & melt + +for spending operations if nonthing specific is defined there should be an algorithm selecting +the most fitting mint and keyset and if none are fitting it should automatically +rebalance in the most optimal way then sending or melting is handleded the same way + +## redeem + +default should just check if the mint is trusted and redeem all proofs, +if non trusted mint it should swap into the trusted mint active keyset with biggest balance + +# currency exchange + +metthod that can be used to check rates of swap between keysets +method to do the swap between to keysets of proofs diff --git a/sixty_nuts/cli.py b/sixty_nuts/cli.py index 7ee4e48..6d03554 100644 --- a/sixty_nuts/cli.py +++ b/sixty_nuts/cli.py @@ -4,7 +4,7 @@ import os import shutil import time -from typing import Annotated, Optional +from typing import Annotated, Optional, cast import typer from rich.console import Console @@ -29,6 +29,8 @@ MINTS_ENV_VAR, set_mints_in_env, clear_mints_from_env, + CurrencyUnit, + ProofDict, ) from .temp import redeem_to_lnurl from .relay import prompt_user_for_relays, RELAYS_ENV_VAR @@ -675,6 +677,110 @@ def handle_wallet_error(e: Exception) -> None: console.print(f"[red]❌ Error: {e}[/red]") +async def prompt_user_for_mint_and_keyset( + wallet, + mint_unit: "CurrencyUnit", +) -> tuple[str, str]: + """Prompt user to select mint and optionally keyset when multiple options are available. + + Args: + wallet: Wallet instance + mint_unit: Currency unit to mint + + Returns: + Tuple of (target_mint_url, target_keyset_id) + + Raises: + typer.Exit: If user cancels selection or no valid options + """ + # Update keysets from all mints + for mint_url in wallet.mint_urls: + await wallet._update_keysets_from_mint(mint_url) + + # Find all mints and keysets that support the requested unit + available_options: list[ + tuple[str, str, str] + ] = [] # (mint_url, keyset_id, keyset_display) + + for mint_url in wallet.mint_urls: + mint_keysets = wallet.keyset_manager.get_mint_keysets(mint_url) + for ks in mint_keysets: + if ks.unit == mint_unit and ks.active: + mint_display = mint_url[:40] + "..." if len(mint_url) > 43 else mint_url + keyset_display = f"{ks.id[:16]}... - {ks.unit.upper()}" + if ks.input_fee_ppk: + keyset_display += f" (fee: {ks.input_fee_ppk} ppk)" + available_options.append( + (mint_url, ks.id, f"{mint_display} | {keyset_display}") + ) + + if not available_options: + console.print(f"[red]No mints found that support {mint_unit.upper()}[/red]") + console.print("\nAvailable currencies by mint:") + for mint_url in wallet.mint_urls: + mint_keysets = wallet.keyset_manager.get_mint_keysets(mint_url) + units = set(ks.unit for ks in mint_keysets if ks.active) + if units: + console.print(f" {mint_url}: {', '.join(u.upper() for u in units)}") + raise typer.Exit(1) + + # If only one option, use it automatically + if len(available_options) == 1: + return available_options[0][0], available_options[0][1] + + # Multiple options - prompt user to choose + console.print(f"\n[cyan]🏦 Multiple mints support {mint_unit.upper()}[/cyan]") + console.print(f"Found {len(available_options)} minting options:") + + # Create selection table + table = Table(show_header=True, header_style="bold cyan") + table.add_column("#", style="dim", width=3) + table.add_column("Mint & Keyset", style="green") + + for i, (mint_url, keyset_id, display_text) in enumerate(available_options, 1): + table.add_row(str(i), display_text) + + console.print(table) + + # Prompt for selection + while True: + try: + choice = Prompt.ask( + f"\nSelect mint and keyset for {mint_unit.upper()} minting", default="1" + ) + + if choice.isdigit(): + choice_num = int(choice) + if 1 <= choice_num <= len(available_options): + selected_mint, selected_keyset, _ = available_options[ + choice_num - 1 + ] + + # Show selection + keyset_info = wallet.keyset_manager.get_keyset(selected_keyset) + mint_display = ( + selected_mint[:40] + "..." + if len(selected_mint) > 43 + else selected_mint + ) + console.print(f"[green]✅ Selected: {mint_display}[/green]") + console.print( + f"[dim] Keyset: {selected_keyset[:16]}... ({keyset_info.unit.upper()})[/dim]" + ) + + return selected_mint, selected_keyset + else: + console.print( + f"[red]Invalid choice. Please enter 1-{len(available_options)}[/red]" + ) + else: + console.print("[red]Please enter a number[/red]") + + except KeyboardInterrupt: + console.print("\n[yellow]Selection cancelled[/yellow]") + raise typer.Exit(1) + + async def _debug_nostr_state(wallet: Wallet) -> None: """Debug Nostr relay state and proof storage.""" from datetime import datetime @@ -886,8 +992,14 @@ def balance( bool, typer.Option("--nostr-debug", help="Show detailed Nostr relay debugging info"), ] = False, + unit: Annotated[ + Optional[str], + typer.Option( + "--unit", "-u", help="Filter by currency unit (sat, usd, eur, etc.)" + ), + ] = None, ) -> None: - """Check wallet balance.""" + """Check wallet balance across all currencies and keysets.""" async def _balance() -> None: nsec = get_nsec() @@ -899,56 +1011,155 @@ async def _balance() -> None: if nostr_debug: await _debug_nostr_state(wallet) - if validate: - balance_amount = await wallet.get_balance(check_proofs=True) - console.print( - f"[green]✅ Validated Balance: {balance_amount} sats[/green]" - ) + # Get the enhanced wallet state with keyset info + state = await wallet.fetch_wallet_state(check_proofs=validate) + + # Filter by unit if specified + if unit: + unit_cast = cast(CurrencyUnit, unit) + if unit_cast in state.balance_by_currency: + unit_balance = state.balance_by_currency[unit_cast] + console.print( + f"[green]✅ {unit.upper()} Balance: {unit_balance} {unit}[/green]" + ) + else: + console.print(f"[yellow]No balance in {unit.upper()}[/yellow]") + return else: - balance_amount = await wallet.get_balance(check_proofs=False) - console.print( - f"[yellow]📊 Quick Balance: {balance_amount} sats[/yellow]" - ) - console.print("[dim](not validated with mint)[/dim]") + # Show all currency balances + console.print("[green]✅ Balance by Currency:[/green]") - if details: - state = await wallet.fetch_wallet_state(check_proofs=validate) + if not state.balance_by_currency: + console.print("[yellow]No balance found[/yellow]") + return + + # Create currency table + currency_table = Table(title="Currency Balances") + currency_table.add_column("Currency", style="cyan") + currency_table.add_column("Balance", style="green") + currency_table.add_column("Approx USD", style="yellow") + + for currency, balance in state.balance_by_currency.items(): + # Simple USD approximation (in production, use real exchange rates) + usd_value = "" + if currency == "sat": + # Assume 1 BTC = $50,000 for example + usd_value = f"~${balance * 50000 / 100_000_000:.2f}" + elif currency == "usd": + usd_value = f"${balance / 100:.2f}" # Assuming cents + elif currency == "eur": + usd_value = ( + f"~${balance * 1.1 / 100:.2f}" # Assuming EUR/USD = 1.1 + ) + + currency_table.add_row( + currency.upper(), f"{balance} {currency}", usd_value + ) - # Create table for mint breakdown - table = Table(title="Wallet Details") - table.add_column("Mint", style="cyan") - table.add_column("Balance", style="green") - table.add_column("Proofs", style="blue") - table.add_column("Denominations", style="magenta") + console.print(currency_table) - # Group proofs by mint - proofs_by_mint: dict[str, list] = {} + if details: + # Create detailed keyset table with wider columns + keyset_table = Table(title="Keyset Details") + keyset_table.add_column("Mint", style="blue", min_width=35) + keyset_table.add_column("Keyset ID", style="cyan", min_width=20) + keyset_table.add_column("Unit", style="green", min_width=6) + keyset_table.add_column("Balance", style="yellow", min_width=9) + keyset_table.add_column("Proofs", style="magenta", min_width=8) + keyset_table.add_column("Status", style="white", min_width=11) + + # Group proofs by keyset + proofs_by_keyset: dict[str, list[ProofDict]] = {} for proof in state.proofs: - mint_url = proof.get("mint") or "unknown" - if mint_url not in proofs_by_mint: - proofs_by_mint[mint_url] = [] - proofs_by_mint[mint_url].append(proof) + keyset_id = proof.get("id", "unknown") + if keyset_id not in proofs_by_keyset: + proofs_by_keyset[keyset_id] = [] + proofs_by_keyset[keyset_id].append(proof) + + # Ensure we have keyset info for all keysets with balances + for keyset_id in state.balance_by_keyset.keys(): + if keyset_id not in state.keysets: + # Try to find the mint URL from proofs and fetch keyset info + proofs_for_keyset = proofs_by_keyset.get(keyset_id, []) + if proofs_for_keyset: + mint_url = proofs_for_keyset[0].get("mint", "") + if mint_url: + try: + await wallet._update_keysets_from_mint(mint_url) + except Exception as e: + console.print( + f"[dim]Warning: Could not fetch keyset info for {mint_url}: {e}[/dim]" + ) - for mint_url, proofs in proofs_by_mint.items(): - mint_balance = sum(p["amount"] for p in proofs) - # Get denomination breakdown - denominations: dict[int, int] = {} - for proof in proofs: - amount = proof["amount"] - denominations[amount] = denominations.get(amount, 0) + 1 + # Refresh state keysets after any updates + state_keysets = wallet.keyset_manager.keysets.copy() - denom_str = ", ".join( - f"{amt}×{count}" for amt, count in sorted(denominations.items()) - ) + for keyset_id, balance in state.balance_by_keyset.items(): + keyset_info = state_keysets.get(keyset_id) + proof_count = len(proofs_by_keyset.get(keyset_id, [])) - table.add_row( - mint_url[:30] + "..." if len(mint_url) > 33 else mint_url, - f"{mint_balance} sats", - str(len(proofs)), - denom_str, - ) + if keyset_info: + # Truncate mint URL more intelligently + mint_url = keyset_info.mint_url + if len(mint_url) > 35: + # Show beginning and end with ellipsis + mint_display = f"{mint_url[:16]}...{mint_url[-16:]}" + else: + mint_display = mint_url + + status = "✅ Active" if keyset_info.active else "❌ Inactive" + unit_display = keyset_info.unit.upper() + balance_display = f"{balance} {keyset_info.unit}" + + # Show more of keyset ID + keyset_display = f"{keyset_id[:16]}..." + + keyset_table.add_row( + keyset_display, + mint_display, + unit_display, + balance_display, + str(proof_count), + status, + ) + else: + # Handle missing keyset info - try to get mint from proofs + proofs_for_keyset = proofs_by_keyset.get(keyset_id, []) + mint_url = "unknown" + if proofs_for_keyset: + mint_url = proofs_for_keyset[0].get("mint", "unknown") + if len(mint_url) > 35: + mint_url = f"{mint_url[:16]}...{mint_url[-16:]}" + + keyset_table.add_row( + f"{keyset_id[:16]}...", + mint_url, + "unknown", + f"{balance} ???", + str(proof_count), + "❓ Unknown", + ) - console.print(table) + console.print("\n") + console.print(keyset_table) + + # Show denomination breakdown per keyset if requested + if typer.confirm("\nShow denomination breakdown?", default=False): + for keyset_id, proofs in proofs_by_keyset.items(): + if proofs: + keyset_info = state.keysets.get(keyset_id) + console.print( + f"\n[cyan]Keyset {keyset_id[:16]}... ({keyset_info.unit if keyset_info else 'unknown'}):[/cyan]" + ) + + denominations: dict[int, int] = {} + for proof in proofs: + amount = proof["amount"] + denominations[amount] = denominations.get(amount, 0) + 1 + + for denom in sorted(denominations.keys()): + count = denominations[denom] + console.print(f" {denom} × {count} = {denom * count}") try: asyncio.run(_balance()) @@ -959,7 +1170,9 @@ async def _balance() -> None: @app.command() def send( - amount: Annotated[int, typer.Argument(help="Amount to send in sats")], + amount: Annotated[ + int, typer.Argument(help="Amount to send (in specified unit or sats)") + ], mint_urls: Annotated[ Optional[list[str]], typer.Option("--mint", "-m", help="Mint URLs") ] = None, @@ -970,72 +1183,133 @@ def send( Optional[str], typer.Option("--to-lnurl", help="Send directly to LNURL or Lightning address"), ] = None, + unit: Annotated[ + Optional[str], + typer.Option( + "--unit", "-u", help="Currency unit to send from (sat, usd, eur, etc.)" + ), + ] = None, + keyset: Annotated[ + Optional[str], + typer.Option("--keyset", "-k", help="Specific keyset ID to send from"), + ] = None, ) -> None: - """Send sats - create a Cashu token or send to Lightning address. - - Create a token: - nuts send 1000 + """Send tokens from wallet. - Send to Lightning address: - nuts send --to-lnurl user@getalby.com 1000 + Examples: + Send 100 sats: nuts send 100 + Send 50 USD: nuts send 50 --unit usd + Send from specific keyset: nuts send 100 --keyset abc123... + Send to Lightning: nuts send 100 --to-lnurl user@getalby.com """ - async def _send(): + async def _send() -> None: nsec = get_nsec() # Use create_wallet_with_mint_selection for automatic mint discovery and selection wallet = await create_wallet_with_mint_selection(nsec=nsec, mint_urls=mint_urls) + async with wallet: + # Get wallet state to check balances + state = await wallet.fetch_wallet_state(check_proofs=False) + + # Determine which unit to use + send_unit: CurrencyUnit = ( + cast(CurrencyUnit, unit) if unit else wallet.currency + ) + + # Check if we have balance in that unit + if send_unit not in state.balance_by_currency: + console.print(f"[red]No balance in {send_unit.upper()}[/red]") + console.print("\nAvailable currencies:") + for curr, bal in state.balance_by_currency.items(): + console.print(f" {curr.upper()}: {bal} {curr}") + return + + unit_balance = state.balance_by_currency[send_unit] + if to_lnurl: # Send directly to Lightning address - console.print(f"[blue]Sending {amount} sats to {to_lnurl}...[/blue]") + console.print( + f"[blue]Sending {amount} {send_unit} to {to_lnurl}...[/blue]" + ) - # Check balance first - balance = await wallet.get_balance() - if balance <= amount: + # Check balance first (need extra for fees) + if unit_balance <= amount: console.print( - f"[red]Insufficient balance! Need >{amount}, have {balance}[/red]" + f"[red]Insufficient balance! Need >{amount}, have {unit_balance} {send_unit}[/red]" ) console.print( - "[dim]Lightning payments require fees (typically 1 sat)[/dim]" + "[dim]Lightning payments require fees (typically 1%)[/dim]" ) return - actual_paid = await wallet.send_to_lnurl(to_lnurl, amount) + # If keyset specified, use it + keyset_id = None + if keyset: + if keyset not in state.keysets: + console.print(f"[red]Keyset {keyset} not found[/red]") + return + keyset_id = keyset + console.print(f"[dim]Using keyset: {keyset_id[:16]}...[/dim]") + + actual_paid = await wallet.send_to_lnurl( + to_lnurl, amount, unit=send_unit, keyset_id=keyset_id + ) console.print("[green]✅ Successfully sent![/green]") - console.print(f"Total paid (including fees): {actual_paid} sats") + console.print(f"Total paid (including fees): {actual_paid} {send_unit}") # Show remaining balance - balance = await wallet.get_balance() - console.print(f"Remaining balance: {balance} sats") + new_state = await wallet.fetch_wallet_state(check_proofs=False) + new_balance = new_state.balance_by_currency.get(send_unit, 0) + console.print(f"Remaining balance: {new_balance} {send_unit}") else: # Create Cashu token - console.print(f"[blue]Creating token for {amount} sats...[/blue]") + console.print( + f"[blue]Creating token for {amount} {send_unit}...[/blue]" + ) - # Check balance first - balance = await wallet.get_balance() - if balance < amount: + # Check balance + if unit_balance < amount: console.print( - f"[red]Insufficient balance! Need {amount}, have {balance}[/red]" + f"[red]Insufficient balance! Need {amount}, have {unit_balance} {send_unit}[/red]" ) return - token = await wallet.send(amount) + # If keyset specified, verify it matches the unit + if keyset: + keyset_info = state.keysets.get(keyset) + if not keyset_info: + console.print(f"[red]Keyset {keyset} not found[/red]") + return + if keyset_info.unit != send_unit: + console.print( + f"[red]Keyset {keyset[:16]}... uses {keyset_info.unit}, but you specified {send_unit}[/red]" + ) + return + console.print(f"[dim]Using keyset: {keyset[:16]}...[/dim]") + + token = await wallet.send(amount, unit=send_unit, keyset_id=keyset) - console.print("\n[green]✅ Cashu Token Created:[/green]") + console.print( + f"\n[green]✅ Cashu Token Created ({amount} {send_unit}):[/green]" + ) # Display token without line wrapping for easy copying console.print(token, soft_wrap=True, no_wrap=True) # Display QR code unless disabled if not no_qr: - display_qr_code(token, f"Cashu Token ({amount} sats)") + display_qr_code(token, f"Cashu Token ({amount} {send_unit})") else: console.print() # Show remaining balance - balance = await wallet.get_balance() - console.print(f"[dim]Remaining balance: {balance} sats[/dim]") + new_state = await wallet.fetch_wallet_state(check_proofs=False) + new_balance = new_state.balance_by_currency.get(send_unit, 0) + console.print( + f"[dim]Remaining balance: {new_balance} {send_unit}[/dim]" + ) try: asyncio.run(_send()) @@ -1155,7 +1429,9 @@ async def _pay(): @app.command() def mint( - amount: Annotated[int, typer.Argument(help="Amount to mint in sats")], + amount: Annotated[ + int, typer.Argument(help="Amount to mint (in specified unit or sats)") + ], mint_urls: Annotated[ Optional[list[str]], typer.Option("--mint", "-m", help="Mint URLs") ] = None, @@ -1165,39 +1441,140 @@ def mint( no_qr: Annotated[ bool, typer.Option("--no-qr", help="Don't display QR code") ] = False, + unit: Annotated[ + Optional[str], + typer.Option( + "--unit", "-u", help="Currency unit to mint (sat, usd, eur, etc.)" + ), + ] = None, + keyset: Annotated[ + Optional[str], + typer.Option("--keyset", "-k", help="Specific keyset ID to mint with"), + ] = None, ) -> None: - """Create Lightning invoice to mint new tokens.""" + """Create Lightning invoice to mint new tokens. + + Examples: + Mint 1000 sats: nuts mint 1000 + Mint 50 USD: nuts mint 50 --unit usd + Mint with specific keyset: nuts mint 100 --keyset abc123... + """ - async def _mint(): + async def _mint() -> None: nsec = get_nsec() # Use create_wallet_with_mint_selection for automatic mint discovery and selection wallet = await create_wallet_with_mint_selection(nsec=nsec, mint_urls=mint_urls) - async with wallet: - console.print(f"[blue]Creating invoice for {amount} sats...[/blue]") - invoice, task = await wallet.mint_async(amount, timeout=timeout) + # Determine which unit to mint + mint_unit: CurrencyUnit = cast(CurrencyUnit, unit) if unit else wallet.currency + + async with wallet: + # If keyset specified, verify it exists and get its info + target_mint_url = None + if keyset: + # Update keysets from all mints + for mint_url in wallet.mint_urls: + await wallet._update_keysets_from_mint(mint_url) + + keyset_info = wallet.keyset_manager.get_keyset(keyset) + if not keyset_info: + console.print(f"[red]Keyset {keyset} not found[/red]") + console.print("\nAvailable keysets:") + + # Show available keysets + for mint_url in wallet.mint_urls: + mint_keysets = wallet.keyset_manager.get_mint_keysets(mint_url) + if mint_keysets: + console.print(f"\n{mint_url}:") + for ks in mint_keysets: + console.print(f" {ks.id[:16]}... - {ks.unit.upper()}") + return - # Display invoice for easy copying - console.print("\n[yellow]⚡ Lightning Invoice:[/yellow]") - # Display invoice without line wrapping for easy copying - console.print(invoice, soft_wrap=False, no_wrap=True, overflow="ignore") + # Verify unit matches + if keyset_info.unit != mint_unit: + console.print( + f"[red]Keyset {keyset[:16]}... uses {keyset_info.unit}, but you specified {mint_unit}[/red]" + ) + return - # Display QR code unless disabled - if not no_qr: - display_qr_code(invoice, "Lightning Invoice QR Code") + target_mint_url = keyset_info.mint_url + target_keyset_id = keyset + console.print( + f"[dim]Using specified keyset: {keyset[:16]}... ({keyset_info.unit.upper()})[/dim]" + ) else: - console.print() + # Prompt user to select mint and keyset when multiple options are available + ( + target_mint_url, + target_keyset_id, + ) = await prompt_user_for_mint_and_keyset(wallet, mint_unit) + console.print( + f"[dim]Using selected mint and keyset: {target_keyset_id[:16]}... ({mint_unit.upper()})[/dim]" + ) - console.print("[blue]Waiting for payment...[/blue]") + console.print(f"[blue]Creating invoice for {amount} {mint_unit}...[/blue]") - paid = await task + # Override wallet currency temporarily for this mint operation + original_currency = wallet.currency + wallet.currency = mint_unit - if paid: - console.print("[green]✅ Payment received! Tokens minted.[/green]") - balance = await wallet.get_balance() - console.print(f"New balance: {balance} sats") - else: - console.print(f"[red]❌ Payment timeout after {timeout} seconds[/red]") + try: + # Pass the selected mint URL directly instead of reordering + invoice, task = await wallet.mint_async( + amount, timeout=timeout, mint_url=target_mint_url + ) + + # Display invoice for easy copying + console.print( + f"\n[yellow]⚡ Lightning Invoice ({amount} {mint_unit}):[/yellow]" + ) + # Display invoice with normal wrapping so it can be copied completely + console.print(invoice) + + # Display QR code unless disabled + if not no_qr: + display_qr_code( + invoice, f"Lightning Invoice ({amount} {mint_unit})" + ) + else: + console.print() + + console.print("[blue]Waiting for payment...[/blue]") + console.print("[dim]Press Ctrl+C to cancel and return to CLI[/dim]") + + try: + paid = await task + + if paid: + console.print( + f"[green]✅ Payment received! {amount} {mint_unit} minted.[/green]" + ) + + # Show updated balance + state = await wallet.fetch_wallet_state(check_proofs=False) + if mint_unit in state.balance_by_currency: + new_balance = state.balance_by_currency[mint_unit] + console.print( + f"New {mint_unit.upper()} balance: {new_balance} {mint_unit}" + ) + else: + console.print( + f"[red]❌ Payment timeout after {timeout} seconds[/red]" + ) + console.print( + "[yellow]Invoice is still valid - you can pay it later[/yellow]" + ) + + except Exception as payment_error: + console.print( + f"[red]❌ Payment polling error: {payment_error}[/red]" + ) + console.print( + "[yellow]Invoice may still be valid - check with your Lightning wallet[/yellow]" + ) + finally: + # Restore original currency + wallet.currency = original_currency try: asyncio.run(_mint()) @@ -1212,7 +1589,7 @@ def info( Optional[list[str]], typer.Option("--mint", "-m", help="Mint URLs") ] = None, ) -> None: - """Show wallet information.""" + """Show comprehensive wallet information including keysets and currencies.""" async def _info(): nsec = get_nsec() @@ -1224,6 +1601,10 @@ async def _info(): # Get wallet state state = await wallet.fetch_wallet_state(check_proofs=False) + # Update keysets from all mints + for mint_url in wallet.mint_urls: + await wallet._update_keysets_from_mint(mint_url) + # Create info table table = Table(title="Wallet Information") table.add_column("Property", style="cyan") @@ -1231,22 +1612,87 @@ async def _info(): # Basic info table.add_row("Public Key", wallet._get_pubkey()) - table.add_row("Currency", wallet.currency) - table.add_row("Balance", f"{state.balance} sats") + table.add_row("Default Currency", wallet.currency.upper()) + table.add_row("Total Balance (SAT)", f"{state.total_balance_sat} sats") table.add_row("Total Proofs", str(len(state.proofs))) - # Mint info + # Currency breakdown + if state.balance_by_currency: + table.add_row("", "") # Empty row for spacing + table.add_row("[bold]Currency Balances[/bold]", "") + for currency, balance in sorted(state.balance_by_currency.items()): + table.add_row(f" {currency.upper()}", f"{balance} {currency}") + + # Mint info with keyset details + table.add_row("", "") # Empty row for spacing + table.add_row("[bold]Mints & Keysets[/bold]", "") table.add_row("Configured Mints", str(len(wallet.mint_urls))) + for i, mint_url in enumerate(wallet.mint_urls): table.add_row(f" Mint {i + 1}", mint_url) + # Get keysets for this mint + mint_keysets = wallet.keyset_manager.get_mint_keysets(mint_url) + if mint_keysets: + # Group by currency + currencies = set(ks.unit for ks in mint_keysets if ks.active) + if currencies: + table.add_row( + " Currencies", + ", ".join(c.upper() for c in sorted(currencies)), + ) + + # Show keyset count + active_count = sum(1 for ks in mint_keysets if ks.active) + total_count = len(mint_keysets) + table.add_row( + " Keysets", f"{active_count} active / {total_count} total" + ) + # Relay info + table.add_row("", "") # Empty row for spacing + table.add_row("[bold]Relays[/bold]", "") table.add_row("Configured Relays", str(len(wallet.relays))) for i, relay_url in enumerate(wallet.relays): table.add_row(f" Relay {i + 1}", relay_url) console.print(table) + # Show keyset details if requested + if typer.confirm("\nShow detailed keyset information?", default=False): + keyset_table = Table(title="Keyset Details") + keyset_table.add_column("Keyset ID", style="cyan") + keyset_table.add_column("Mint", style="blue") + keyset_table.add_column("Unit", style="green") + keyset_table.add_column("Status", style="yellow") + keyset_table.add_column("Input Fee", style="magenta") + + all_keysets = [] + for mint_url in wallet.mint_urls: + all_keysets.extend(wallet.keyset_manager.get_mint_keysets(mint_url)) + + for ks in sorted(all_keysets, key=lambda k: (k.mint_url, k.unit, k.id)): + mint_display = ( + ks.mint_url[:30] + "..." + if len(ks.mint_url) > 33 + else ks.mint_url + ) + status = "✅ Active" if ks.active else "❌ Inactive" + fee_display = ( + f"{ks.input_fee_ppk} ppk" if ks.input_fee_ppk else "None" + ) + + keyset_table.add_row( + ks.id[:16] + "...", + mint_display, + ks.unit.upper(), + status, + fee_display, + ) + + console.print("\n") + console.print(keyset_table) + try: asyncio.run(_info()) except Exception as e: @@ -2725,5 +3171,289 @@ def cli() -> None: app() +@app.command() +def swap( + amount: Annotated[int, typer.Argument(help="Amount to swap")], + from_unit: Annotated[ + str, typer.Argument(help="Source currency (sat, usd, eur, etc.)") + ], + to_unit: Annotated[ + str, typer.Argument(help="Target currency (sat, usd, eur, etc.)") + ], + mint_urls: Annotated[ + Optional[list[str]], typer.Option("--mint", "-m", help="Mint URLs") + ] = None, + same_mint: Annotated[ + bool, + typer.Option( + "--same-mint", + help="Require swap within same mint (default: prefer same mint)", + ), + ] = False, + confirm: Annotated[ + bool, typer.Option("--yes", "-y", help="Skip confirmation prompt") + ] = False, +) -> None: + """Exchange tokens between different currencies/keysets. + + This command allows you to swap tokens from one currency to another, + either within the same mint (if it supports both currencies) or + between different mints. + + Examples: + Swap 100 USD to EUR: nuts swap 100 usd eur + Swap 1000 sats to USD: nuts swap 1000 sat usd + Force same mint: nuts swap 50 eur sat --same-mint + """ + + async def _swap(): + nsec = get_nsec() + wallet = await create_wallet_with_mint_selection(nsec=nsec, mint_urls=mint_urls) + + # Cast units to proper type + from_currency = cast(CurrencyUnit, from_unit.lower()) + to_currency = cast(CurrencyUnit, to_unit.lower()) + + async with wallet: + console.print( + f"[blue]Preparing to swap {amount} {from_unit.upper()} → {to_unit.upper()}...[/blue]" + ) + + # Get current state + state = await wallet.fetch_wallet_state(check_proofs=False) + + # Check source balance + from_balance = state.balance_by_currency.get(from_currency, 0) + if from_balance < amount: + console.print( + f"[red]Insufficient {from_unit.upper()} balance! " + f"Need {amount}, have {from_balance}[/red]" + ) + return + + # Update keysets from all mints + for mint_url in wallet.mint_urls: + await wallet._update_keysets_from_mint(mint_url) + + # Find suitable keysets for the swap + from_keysets = wallet.keyset_manager.get_currency_keysets(from_currency) + to_keysets = wallet.keyset_manager.get_currency_keysets(to_currency) + + if not from_keysets: + console.print(f"[red]No keysets found for {from_unit.upper()}[/red]") + return + + if not to_keysets: + console.print(f"[red]No keysets found for {to_unit.upper()}[/red]") + return + + # Find optimal swap path + swap_path = None + same_mint_possible = False + + # Check if same mint supports both currencies + for from_ks in from_keysets: + for to_ks in to_keysets: + if from_ks.mint_url == to_ks.mint_url: + same_mint_possible = True + swap_path = { + "type": "same_mint", + "mint": from_ks.mint_url, + "from_keyset": from_ks.id, + "to_keyset": to_ks.id, + } + break + if swap_path: + break + + # If same mint not possible but required, error out + if same_mint and not same_mint_possible: + console.print( + f"[red]No single mint supports both {from_unit.upper()} and {to_unit.upper()}[/red]" + ) + console.print("Remove --same-mint flag to allow cross-mint swaps") + return + + # If no same-mint path found, use cross-mint swap + if not swap_path: + # Find mints with balance for source currency + from_mints = set(ks.mint_url for ks in from_keysets) + to_mints = set(ks.mint_url for ks in to_keysets) + + # For now, pick first available mints + from_mint = None + for m in from_mints: + has_balance = False + for p in state.proofs: + if p.get("mint") == m: + keyset = wallet.keyset_manager.get_keyset(p.get("id", "")) + if keyset and keyset.unit == from_currency: + has_balance = True + break + if has_balance: + from_mint = m + break + + if not from_mint: + console.print( + f"[red]No mint with {from_unit.upper()} balance found[/red]" + ) + return + + to_mint = next(iter(to_mints)) + + swap_path = { + "type": "cross_mint", + "from_mint": from_mint, + "to_mint": to_mint, + "from_keyset": next( + ks.id for ks in from_keysets if ks.mint_url == from_mint + ), + "to_keyset": next( + ks.id for ks in to_keysets if ks.mint_url == to_mint + ), + } + + # Show swap details + console.print("\n[cyan]Swap Details:[/cyan]") + console.print(f" Amount: {amount} {from_unit.upper()} → {to_unit.upper()}") + + if swap_path["type"] == "same_mint": + console.print(f" Type: Same-mint swap") + console.print(f" Mint: {swap_path['mint']}") + else: + console.print(f" Type: Cross-mint swap (via Lightning)") + console.print(f" From: {swap_path['from_mint']}") + console.print(f" To: {swap_path['to_mint']}") + console.print( + f" [yellow]Note: Cross-mint swaps incur Lightning fees[/yellow]" + ) + + # Confirm unless auto-confirmed + if not confirm: + if not typer.confirm("\nProceed with swap?"): + console.print("[yellow]Swap cancelled[/yellow]") + return + + # Execute the swap + console.print("\n[blue]Executing swap...[/blue]") + + try: + if swap_path["type"] == "same_mint": + # Same-mint swap: just reorganize denominations + # Select proofs from source keyset + source_proofs = [ + p + for p in state.proofs + if p.get("id") == swap_path["from_keyset"] + and p.get("mint") == swap_path["mint"] + ] + + # Filter to get exactly the amount we need + selected_proofs = [] + selected_amount = 0 + for proof in sorted( + source_proofs, key=lambda p: p["amount"], reverse=True + ): + if selected_amount >= amount: + break + selected_proofs.append(proof) + selected_amount += proof["amount"] + + if selected_amount < amount: + console.print( + f"[red]Could not select exactly {amount} {from_unit.upper()}[/red]" + ) + return + + # TODO: Implement keyset swap at the mint level + # For now, we'll use the existing swap mechanism + console.print( + "[yellow]Same-mint currency swap not yet implemented[/yellow]" + ) + console.print( + "[dim]This feature requires mint support for cross-keyset swaps[/dim]" + ) + + else: + # Cross-mint swap: melt from one mint, mint at another + console.print( + f"[dim]Step 1/3: Creating invoice at target mint...[/dim]" + ) + + # Create invoice at target mint for the amount + # Temporarily switch currency for the mint operation + original_currency = wallet.currency + wallet.currency = to_currency + + try: + target_mint = swap_path["to_mint"] + invoice, mint_task = await wallet.mint_async(amount, timeout=60) + + console.print( + f"[dim]Step 2/3: Paying invoice from source mint...[/dim]" + ) + + # Pay the invoice using funds from source mint + # The melt operation will automatically select proofs from the right mint/currency + wallet.currency = from_currency + await wallet.melt(invoice, target_mint=swap_path["from_mint"]) + + console.print( + f"[dim]Step 3/3: Receiving tokens at target mint...[/dim]" + ) + + # Wait for the mint to complete + success = await mint_task + + if success: + console.print( + f"[green]✅ Successfully swapped {amount} {from_unit.upper()} → {to_unit.upper()}![/green]" + ) + + # Show new balances + new_state = await wallet.fetch_wallet_state( + check_proofs=False + ) + + console.print("\n[cyan]Updated Balances:[/cyan]") + old_from = from_balance + new_from = new_state.balance_by_currency.get( + from_currency, 0 + ) + old_to = state.balance_by_currency.get(to_currency, 0) + new_to = new_state.balance_by_currency.get(to_currency, 0) + + console.print( + f" {from_unit.upper()}: {old_from} → {new_from} (-{old_from - new_from})" + ) + console.print( + f" {to_unit.upper()}: {old_to} → {new_to} (+{new_to - old_to})" + ) + + if (old_from - new_from) > amount: + fee = (old_from - new_from) - amount + console.print( + f" [yellow]Lightning fees: {fee} {from_unit}[/yellow]" + ) + else: + console.print( + "[red]❌ Swap failed - mint did not receive payment[/red]" + ) + + finally: + # Restore original currency + wallet.currency = original_currency + + except Exception as e: + console.print(f"[red]❌ Swap failed: {e}[/red]") + + try: + asyncio.run(_swap()) + except Exception as e: + handle_wallet_error(e) + raise typer.Exit(1) + + if __name__ == "__main__": cli() diff --git a/sixty_nuts/crypto.py b/sixty_nuts/crypto.py index 43103ba..9379a2e 100644 --- a/sixty_nuts/crypto.py +++ b/sixty_nuts/crypto.py @@ -11,7 +11,7 @@ import struct import time from dataclasses import dataclass -from typing import Tuple, TypedDict +from typing import Tuple from coincurve import PrivateKey, PublicKey from cryptography.hazmat.backends import default_backend @@ -19,6 +19,8 @@ from cryptography.hazmat.primitives.ciphers import Cipher, algorithms from cryptography.hazmat.primitives.kdf.hkdf import HKDFExpand +from .types import BlindedMessage + try: from bech32 import bech32_decode, convertbits # type: ignore except ModuleNotFoundError: # pragma: no cover – allow runtime miss @@ -27,44 +29,6 @@ # ────────────────────────────────────────────────────────────────────────────── -# NUT-00 Protocol Types (for network communication) -# ────────────────────────────────────────────────────────────────────────────── - - -class BlindedMessage(TypedDict): - """A blinded message as defined in NUT-00 specification. - - This is sent from Alice to Bob for minting or swapping tokens. - """ - - amount: int - id: str # keyset ID (hex string) - B_: str # blinded secret message (hex string) - - -class BlindSignature(TypedDict): - """A blind signature as defined in NUT-00 specification. - - This is sent from Bob to Alice after minting or swapping tokens. - """ - - amount: int - id: str # keyset ID (hex string) - C_: str # blinded signature (hex string) - - -class Proof(TypedDict): - """A proof as defined in NUT-00 specification. - - Generated by Alice from a BlindSignature. Used for melting tokens. - """ - - amount: int - id: str # keyset ID (hex string) - secret: str # secret message (utf-8 encoded string) - C: str # unblinded signature (hex string) - - # ────────────────────────────────────────────────────────────────────────────── # Internal Types (for implementation use) # ────────────────────────────────────────────────────────────────────────────── diff --git a/sixty_nuts/denominations.py b/sixty_nuts/denominations.py new file mode 100644 index 0000000..e636fb8 --- /dev/null +++ b/sixty_nuts/denominations.py @@ -0,0 +1,147 @@ +"""Dynamic denomination system for Cashu mints.""" + +from __future__ import annotations + + +from .types import KeysetInfo + + +class DenominationSystem: + """Manages dynamic denomination calculation based on keyset information.""" + + @staticmethod + def get_keyset_denominations(keyset_info: KeysetInfo) -> list[int]: + """Extract denominations from keyset keys. + + Args: + keyset_info: Keyset information containing keys + + Returns: + Sorted list of denominations (ascending order) + """ + denominations = [] + + # Keys are stored as dict with amount strings as keys + if isinstance(keyset_info.keys, dict): + for amount_str in keyset_info.keys: + try: + amount = int(amount_str) + denominations.append(amount) + except (ValueError, TypeError): + continue + + return sorted(denominations) + + @staticmethod + def calculate_optimal_split( + amount: int, available_denominations: list[int] + ) -> dict[int, int]: + """Calculate optimal denomination breakdown for an amount. + + Uses a greedy algorithm to minimize the number of tokens while + preferring the available denominations from the keyset. + + Args: + amount: Total amount to split + available_denominations: List of available denominations (sorted) + + Returns: + Dict of denomination -> count + """ + if not available_denominations: + # Fallback to default powers of 2 + return DenominationSystem._default_split(amount) + + denominations = {} + remaining = amount + + # Sort in descending order for greedy algorithm + for denom in sorted(available_denominations, reverse=True): + if remaining >= denom: + count = remaining // denom + denominations[denom] = count + remaining -= denom * count + + # If we couldn't split perfectly, add smallest denomination + if remaining > 0 and available_denominations: + smallest = min(available_denominations) + if smallest in denominations: + denominations[smallest] += 1 + else: + denominations[smallest] = 1 + + return denominations + + @staticmethod + def _default_split(amount: int) -> dict[int, int]: + """Default split using powers of 2.""" + denominations = {} + remaining = amount + + # Standard powers of 2 + for denom in [ + 16384, + 8192, + 4096, + 2048, + 1024, + 512, + 256, + 128, + 64, + 32, + 16, + 8, + 4, + 2, + 1, + ]: + if remaining >= denom: + count = remaining // denom + denominations[denom] = count + remaining -= denom * count + + return denominations + + @staticmethod + def validate_denominations( + keyset_info: KeysetInfo, requested_denominations: dict[int, int] + ) -> tuple[bool, str | None]: + """Validate if requested denominations are available in keyset. + + Args: + keyset_info: Keyset information to validate against + requested_denominations: Dict of denomination -> count + + Returns: + Tuple of (is_valid, error_message) + """ + available_denoms = DenominationSystem.get_keyset_denominations(keyset_info) + available_set = set(available_denoms) + + for denom in requested_denominations: + if denom not in available_set: + return False, f"Denomination {denom} not available in keyset" + + return True, None + + @staticmethod + def merge_denominations(denominations_list: list[dict[int, int]]) -> dict[int, int]: + """Merge multiple denomination dicts into one. + + Args: + denominations_list: List of denomination dicts to merge + + Returns: + Merged denomination dict + """ + merged: dict[int, int] = {} + + for denoms in denominations_list: + for denom, count in denoms.items(): + if denom in merged: + merged[denom] += count + else: + merged[denom] = count + + return merged diff --git a/sixty_nuts/mint.py b/sixty_nuts/mint.py index de73a38..b73c769 100644 --- a/sixty_nuts/mint.py +++ b/sixty_nuts/mint.py @@ -2,33 +2,23 @@ from __future__ import annotations -from typing import TypedDict, cast, Any, Literal +from typing import TypedDict, cast, Any import httpx -from .crypto import BlindedMessage, BlindSignature as BlindedSignature, Proof +from .types import ( + BlindedMessage, + BlindedSignature, + Proof, + CurrencyUnit, + MintError, +) # ────────────────────────────────────────────────────────────────────────────── # Type definitions based on NUT-01 and OpenAPI spec # ────────────────────────────────────────────────────────────────────────────── -# NUT-01 compliant currency units -CurrencyUnit = Literal[ - "btc", - "sat", - "msat", # Bitcoin units - "usd", - "eur", - "gbp", - "jpy", # Major fiat (ISO 4217) - "auth", # Authentication unit - # Add more ISO 4217 codes and stablecoin units as needed - "usdt", - "usdc", - "dai", # Common stablecoins -] - class ProofOptional(TypedDict, total=False): """Optional fields for Proof (NUT-00 specification).""" @@ -217,10 +207,6 @@ class PostRestoreResponse(TypedDict, total=False): # ────────────────────────────────────────────────────────────────────────────── -class MintError(Exception): - """Raised when mint returns an error response.""" - - class InvalidKeysetError(MintError): """Raised when keyset structure is invalid per NUT-01.""" diff --git a/sixty_nuts/types.py b/sixty_nuts/types.py index 28f5e8e..cc00f16 100644 --- a/sixty_nuts/types.py +++ b/sixty_nuts/types.py @@ -1,8 +1,9 @@ -"""Shared types for the sixty_nuts package.""" +"""Type definitions for the sixty-nuts package following NUT-00 specifications.""" from __future__ import annotations -from typing import TypedDict +from dataclasses import dataclass, field +from typing import Any, Literal, NotRequired, TypedDict class ProofDict(TypedDict): @@ -16,7 +17,228 @@ class ProofDict(TypedDict): secret: str C: str mint: str + unit: NotRequired[CurrencyUnit] # currency unit for this proof class WalletError(Exception): """Base class for wallet errors.""" + + +class MintError(Exception): + """Base exception for mint errors.""" + + pass + + +class RelayError(Exception): + """Base exception for relay errors.""" + + pass + + +class LNURLError(Exception): + """Base exception for LNURL errors.""" + + pass + + +# Standard currency units as per NUT-00 specification +CurrencyUnit = Literal[ + "btc", # Bitcoin + "sat", # Satoshi (1e-8 BTC) + "msat", # Millisatoshi (1e-11 BTC) + "usd", # US Dollar + "eur", # Euro + "gbp", # British Pound + "jpy", # Japanese Yen + "cny", # Chinese Yuan + "cad", # Canadian Dollar + "chf", # Swiss Franc + "aud", # Australian Dollar + "inr", # Indian Rupee + # Special units + "auth", # Authentication tokens + # Stablecoins + "usdt", # Tether + "usdc", # USD Coin + "dai", # DAI Stablecoin +] + + +class Proof(TypedDict): + """Proof format for mint API communication (NUT-00 compliant).""" + + id: str # keyset ID + amount: int + secret: str # hex encoded secret + C: str # hex encoded signature + + +class BlindedMessage(TypedDict): + """Blinded message for mint operations.""" + + amount: int + B_: str # hex encoded blinded message + id: str # keyset ID + + +class BlindedSignature(TypedDict): + """Blinded signature response from mint.""" + + amount: int + C_: str # hex encoded blinded signature + id: str # keyset ID + + +@dataclass +class KeysetInfo: + """Complete keyset information.""" + + id: str + mint_url: str + unit: CurrencyUnit + active: bool + input_fee_ppk: int = 0 + keys: dict[str, str] = field(default_factory=dict) # amount -> pubkey + denominations: list[int] = field(default_factory=list) # available denominations + + def __post_init__(self): + """Extract denominations from keys if not provided.""" + if not self.denominations and self.keys: + self.denominations = sorted([int(amount) for amount in self.keys.keys()]) + + +class EventKind: + """Nostr event kinds used by the wallet.""" + + # NIP-60 wallet events + Wallet = 37375 # NIP-60 wallet event kind (replaceable) + Token = 7375 # NIP-60 token event kind + TokenHistory = 7376 # NIP-60 spending history event kind + + # Standard Nostr events + Metadata = 0 # NIP-01 metadata + TextNote = 1 # NIP-01 text note + ContactList = 3 # NIP-02 contact list + DirectMessage = 4 # NIP-04 encrypted direct message + Deletion = 5 # NIP-09 event deletion + FollowList = 30000 # NIP-65 relay list + + +# Event type definitions +EventDict = dict[str, Any] # Generic Nostr event dictionary + + +@dataclass +class EnhancedWalletState: + """Enhanced wallet state with keyset tracking.""" + + balance_by_keyset: dict[str, int] # keyset_id -> balance + balance_by_currency: dict[CurrencyUnit, int] # currency -> balance + proofs: list[ProofDict] + keysets: dict[str, KeysetInfo] # keyset_id -> KeysetInfo + proof_to_event_id: dict[str, str] | None = None + + @property + def total_balance_sat(self) -> int: + """Get total balance in satoshis (only BTC-based currencies).""" + total = 0 + + for unit, balance in self.balance_by_currency.items(): + if unit == "sat": + total += balance + elif unit == "msat": + total += balance // 1000 + elif unit == "btc": + total += balance * 100_000_000 + + return total + + @property + def balance(self) -> int: + """Get total balance in satoshis for backward compatibility.""" + return self.total_balance_sat + + @property + def proofs_by_keyset(self) -> dict[str, list[ProofDict]]: + """Group proofs by keyset ID.""" + grouped: dict[str, list[ProofDict]] = {} + for proof in self.proofs: + keyset_id = proof["id"] + if keyset_id not in grouped: + grouped[keyset_id] = [] + grouped[keyset_id].append(proof) + return grouped + + @property + def proofs_by_mint(self) -> dict[str, list[ProofDict]]: + """Group proofs by mint URL.""" + grouped: dict[str, list[ProofDict]] = {} + for proof in self.proofs: + mint_url = proof["mint"] + if mint_url not in grouped: + grouped[mint_url] = [] + grouped[mint_url].append(proof) + return grouped + + @property + def proofs_by_mints(self) -> dict[str, list[ProofDict]]: + """Group proofs by mint URL (backward compatibility alias).""" + return self.proofs_by_mint + + @property + def mint_balances(self) -> dict[str, int]: + """Get balances for all mints (backward compatibility).""" + balances: dict[str, int] = {} + for mint_url, proofs in self.proofs_by_mint.items(): + balances[mint_url] = sum(p["amount"] for p in proofs) + return balances + + +class KeysetResponse(TypedDict): + """Response from GET /v1/keysets endpoint.""" + + keysets: list[dict[str, Any]] + + +class KeysResponse(TypedDict): + """Response from GET /v1/keys endpoint.""" + + keysets: list[dict[str, Any]] + + +class MintQuoteResponse(TypedDict): + """Response from POST /v1/mint/quote/bolt11 endpoint.""" + + quote: str + request: str # Lightning invoice + state: str + expiry: int + + +class MeltQuoteResponse(TypedDict): + """Response from POST /v1/melt/quote/bolt11 endpoint.""" + + quote: str + amount: int + fee_reserve: int + state: str + expiry: int + + +class SwapResponse(TypedDict): + """Response from POST /v1/swap endpoint.""" + + signatures: list[BlindedSignature] + + +class MintResponse(TypedDict): + """Response from POST /v1/mint/bolt11 endpoint.""" + + signatures: list[BlindedSignature] + + +class CheckStateResponse(TypedDict): + """Response from POST /v1/checkstate endpoint.""" + + states: list[dict[str, str]] # Y -> state mapping diff --git a/sixty_nuts/wallet.py b/sixty_nuts/wallet.py index 1a01765..21c4283 100644 --- a/sixty_nuts/wallet.py +++ b/sixty_nuts/wallet.py @@ -6,7 +6,6 @@ import json import secrets import time -from dataclasses import dataclass import asyncio from pathlib import Path @@ -15,9 +14,7 @@ from .mint import ( Mint, - ProofComplete as Proof, - BlindedMessage, - CurrencyUnit, + ProofComplete, ) from .relay import ( RelayManager, @@ -40,7 +37,15 @@ parse_lightning_invoice_amount, LNURLError, ) -from .types import ProofDict, WalletError +from .types import ( + ProofDict, + WalletError, + EnhancedWalletState, + KeysetInfo, + CurrencyUnit, + BlindedMessage, + Proof, +) from .events import EventManager try: @@ -228,40 +233,104 @@ def validate_mint_url(url: str) -> bool: # ────────────────────────────────────────────────────────────────────────────── -# Protocol-level definitions +# Keyset Management System # ────────────────────────────────────────────────────────────────────────────── -@dataclass -class WalletState: - """Current wallet state.""" +class KeysetManager: + """Centralized keyset management for multi-currency support.""" + + def __init__(self) -> None: + self.keysets: dict[str, KeysetInfo] = {} # keyset_id -> KeysetInfo + self.mint_keysets: dict[str, list[str]] = {} # mint_url -> [keyset_ids] + self.currency_keysets: dict[ + CurrencyUnit, list[str] + ] = {} # currency -> [keyset_ids] + self._cache_expiry = 3600 # 1 hour cache + self._cache_timestamps: dict[str, float] = {} # keyset_id -> timestamp + + def add_keyset(self, keyset_info: KeysetInfo) -> None: + """Add or update a keyset.""" + keyset_id = keyset_info.id + mint_url = keyset_info.mint_url + currency = keyset_info.unit + + # Store keyset + self.keysets[keyset_id] = keyset_info + self._cache_timestamps[keyset_id] = time.time() + + # Update mint index + if mint_url not in self.mint_keysets: + self.mint_keysets[mint_url] = [] + if keyset_id not in self.mint_keysets[mint_url]: + self.mint_keysets[mint_url].append(keyset_id) + + # Update currency index + if currency not in self.currency_keysets: + self.currency_keysets[currency] = [] + if keyset_id not in self.currency_keysets[currency]: + self.currency_keysets[currency].append(keyset_id) + + def get_keyset(self, keyset_id: str) -> KeysetInfo | None: + """Get a keyset by ID.""" + return self.keysets.get(keyset_id) + + def get_mint_keysets( + self, mint_url: str, *, active_only: bool = True + ) -> list[KeysetInfo]: + """Get all keysets for a mint.""" + keyset_ids = self.mint_keysets.get(mint_url, []) + keysets = [self.keysets[kid] for kid in keyset_ids if kid in self.keysets] + + if active_only: + keysets = [ks for ks in keysets if ks.active] + + return keysets + + def get_currency_keysets( + self, currency: CurrencyUnit, *, active_only: bool = True + ) -> list[KeysetInfo]: + """Get all keysets for a currency.""" + keyset_ids = self.currency_keysets.get(currency, []) + keysets = [self.keysets[kid] for kid in keyset_ids if kid in self.keysets] + + if active_only: + keysets = [ks for ks in keysets if ks.active] + + return keysets + + def find_keyset( + self, mint_url: str, currency: CurrencyUnit, *, active_only: bool = True + ) -> KeysetInfo | None: + """Find the best keyset for a mint and currency.""" + mint_keysets = self.get_mint_keysets(mint_url, active_only=active_only) + currency_keysets = [ks for ks in mint_keysets if ks.unit == currency] + + if not currency_keysets: + return None + + # Prefer keysets with lower fees + currency_keysets.sort(key=lambda ks: ks.input_fee_ppk) + return currency_keysets[0] + + def is_cached(self, keyset_id: str) -> bool: + """Check if keyset is cached and not expired.""" + if keyset_id not in self._cache_timestamps: + return False - balance: int - proofs: list[ProofDict] - mint_keysets: dict[str, list[dict[str, str]]] # mint_url -> keysets - proof_to_event_id: dict[str, str] | None = ( - None # proof_id -> event_id mapping (TODO) - ) + age = time.time() - self._cache_timestamps[keyset_id] + return age < self._cache_expiry - @property - def proofs_by_mints(self) -> dict[str, list[ProofDict]]: - """Group proofs by mint.""" - return { - mint_url: [proof for proof in self.proofs if proof["mint"] == mint_url] - for mint_url in self.mint_keysets.keys() - } - - @property - def mint_balances(self) -> dict[str, int]: - """Get balances for all mints.""" - return { - mint_url: sum(p["amount"] for p in self.proofs_by_mints[mint_url]) - for mint_url in self.mint_keysets.keys() - } + def clear_cache(self) -> None: + """Clear all cached keysets.""" + self.keysets.clear() + self.mint_keysets.clear() + self.currency_keysets.clear() + self._cache_timestamps.clear() # ────────────────────────────────────────────────────────────────────────────── -# Wallet implementation skeleton +# Wallet implementation # ────────────────────────────────────────────────────────────────────────────── @@ -273,8 +342,8 @@ def __init__( nsec: str, # nostr private key *, mint_urls: list[str] | None = None, # cashu mint urls (can have multiple) - currency: CurrencyUnit = "sat", # Updated to use NUT-01 compliant type - wallet_privkey: str | None = None, # separate privkey for P2PK ecash (NIP-61) + currency: CurrencyUnit = "sat", # TODO remove bc multi currency by default + wallet_privkey: str | None = None, # TODO remove contained in wallet event relays: list[str] | None = None, # nostr relays to use ) -> None: self.nsec = nsec @@ -299,6 +368,9 @@ def __init__( # Mint instances self.mints: dict[str, Mint] = {} + # Keyset manager for multi-currency support + self.keyset_manager = KeysetManager() + # Relay manager - will be initialized with proper relays later self.relay_manager = RelayManager( relay_urls=self.relays, # May be empty initially @@ -514,7 +586,7 @@ async def redeem(self, token: str, *, auto_swap: bool = True) -> tuple[int, str] return output_amount, unit # Return actual amount added to wallet after fees async def mint_async( - self, amount: int, *, timeout: int = 300 + self, amount: int, *, timeout: int = 300, mint_url: str | None = None ) -> tuple[str, asyncio.Task[bool]]: """Create a Lightning invoice and return a task that completes when paid. @@ -524,6 +596,7 @@ async def mint_async( Args: amount: Amount in the wallet's currency unit timeout: Maximum seconds to wait for payment (default: 5 minutes) + mint_url: Specific mint URL to use (defaults to primary mint) Returns: Tuple of (lightning_invoice, payment_task) @@ -535,9 +608,11 @@ async def mint_async( # Do other things... paid = await task # Wait for payment """ - mint_url = self._primary_mint_url() - invoice, quote_id = await self.create_quote(amount, mint_url) - mint = self._get_mint(mint_url) + target_mint_url = mint_url or self._primary_mint_url() + ## TODO maybe better self._get_mint(mint_url) # returns primary mint if None + ## then mint.create_quote(amount) + invoice, quote_id = await self.create_quote(amount, target_mint_url) + mint = self._get_mint(target_mint_url) async def poll_payment() -> bool: start_time = time.time() @@ -549,7 +624,7 @@ async def poll_payment() -> bool: quote_id, amount, minted_quotes=self._minted_quotes, - mint_url=mint_url, + mint_url=target_mint_url, # TODO this should not be needed, mint instance should contain url ) # If new proofs were minted, publish wallet events @@ -595,7 +670,13 @@ async def poll_payment() -> bool: # ─────────────────────────────── Send ───────────────────────────────────── - async def melt(self, invoice: str, *, target_mint: str | None = None) -> None: + async def melt( + self, + invoice: str, + *, + target_mint: str | None = None, # TODO remove because keyset contain mint info + target_keyset_id: str | None = None, # TODO implement + ) -> None: """Pay a Lightning invoice by melting tokens with automatic multi-mint support. Args: @@ -616,7 +697,7 @@ async def melt(self, invoice: str, *, target_mint: str | None = None) -> None: # Get current state and check balance state = await self.fetch_wallet_state(check_proofs=True) total_needed = int(invoice_amount * 1.01) - self.raise_if_insufficient_balance(state.balance, total_needed) + self.raise_if_insufficient_balance(state.total_balance_sat, total_needed) if target_mint is None: mint_balances = state.mint_balances @@ -643,7 +724,10 @@ async def melt(self, invoice: str, *, target_mint: str | None = None) -> None: mint_proofs = [self._proofdict_to_mint_proof(p) for p in selected_proofs] # Execute the melt operation - melt_resp = await mint.melt(quote=melt_quote["quote"], inputs=mint_proofs) + # Cast to ProofComplete since melt expects it (ProofComplete extends Proof with optional fields) + melt_resp = await mint.melt( + quote=melt_quote["quote"], inputs=cast(list[ProofComplete], mint_proofs) + ) # Check if payment was successful if not melt_resp.get("paid", False): @@ -678,9 +762,12 @@ async def melt(self, invoice: str, *, target_mint: str | None = None) -> None: async def send( self, amount: int, - target_mint: str | None = None, + target_mint: str | None = None, # TODO remove bc keyset contains mint info + keyset_id: str | None = None, # TODO implement *, token_version: int = 4, # Default to V4 (CashuB) + unit: CurrencyUnit + | None = None, # TODO remove bc keyset should handle currency ) -> str: """Create a Cashu token for sending. @@ -689,9 +776,11 @@ async def send( splitting proofs to achieve exact amounts. Args: - amount: Amount to send in the wallet's currency unit + amount: Amount to send in the specified currency unit target_mint: Target mint URL (defaults to primary mint) token_version: Token format version (3 for CashuA, 4 for CashuB) + unit: Currency unit to send (defaults to wallet's currency) + keyset_id: Specific keyset ID to send from Returns: Cashu token string that can be sent to another wallet @@ -706,23 +795,76 @@ async def send( # Send using V3 format token = await wallet.send(100, token_version=3) + + # Send USD tokens + token = await wallet.send(50, unit="usd") + + # Send from specific keyset + token = await wallet.send(100, keyset_id="abc123...") """ if token_version not in [3, 4]: raise ValueError(f"Unsupported token version: {token_version}. Use 3 or 4.") - if target_mint is None: - target_mint = await self.summon_mint_with_balance(amount) + # Use specified unit or default to wallet currency + send_unit = unit or self.currency + # Get wallet state and filter proofs by unit/keyset state = await self.fetch_wallet_state(check_proofs=True) - balance = await self.get_balance_by_mint(target_mint) - if balance < amount: + + # Filter proofs based on unit and keyset_id + filtered_proofs = state.proofs + if keyset_id: + # If keyset_id specified, use only proofs from that keyset + filtered_proofs = [p for p in filtered_proofs if p.get("id") == keyset_id] + if not filtered_proofs: + raise WalletError(f"No proofs found for keyset {keyset_id}") + + # Verify keyset matches requested unit + keyset_info = self.keyset_manager.get_keyset(keyset_id) + if keyset_info and keyset_info.unit != send_unit: + raise WalletError( + f"Keyset {keyset_id} uses {keyset_info.unit}, but requested {send_unit}" + ) + else: + # Filter by currency unit + unit_filtered = [] + for proof in filtered_proofs: + proof_keyset = self.keyset_manager.get_keyset(proof.get("id", "")) + if proof_keyset and proof_keyset.unit == send_unit: + unit_filtered.append(proof) + filtered_proofs = unit_filtered + + if not filtered_proofs: + raise WalletError(f"No proofs found for currency {send_unit}") + + # Calculate balance from filtered proofs + filtered_balance = sum(p["amount"] for p in filtered_proofs) + + if target_mint is None: + # Find mint with balance in the requested currency + mint_urls = set(p.get("mint", "") for p in filtered_proofs) + if not mint_urls: + raise WalletError(f"No mints found with {send_unit} balance") + + # Use mint with highest balance + best_mint = "" + best_balance = 0 + for mint_url in mint_urls: + mint_proofs = [p for p in filtered_proofs if p.get("mint") == mint_url] + mint_balance = sum(p["amount"] for p in mint_proofs) + if mint_balance > best_balance: + best_balance = mint_balance + best_mint = mint_url + target_mint = best_mint + + if filtered_balance < amount: raise WalletError( - f"Insufficient balance at {target_mint}. Need at least {amount} {self.currency} " - f"(amount: {amount}), but have {balance}" + f"Insufficient balance. Need at least {amount} {send_unit}, " + f"but have {filtered_balance}" ) selected_proofs, consumed_proofs = await self._select_proofs( - state.proofs, amount, target_mint + filtered_proofs, amount, target_mint ) token = self._serialize_proofs_for_token( @@ -738,18 +880,24 @@ async def send( return token - async def send_to_lnurl(self, lnurl: str, amount: int) -> int: + async def send_to_lnurl( + self, + lnurl: str, + amount: int, + *, + unit: CurrencyUnit | None = None, + keyset_id: str | None = None, + ) -> int: """Send funds to an LNURL address. Args: lnurl: LNURL string (can be lightning:, user@host, bech32, or direct URL) - amount: Amount to send in the wallet's currency unit - fee_estimate: Fee estimate as a percentage (default: 1%) - max_fee: Maximum fee in the wallet's currency unit (optional) - mint_fee_reserve: Expected mint fee reserve (default: 1 sat) + amount: Amount to send in the specified currency unit + unit: Currency unit to send (defaults to wallet's currency) + keyset_id: Specific keyset ID to send from Returns: - Amount actually paid in the wallet's currency unit + Amount actually paid in the specified currency unit Raises: WalletError: If amount is outside LNURL limits or insufficient balance @@ -759,40 +907,49 @@ async def send_to_lnurl(self, lnurl: str, amount: int) -> int: # Send 1000 sats to a Lightning Address paid = await wallet.send_to_lnurl("user@getalby.com", 1000) print(f"Paid {paid} sats") + + # Send USD to Lightning Address + paid = await wallet.send_to_lnurl("user@getalby.com", 50, unit="usd") """ - # Get current balance + # Use specified unit or default to wallet currency + send_unit = unit or self.currency + + # Get current balance for the specified unit state = await self.fetch_wallet_state(check_proofs=True) - balance = state.balance + # Get balance for the specific unit + unit_balance = state.balance_by_currency.get(send_unit, 0) + + # Estimate fees (1% with minimum) estimated_fee_sats = max(amount * 0.01, 2) - if self.currency == "msat": + if send_unit == "msat": estimated_fee = estimated_fee_sats * 1000 else: estimated_fee = estimated_fee_sats - if balance < amount + estimated_fee: + if unit_balance < amount + estimated_fee: raise WalletError( - f"Insufficient balance. Need at least {amount + estimated_fee} {self.currency} " - f"(amount: {amount} + estimated fees: {estimated_fee} {self.currency}), but have {balance}" + f"Insufficient balance. Need at least {amount + estimated_fee} {send_unit} " + f"(amount: {amount} + estimated fees: {estimated_fee} {send_unit}), but have {unit_balance}" ) # Get LNURL data lnurl_data = await get_lnurl_data(lnurl) # Convert amounts based on currency - if self.currency == "sat": + if send_unit == "sat": amount_msat = amount * 1000 min_sendable_sat = lnurl_data["min_sendable"] // 1000 max_sendable_sat = lnurl_data["max_sendable"] // 1000 unit_str = "sat" - elif self.currency == "msat": + elif send_unit == "msat": amount_msat = amount min_sendable_sat = lnurl_data["min_sendable"] max_sendable_sat = lnurl_data["max_sendable"] unit_str = "msat" else: - raise WalletError(f"Currency {self.currency} not supported for LNURL") + raise WalletError(f"Currency {send_unit} not supported for LNURL") # Check amount limits if not ( @@ -911,39 +1068,37 @@ async def _consolidate_proofs( print(f"Warning: Failed to consolidate proofs for {mint_url}: {e}") continue - def _calculate_optimal_denominations(self, amount: int) -> dict[int, int]: + def _calculate_optimal_denominations( + self, amount: int, keyset_id: str | None = None, mint_url: str | None = None + ) -> dict[int, int]: """Calculate optimal denomination breakdown for an amount. - Returns dict of denomination -> count. + Args: + amount: Amount to split into denominations + keyset_id: Specific keyset to use (optional) + mint_url: Mint URL to find keyset from (optional) + + Returns: + Dict of denomination -> count """ - denominations = {} - # Ensure we're working with an integer - remaining = int(amount) - - # Use powers of 2 for optimal denomination - for denom in [ - 16384, - 8192, - 4096, - 2048, - 1024, - 512, - 256, - 128, - 64, - 32, - 16, - 8, - 4, - 2, - 1, - ]: - if remaining >= denom: - count = remaining // denom - denominations[denom] = int(count) # Ensure count is always int - remaining -= denom * count + from .denominations import DenominationSystem + + # Try to get keyset info for dynamic denominations + keyset_info = None - return denominations + if keyset_id: + keyset_info = self.keyset_manager.get_keyset(keyset_id) + elif mint_url: + # Find best keyset for the mint and currency + keyset_info = self.keyset_manager.find_keyset(mint_url, self.currency) + + if keyset_info: + # Use dynamic denominations from keyset + available_denoms = DenominationSystem.get_keyset_denominations(keyset_info) + return DenominationSystem.calculate_optimal_split(amount, available_denoms) + else: + # Fallback to default powers of 2 + return DenominationSystem._default_split(amount) async def _select_proofs( self, proofs: list[ProofDict], amount: int, target_mint: str @@ -1156,7 +1311,10 @@ async def _swap_proof_denominations( blinding_factors.append(r_hex) # Perform swap - swap_resp = await mint.swap(inputs=mint_proofs, outputs=outputs) + # Cast to ProofComplete since swap expects it (ProofComplete extends Proof with optional fields) + swap_resp = await mint.swap( + inputs=cast(list[ProofComplete], mint_proofs), outputs=outputs + ) # Get mint keys for unblinding keys_resp = await mint.get_keys(keyset_id) @@ -1796,6 +1954,48 @@ def _get_mint(self, mint_url: str) -> Mint: self.mints[mint_url] = Mint(mint_url, client=self.mint_client) return self.mints[mint_url] + async def _update_keysets_from_mint(self, mint_url: str) -> None: + """Fetch and update keysets from a mint.""" + mint = self._get_mint(mint_url) + + try: + # Get all keysets from the mint + keysets_resp = await mint.get_keysets() + keysets = keysets_resp.get("keysets", []) + + for keyset_data in keysets: + keyset_id = str(keyset_data.get("id", "")) + if not keyset_id: + continue + + # Get detailed keys for this keyset + keys_resp = await mint.get_keys(keyset_id) + keys_data = {} + + for ks in keys_resp.get("keysets", []): + if str(ks.get("id")) == keyset_id: + keys_data = ks.get("keys", {}) + break + + # Create KeysetInfo and add to manager + # Get unit and cast to CurrencyUnit type + unit_str = keyset_data.get("unit", "sat") + unit: CurrencyUnit = cast(CurrencyUnit, unit_str) + + keyset_info = KeysetInfo( + id=keyset_id, + mint_url=mint_url, + unit=unit, + active=keyset_data.get("active", True), + input_fee_ppk=keyset_data.get("input_fee_ppk", 0), + keys=keys_data if isinstance(keys_data, dict) else {}, + ) + + self.keyset_manager.add_keyset(keyset_info) + + except Exception as e: + print(f"Warning: Failed to update keysets from {mint_url}: {e}") + def _serialize_proofs_for_token( self, proofs: list[ProofDict], mint_url: str, token_version: int ) -> str: @@ -2072,7 +2272,7 @@ async def _validate_proofs_with_cache( async def fetch_wallet_state( self, *, check_proofs: bool = True, check_local_backups: bool = True - ) -> WalletState: + ) -> EnhancedWalletState: """Fetch wallet, token events and compute balance. Args: @@ -2263,9 +2463,6 @@ async def fetch_wallet_state( # Add back pending proofs (assume they're valid) all_proofs = validated_proofs + pending_proofs - # Calculate balance - balance = sum(p["amount"] for p in all_proofs) - # Fetch mint keysets mint_keysets: dict[str, list[dict[str, str]]] = {} for mint_url in self.mint_urls: @@ -2306,11 +2503,8 @@ async def fetch_wallet_state( if not should_check: # Skip backup check if we just did it - return WalletState( - balance=balance, - proofs=all_proofs, - mint_keysets=mint_keysets, - proof_to_event_id=proof_to_event_id, + return await self._build_enhanced_wallet_state( + all_proofs, mint_keysets, proof_to_event_id ) # Check if we have any backup files with missing proofs existing_proof_ids = set(f"{p['secret']}:{p['C']}" for p in all_proofs) @@ -2365,11 +2559,8 @@ async def fetch_wallet_state( ) await self._cleanup_spent_proof_backups() - return WalletState( - balance=balance, - proofs=all_proofs, - mint_keysets=mint_keysets, - proof_to_event_id=proof_to_event_id, + return await self._build_enhanced_wallet_state( + all_proofs, mint_keysets, proof_to_event_id ) async def get_balance(self, *, check_proofs: bool = True) -> int: @@ -2581,6 +2772,62 @@ def _get_pubkey(self) -> str: """Get the Nostr public key for this wallet.""" return get_pubkey(self._privkey) + async def _build_enhanced_wallet_state( + self, + all_proofs: list[ProofDict], + mint_keysets: dict[str, list[dict[str, str]]], + proof_to_event_id: dict[str, str] | None, + ) -> EnhancedWalletState: + """Build enhanced wallet state from proofs and keysets.""" + # Update keyset manager with latest data from mints + for mint_url in self.mint_urls: + if not self.keyset_manager.is_cached(f"{mint_url}:check"): + await self._update_keysets_from_mint(mint_url) + # Mark as checked (simple cache mechanism) + self.keyset_manager._cache_timestamps[f"{mint_url}:check"] = time.time() + + # Calculate balance by keyset and currency + balance_by_keyset: dict[str, int] = {} + balance_by_currency: dict[CurrencyUnit, int] = {} + keysets: dict[str, KeysetInfo] = {} + + # Get all keysets from the manager + for keyset_id, keyset_info in self.keyset_manager.keysets.items(): + keysets[keyset_id] = keyset_info + + # Calculate balances + for proof in all_proofs: + keyset_id = proof["id"] + amount = proof["amount"] + + # Update keyset balance + if keyset_id not in balance_by_keyset: + balance_by_keyset[keyset_id] = 0 + balance_by_keyset[keyset_id] += amount + + # Get keyset info to determine currency + proof_keyset_info: KeysetInfo | None = self.keyset_manager.get_keyset( + keyset_id + ) + if proof_keyset_info: + currency = proof_keyset_info.unit + else: + # Fallback to wallet currency if keyset not found + currency = self.currency + + # Update currency balance + if currency not in balance_by_currency: + balance_by_currency[currency] = 0 + balance_by_currency[currency] += amount + + return EnhancedWalletState( + balance_by_keyset=balance_by_keyset, + balance_by_currency=balance_by_currency, + proofs=all_proofs, + keysets=keysets, + proof_to_event_id=proof_to_event_id, + ) + async def check_wallet_event_exists(self) -> tuple[bool, NostrEvent | None]: """Check if a wallet event already exists for this wallet. From 5b3b0aac27b229d35126619dd5c96b4f11b40ff1 Mon Sep 17 00:00:00 2001 From: Shroominic Date: Mon, 14 Jul 2025 15:52:31 -0300 Subject: [PATCH 03/17] fix unit tests --- tests/unit/test_mint.py | 43 +++--- tests/unit/test_nut02.py | 63 ++++----- tests/unit/test_relay.py | 16 +-- tests/unit/test_wallet.py | 291 +++++++++++++++++++++++--------------- 4 files changed, 226 insertions(+), 187 deletions(-) diff --git a/tests/unit/test_mint.py b/tests/unit/test_mint.py index 6ddf936..19fbc6f 100644 --- a/tests/unit/test_mint.py +++ b/tests/unit/test_mint.py @@ -9,7 +9,6 @@ MintError, InvalidKeysetError, BlindedMessage, - Proof, CurrencyUnit, ) @@ -36,13 +35,10 @@ async def test_mint_initialization(self) -> None: """Test mint initialization.""" mint = Mint("https://testnut.cashu.space") assert mint.url == "https://testnut.cashu.space" - assert mint._owns_client is True await mint.aclose() - # Test with custom client client = httpx.AsyncClient() - mint = Mint("https://testnut.cashu.space", client=client) - assert mint._owns_client is False + mint = Mint("https://testnut.cashu.space") await client.aclose() async def test_get_info(self, mint, mock_client) -> None: @@ -87,28 +83,27 @@ async def test_get_keys_nut01_compliant(self, mint, mock_client) -> None: mock_client.request.return_value = mock_response mint.client = mock_client - # Test without keyset_id - keys = await mint.get_keys() - assert len(keys["keysets"]) == 1 - assert keys["keysets"][0]["id"] == "00ad268c4d1f5826" - assert keys["keysets"][0]["unit"] == "sat" - assert "keys" in keys["keysets"][0] + keysets = await mint.get_active_keysets() + assert len(keysets) == 1 + assert keysets[0]["id"] == "00ad268c4d1f5826" + assert keysets[0]["unit"] == "sat" + assert "keys" in keysets[0] - # Test with keyset_id - keys = await mint.get_keys("00ad268c4d1f5826") + keyset = await mint.get_keyset("00ad268c4d1f5826") mock_client.request.assert_called_with( "GET", "https://testnut.cashu.space/v1/keys/00ad268c4d1f5826", json=None, params=None, ) + assert keyset["id"] == "00ad268c4d1f5826" + assert keyset["unit"] == "sat" async def test_get_keys_invalid_response(self, mint, mock_client) -> None: """Test get_keys with invalid response structure.""" mock_response = Mock() mock_response.status_code = 200 - # Test missing keysets field mock_response.json.return_value = {"invalid": "response"} mock_client.request.return_value = mock_response mint.client = mock_client @@ -116,7 +111,7 @@ async def test_get_keys_invalid_response(self, mint, mock_client) -> None: with pytest.raises( InvalidKeysetError, match="Response missing 'keysets' field" ): - await mint.get_keys() + await mint.get_active_keysets() async def test_get_keys_invalid_keyset_structure(self, mint, mock_client) -> None: """Test get_keys with invalid keyset structure.""" @@ -126,7 +121,6 @@ async def test_get_keys_invalid_keyset_structure(self, mint, mock_client) -> Non "keysets": [ { "id": "00ad268c4d1f5826", - # Missing unit and keys fields } ] } @@ -135,7 +129,7 @@ async def test_get_keys_invalid_keyset_structure(self, mint, mock_client) -> Non mint.client = mock_client with pytest.raises(InvalidKeysetError, match="Invalid keyset at index 0"): - await mint.get_keys() + await mint.get_active_keysets() async def test_validate_compressed_pubkey(self, mint) -> None: """Test compressed public key validation.""" @@ -263,7 +257,9 @@ async def test_swap(self, mint, mock_client) -> None: mock_client.request.return_value = mock_response mint.client = mock_client - inputs = [Proof(id="00ad268c4d1f5826", amount=3, secret="secret", C="02old...")] + inputs = [ + {"id": "00ad268c4d1f5826", "amount": 3, "secret": "secret", "C": "02old..."} + ] outputs = [ BlindedMessage(amount=1, id="00ad268c4d1f5826", B_="blind1"), BlindedMessage(amount=2, id="00ad268c4d1f5826", B_="blind2"), @@ -361,20 +357,17 @@ async def test_keys_response_structure(self, mint, mock_client) -> None: mock_client.request.return_value = mock_response mint.client = mock_client - keys_response = await mint.get_keys() + keysets = await mint.get_active_keysets() - # Verify structure matches NUT-01 - assert "keysets" in keys_response - assert isinstance(keys_response["keysets"], list) - assert len(keys_response["keysets"]) > 0 + assert isinstance(keysets, list) + assert len(keysets) > 0 - keyset = keys_response["keysets"][0] + keyset = keysets[0] assert "id" in keyset assert "unit" in keyset assert "keys" in keyset assert isinstance(keyset["keys"], dict) - # Verify all pubkeys are compressed secp256k1 for amount, pubkey in keyset["keys"].items(): assert mint._is_valid_compressed_pubkey(pubkey) diff --git a/tests/unit/test_nut02.py b/tests/unit/test_nut02.py index b2b1013..0991692 100644 --- a/tests/unit/test_nut02.py +++ b/tests/unit/test_nut02.py @@ -6,9 +6,9 @@ from typing import cast from sixty_nuts.crypto import derive_keyset_id, validate_keyset_id -from sixty_nuts.mint import Mint, MintError +from sixty_nuts.mint import Mint from sixty_nuts.temp import TempWallet -from sixty_nuts.wallet import ProofDict +from sixty_nuts.wallet import Proof class TestKeysetIDDerivation: @@ -87,7 +87,7 @@ def test_calculate_input_fees_zero_fee(self): """Test fee calculation with zero fee rate.""" wallet = TempWallet() proofs = cast( - list[ProofDict], + list[Proof], [ { "id": "keyset1", @@ -114,7 +114,7 @@ def test_calculate_input_fees_positive_fee(self): """Test fee calculation with positive fee rate.""" wallet = TempWallet() proofs = cast( - list[ProofDict], + list[Proof], [ { "id": "keyset1", @@ -150,7 +150,7 @@ def test_calculate_input_fees_fractional(self): """Test fee calculation with fractional fees.""" wallet = TempWallet() proofs = cast( - list[ProofDict], + list[Proof], [ { "id": "keyset1", @@ -179,7 +179,7 @@ def test_calculate_input_fees_string_conversion(self): """Test fee calculation with string fee value.""" wallet = TempWallet() proofs = cast( - list[ProofDict], + list[Proof], [ { "id": "keyset1", @@ -201,7 +201,7 @@ def test_calculate_input_fees_invalid_fee(self): """Test fee calculation with invalid fee value.""" wallet = TempWallet() proofs = cast( - list[ProofDict], + list[Proof], [ { "id": "keyset1", @@ -222,7 +222,7 @@ def test_estimate_transaction_fees(self): """Test total transaction fee estimation.""" wallet = TempWallet() proofs = cast( - list[ProofDict], + list[Proof], [ { "id": "keyset1", @@ -375,10 +375,8 @@ class TestKeysetIntegration: """Test integration of keyset and fee functionality.""" async def test_get_validated_keysets_success(self): - """Test successful keyset validation.""" mint = Mint("https://test.mint") - # Mock valid response mock_response = { "keysets": [ { @@ -389,43 +387,35 @@ async def test_get_validated_keysets_success(self): } ] } - mint.get_keysets = AsyncMock(return_value=mock_response) + mint.get_keysets_info = AsyncMock(return_value=mock_response["keysets"]) - result = await mint.get_validated_keysets() - assert result == mock_response + result = await mint.get_keysets_info() + assert mint.validate_keysets_response(mock_response) + assert result == mock_response["keysets"] async def test_get_validated_keysets_failure(self): - """Test keyset validation failure.""" mint = Mint("https://test.mint") - # Mock invalid response - mock_response = { - "keysets": [ - {"id": "invalid", "unit": "sat", "active": True} # Invalid ID - ] - } - mint.get_keysets = AsyncMock(return_value=mock_response) + mock_response = {"keysets": [{"id": "invalid", "unit": "sat", "active": True}]} + mint.get_keysets_info = AsyncMock(return_value=mock_response["keysets"]) - with pytest.raises(MintError, match="Invalid keysets response"): - await mint.get_validated_keysets() + result = await mint.get_keysets_info() + assert not mint.validate_keysets_response(mock_response) + assert result == mock_response["keysets"] async def test_calculate_total_input_fees_success(self): - """Test successful total input fee calculation.""" wallet = TempWallet() - # Mock mint and keysets response mint = Mock() - mint.get_keysets = AsyncMock( - return_value={ - "keysets": [ - {"id": "keyset1", "input_fee_ppk": 1000}, - {"id": "keyset2", "input_fee_ppk": 2000}, - ] - } + mint.get_keysets_info = AsyncMock( + return_value=[ + {"id": "keyset1", "input_fee_ppk": 1000}, + {"id": "keyset2", "input_fee_ppk": 2000}, + ] ) proofs = cast( - list[ProofDict], + list[Proof], [ { "id": "keyset1", @@ -452,9 +442,6 @@ async def test_calculate_total_input_fees_success(self): ) total_fee = await wallet.calculate_total_input_fees(mint, proofs) - # keyset1: 2 proofs * 1000 ppk / 1000 = 2 sats - # keyset2: 1 proof * 2000 ppk / 1000 = 2 sats - # total: 4 sats assert total_fee == 4 async def test_calculate_total_input_fees_failure(self): @@ -463,10 +450,10 @@ async def test_calculate_total_input_fees_failure(self): # Mock mint that raises exception mint = Mock() - mint.get_keysets = AsyncMock(side_effect=Exception("Mint error")) + mint.get_keysets_info = AsyncMock(side_effect=Exception("Mint error")) proofs = cast( - list[ProofDict], + list[Proof], [ { "id": "keyset1", diff --git a/tests/unit/test_relay.py b/tests/unit/test_relay.py index 561b9eb..fa67d80 100644 --- a/tests/unit/test_relay.py +++ b/tests/unit/test_relay.py @@ -4,13 +4,13 @@ import pytest import json from unittest.mock import AsyncMock, MagicMock, patch -from sixty_nuts.relay import NostrRelay, RelayError, NostrEvent, NostrFilter +from sixty_nuts.relay import Relay, RelayError, NostrEvent, NostrFilter @pytest.fixture async def relay(): """Create a relay instance for testing.""" - relay = NostrRelay("wss://relay.test.com") + relay = Relay("wss://relay.test.com") yield relay if relay.ws and not relay.ws.closed: await relay.disconnect() @@ -25,12 +25,12 @@ def mock_websocket(): return ws -class TestNostrRelay: - """Test cases for NostrRelay class.""" +class TestRelay: + """Test cases for Relay class.""" async def test_relay_initialization(self): """Test relay initialization.""" - relay = NostrRelay("wss://relay.test.com") + relay = Relay("wss://relay.test.com") assert relay.url == "wss://relay.test.com" assert relay.ws is None assert relay.subscriptions == {} @@ -48,7 +48,7 @@ async def async_connect(*args, **kwargs): mock_connect.side_effect = async_connect - relay = NostrRelay("wss://relay.test.com") + relay = Relay("wss://relay.test.com") await relay.connect() assert relay.ws == mock_ws @@ -138,7 +138,7 @@ async def async_connect(*args, **kwargs): mock_connect.side_effect = async_connect - relay = NostrRelay("wss://relay.test.com") + relay = Relay("wss://relay.test.com") # Mock the response sequence event1 = { @@ -298,7 +298,7 @@ async def async_connect(*args, **kwargs): @pytest.mark.asyncio async def test_relay_lifecycle(): """Test the full lifecycle of relay operations.""" - relay = NostrRelay("wss://relay.test.com") + relay = Relay("wss://relay.test.com") # Can't actually connect without a real relay # Just test that the object is created properly diff --git a/tests/unit/test_wallet.py b/tests/unit/test_wallet.py index 8d8a13d..ae085d0 100644 --- a/tests/unit/test_wallet.py +++ b/tests/unit/test_wallet.py @@ -5,7 +5,8 @@ from sixty_nuts.wallet import Wallet from sixty_nuts.crypto import generate_privkey -from sixty_nuts.types import ProofDict +from sixty_nuts.types import Proof +from unittest.mock import patch, AsyncMock class TestWalletFeeCalculation: @@ -16,8 +17,7 @@ def test_fee_calculation_empty_proofs(self): wallet = Wallet( nsec=generate_privkey(), mint_urls=["http://test.mint"], - currency="sat", - relays=["ws://test.relay"], + relay_urls=["ws://test.relay"], ) fees = wallet.calculate_input_fees([], {"input_fee_ppk": 1000}) @@ -28,13 +28,26 @@ def test_fee_calculation_with_proofs(self): wallet = Wallet( nsec=generate_privkey(), mint_urls=["http://test.mint"], - currency="sat", - relays=["ws://test.relay"], + relay_urls=["ws://test.relay"], ) - mock_proofs = [ - ProofDict(id="test1", amount=10, secret="secret1", C="C1", mint="test"), - ProofDict(id="test2", amount=20, secret="secret2", C="C2", mint="test"), + mock_proofs: list[Proof] = [ + { + "id": "test1", + "amount": 10, + "secret": "secret1", + "C": "C1", + "mint": "test", + "unit": "sat", + }, + { + "id": "test2", + "amount": 20, + "secret": "secret2", + "C": "C2", + "mint": "test", + "unit": "sat", + }, ] # Test with 1 sat per proof fee @@ -52,12 +65,18 @@ def test_fee_calculation_fractional(self): wallet = Wallet( nsec=generate_privkey(), mint_urls=["http://test.mint"], - currency="sat", - relays=["ws://test.relay"], + relay_urls=["ws://test.relay"], ) - mock_proofs = [ - ProofDict(id="test1", amount=1, secret="secret1", C="C1", mint="test"), + mock_proofs: list[Proof] = [ + { + "id": "test1", + "amount": 1, + "secret": "secret1", + "C": "C1", + "mint": "test", + "unit": "sat", + }, ] # Test with 0.5 sat per proof fee (500 ppk) @@ -77,13 +96,26 @@ def test_estimate_transaction_fees(self): wallet = Wallet( nsec=generate_privkey(), mint_urls=["http://test.mint"], - currency="sat", - relays=["ws://test.relay"], + relay_urls=["ws://test.relay"], ) - mock_proofs = [ - ProofDict(id="test1", amount=10, secret="secret1", C="C1", mint="test"), - ProofDict(id="test2", amount=20, secret="secret2", C="C2", mint="test"), + mock_proofs: list[Proof] = [ + { + "id": "test1", + "amount": 10, + "secret": "secret1", + "C": "C1", + "mint": "test", + "unit": "sat", + }, + { + "id": "test2", + "amount": 20, + "secret": "secret2", + "C": "C2", + "mint": "test", + "unit": "sat", + }, ] keyset_info = {"input_fee_ppk": 1000} # 1 sat per proof @@ -105,21 +137,21 @@ def test_token_serialization_v3(self): wallet = Wallet( nsec=generate_privkey(), mint_urls=["http://test.mint"], - currency="sat", - relays=["ws://test.relay"], + relay_urls=["ws://test.relay"], ) - sample_proofs = [ - ProofDict( - id="00ffe7838f8d9312", - amount=10, - secret="dGVzdA==", # base64 "test" - C="02a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2", - mint="http://test.mint", - ) + sample_proofs: list[Proof] = [ + { + "id": "00ffe7838f8d9312", + "amount": 10, + "secret": "dGVzdA==", + "C": "02a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2", + "mint": "http://test.mint", + "unit": "sat", + } ] - token_v3 = wallet._serialize_proofs_v3(sample_proofs, "http://test.mint") + token_v3 = wallet._serialize_proofs_v3(sample_proofs, "http://test.mint", "sat") assert token_v3.startswith("cashuA"), "V3 tokens should start with cashuA" def test_token_serialization_v4(self): @@ -127,21 +159,21 @@ def test_token_serialization_v4(self): wallet = Wallet( nsec=generate_privkey(), mint_urls=["http://test.mint"], - currency="sat", - relays=["ws://test.relay"], + relay_urls=["ws://test.relay"], ) - sample_proofs = [ - ProofDict( - id="00ffe7838f8d9312", - amount=10, - secret="dGVzdA==", # base64 "test" - C="02a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2", - mint="http://test.mint", - ) + sample_proofs: list[Proof] = [ + { + "id": "00ffe7838f8d9312", + "amount": 10, + "secret": "dGVzdA==", + "C": "02a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2", + "mint": "http://test.mint", + "unit": "sat", + } ] - token_v4 = wallet._serialize_proofs_v4(sample_proofs, "http://test.mint") + token_v4 = wallet._serialize_proofs_v4(sample_proofs, "http://test.mint", "sat") assert token_v4.startswith("cashuB"), "V4 tokens should start with cashuB" def test_token_roundtrip_v3(self): @@ -149,22 +181,22 @@ def test_token_roundtrip_v3(self): wallet = Wallet( nsec=generate_privkey(), mint_urls=["http://test.mint"], - currency="sat", - relays=["ws://test.relay"], + relay_urls=["ws://test.relay"], ) - sample_proofs = [ - ProofDict( - id="00ffe7838f8d9312", - amount=10, - secret="dGVzdA==", # base64 "test" - C="02a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2", - mint="http://test.mint", - ) + sample_proofs: list[Proof] = [ + { + "id": "00ffe7838f8d9312", + "amount": 10, + "secret": "dGVzdA==", + "C": "02a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2", + "mint": "http://test.mint", + "unit": "sat", + } ] # Serialize to V3 - token_v3 = wallet._serialize_proofs_v3(sample_proofs, "http://test.mint") + token_v3 = wallet._serialize_proofs_v3(sample_proofs, "http://test.mint", "sat") # Parse back mint_url, unit, parsed_proofs = wallet._parse_cashu_token(token_v3) @@ -179,22 +211,22 @@ def test_token_roundtrip_v4(self): wallet = Wallet( nsec=generate_privkey(), mint_urls=["http://test.mint"], - currency="sat", - relays=["ws://test.relay"], + relay_urls=["ws://test.relay"], ) - sample_proofs = [ - ProofDict( - id="00ffe7838f8d9312", - amount=10, - secret="dGVzdA==", # base64 "test" - C="02a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2", - mint="http://test.mint", - ) + sample_proofs: list[Proof] = [ + { + "id": "00ffe7838f8d9312", + "amount": 10, + "secret": "dGVzdA==", + "C": "02a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2", + "mint": "http://test.mint", + "unit": "sat", + } ] # Serialize to V4 - token_v4 = wallet._serialize_proofs_v4(sample_proofs, "http://test.mint") + token_v4 = wallet._serialize_proofs_v4(sample_proofs, "http://test.mint", "sat") # Parse back mint_url, unit, parsed_proofs = wallet._parse_cashu_token(token_v4) @@ -209,8 +241,7 @@ def test_parse_invalid_tokens(self): wallet = Wallet( nsec=generate_privkey(), mint_urls=["http://test.mint"], - currency="sat", - relays=["ws://test.relay"], + relay_urls=["ws://test.relay"], ) with pytest.raises((ValueError, Exception)): @@ -231,17 +262,16 @@ def test_send_token_invalid_version(self): wallet = Wallet( nsec=generate_privkey(), mint_urls=["http://test.mint"], - currency="sat", - relays=["ws://test.relay"], + relay_urls=["ws://test.relay"], ) # These should raise ValueError without needing async context with pytest.raises(ValueError, match="Unsupported token version"): # This will fail early in the method before any async operations - asyncio.run(wallet.send(10, token_version=2)) + asyncio.run(wallet.send(10, token_version=2)) # type: ignore with pytest.raises(ValueError, match="Unsupported token version"): - asyncio.run(wallet.send(10, token_version=5)) + asyncio.run(wallet.send(10, token_version=5)) # type: ignore class TestWalletCurrencyValidation: @@ -249,81 +279,112 @@ class TestWalletCurrencyValidation: def test_valid_currency_units(self): """Test that valid currency units are accepted.""" - valid_units = ["sat", "msat", "btc", "usd", "eur"] + from sixty_nuts.types import CurrencyUnit + + valid_units: list[CurrencyUnit] = ["sat", "msat", "btc", "usd", "eur"] for unit in valid_units: wallet = Wallet( nsec=generate_privkey(), mint_urls=["http://test.mint"], - currency=unit, # type: ignore - relays=["ws://test.relay"], + relay_urls=["ws://test.relay"], ) - assert wallet.currency == unit + wallet._validate_currency_unit(unit) def test_invalid_currency_unit(self): """Test invalid currency unit handling.""" + wallet = Wallet( + nsec=generate_privkey(), + mint_urls=["http://test.mint"], + relay_urls=["ws://test.relay"], + ) + with pytest.raises(ValueError, match="Unsupported currency unit"): - Wallet( - nsec=generate_privkey(), - mint_urls=["http://test.mint"], - currency="invalid_unit", # type: ignore - relays=["ws://test.relay"], - ) + wallet._validate_currency_unit("invalid") # type: ignore class TestWalletOptimalDenominations: """Test optimal denomination calculation logic.""" - def test_calculate_optimal_denominations_small(self): - """Test optimal denominations for small amounts.""" + @pytest.mark.asyncio + async def test_calculate_optimal_denominations_small(self): wallet = Wallet( nsec=generate_privkey(), mint_urls=["http://test.mint"], - currency="sat", - relays=["ws://test.relay"], + relay_urls=["ws://test.relay"], ) - # Test 1 sat - denoms = wallet._calculate_optimal_denominations(1) - assert denoms == {1: 1} - - # Test 3 sats - denoms = wallet._calculate_optimal_denominations(3) - assert denoms == {2: 1, 1: 1} - - # Test 7 sats - denoms = wallet._calculate_optimal_denominations(7) - assert denoms == {4: 1, 2: 1, 1: 1} - - def test_calculate_optimal_denominations_large(self): - """Test optimal denominations for larger amounts.""" + with patch( + "sixty_nuts.wallet.Mint.get_currencies", new_callable=AsyncMock + ) as mock_currencies: + mock_currencies.return_value = ["sat"] + with patch( + "sixty_nuts.wallet.Mint.get_denominations_for_currency", + new_callable=AsyncMock, + ) as mock_denoms: + mock_denoms.return_value = [1, 2, 4, 8, 16, 32, 64, 128, 256, 512, 1024] + denoms = await wallet._calculate_optimal_denominations( + 1, "http://test.mint", "sat" + ) + assert denoms == {1: 1} + + denoms = await wallet._calculate_optimal_denominations( + 3, "http://test.mint", "sat" + ) + assert denoms == {2: 1, 1: 1} + + denoms = await wallet._calculate_optimal_denominations( + 7, "http://test.mint", "sat" + ) + assert denoms == {4: 1, 2: 1, 1: 1} + + @pytest.mark.asyncio + async def test_calculate_optimal_denominations_large(self): wallet = Wallet( nsec=generate_privkey(), mint_urls=["http://test.mint"], - currency="sat", - relays=["ws://test.relay"], + relay_urls=["ws://test.relay"], ) - # Test 1000 sats - denoms = wallet._calculate_optimal_denominations(1000) - expected = {512: 1, 256: 1, 128: 1, 64: 1, 32: 1, 8: 1} - assert denoms == expected - - # Verify total adds up - total = sum(denom * count for denom, count in denoms.items()) - assert total == 1000 - - def test_calculate_optimal_denominations_zero(self): - """Test optimal denominations for zero amount.""" + with patch( + "sixty_nuts.wallet.Mint.get_currencies", new_callable=AsyncMock + ) as mock_currencies: + mock_currencies.return_value = ["sat"] + with patch( + "sixty_nuts.wallet.Mint.get_denominations_for_currency", + new_callable=AsyncMock, + ) as mock_denoms: + mock_denoms.return_value = [1, 2, 4, 8, 16, 32, 64, 128, 256, 512, 1024] + denoms = await wallet._calculate_optimal_denominations( + 1000, "http://test.mint", "sat" + ) + expected = {512: 1, 256: 1, 128: 1, 64: 1, 32: 1, 8: 1} + assert denoms == expected + + total = sum(denom * count for denom, count in denoms.items()) + assert total == 1000 + + @pytest.mark.asyncio + async def test_calculate_optimal_denominations_zero(self): wallet = Wallet( nsec=generate_privkey(), mint_urls=["http://test.mint"], - currency="sat", - relays=["ws://test.relay"], + relay_urls=["ws://test.relay"], ) - denoms = wallet._calculate_optimal_denominations(0) - assert denoms == {} + with patch( + "sixty_nuts.wallet.Mint.get_currencies", new_callable=AsyncMock + ) as mock_currencies: + mock_currencies.return_value = ["sat"] + with patch( + "sixty_nuts.wallet.Mint.get_denominations_for_currency", + new_callable=AsyncMock, + ) as mock_denoms: + mock_denoms.return_value = [1, 2, 4, 8, 16, 32, 64, 128, 256, 512, 1024] + denoms = await wallet._calculate_optimal_denominations( + 0, "http://test.mint", "sat" + ) + assert denoms == {} class TestWalletInsufficientBalanceCheck: @@ -334,8 +395,7 @@ def test_raise_if_insufficient_balance_sufficient(self): wallet = Wallet( nsec=generate_privkey(), mint_urls=["http://test.mint"], - currency="sat", - relays=["ws://test.relay"], + relay_urls=["ws://test.relay"], ) # Should not raise @@ -349,8 +409,7 @@ def test_raise_if_insufficient_balance_insufficient(self): wallet = Wallet( nsec=generate_privkey(), mint_urls=["http://test.mint"], - currency="sat", - relays=["ws://test.relay"], + relay_urls=["ws://test.relay"], ) with pytest.raises(WalletError, match="Insufficient balance"): From b385f7aeba68dd62616be55f918765f511805b36 Mon Sep 17 00:00:00 2001 From: Shroominic Date: Mon, 14 Jul 2025 15:52:51 -0300 Subject: [PATCH 04/17] multi currency support + mint and keyset fixes --- sixty_nuts/TODO.md | 29 - sixty_nuts/__init__.py | 4 +- sixty_nuts/cli.py | 1586 +++++++++++++++++-------------- sixty_nuts/denominations.py | 147 --- sixty_nuts/events.py | 38 +- sixty_nuts/mint.py | 828 +++++++++++----- sixty_nuts/relay.py | 26 +- sixty_nuts/temp.py | 15 +- sixty_nuts/types.py | 52 +- sixty_nuts/wallet.py | 1761 ++++++++++------------------------- 10 files changed, 2035 insertions(+), 2451 deletions(-) delete mode 100644 sixty_nuts/TODO.md delete mode 100644 sixty_nuts/denominations.py diff --git a/sixty_nuts/TODO.md b/sixty_nuts/TODO.md deleted file mode 100644 index d4b8cd7..0000000 --- a/sixty_nuts/TODO.md +++ /dev/null @@ -1,29 +0,0 @@ -# todo - -## minting - -nuts mint should request any non defined data -but wallet mint should always select one if not defined -mint and keyset selection -no timeout if timeout=None on wallet mint but cli nuts mint sets a timeout of 3 minutes - -## mint and keysets - -a wallet has all the mint instances open and each mint has -all the active keysets loaded with the currency information - -## send & melt - -for spending operations if nonthing specific is defined there should be an algorithm selecting -the most fitting mint and keyset and if none are fitting it should automatically -rebalance in the most optimal way then sending or melting is handleded the same way - -## redeem - -default should just check if the mint is trusted and redeem all proofs, -if non trusted mint it should swap into the trusted mint active keyset with biggest balance - -# currency exchange - -metthod that can be used to check rates of swap between keysets -method to do the swap between to keysets of proofs diff --git a/sixty_nuts/__init__.py b/sixty_nuts/__init__.py index df289b1..2b64d20 100644 --- a/sixty_nuts/__init__.py +++ b/sixty_nuts/__init__.py @@ -5,7 +5,7 @@ from .wallet import Wallet from .temp import TempWallet -from .types import ProofDict, WalletError +from .types import Proof, WalletError __all__ = [ # Main wallet classes @@ -13,6 +13,6 @@ # Temporary wallet "TempWallet", # Shared types - "ProofDict", + "Proof", "WalletError", ] diff --git a/sixty_nuts/cli.py b/sixty_nuts/cli.py index 6d03554..6b2de47 100644 --- a/sixty_nuts/cli.py +++ b/sixty_nuts/cli.py @@ -4,6 +4,7 @@ import os import shutil import time +import json # Add missing import from typing import Annotated, Optional, cast import typer @@ -23,17 +24,22 @@ from .types import WalletError from .wallet import ( Wallet, + CurrencyUnit, + Proof, +) +from .mint import ( get_mints_from_env, validate_mint_url, POPULAR_MINTS, - MINTS_ENV_VAR, set_mints_in_env, clear_mints_from_env, - CurrencyUnit, - ProofDict, ) +from .crypto import nip44_decrypt # Add missing import from .temp import redeem_to_lnurl -from .relay import prompt_user_for_relays, RELAYS_ENV_VAR +from .relay import prompt_user_for_relays, RELAYS_ENV_VAR, EventKind + +# Update the environment variable name to match what's in mint.py +MINTS_ENV_VAR = "CASHU_MINTS" app = typer.Typer( name="nuts", @@ -396,7 +402,13 @@ async def create_wallet_with_mint_selection( # Automatically save to Nostr wallet event try: async with wallet: - await wallet.initialize_wallet(force=True) + # Generate a new wallet privkey if not already set + if wallet.wallet_privkey is None: + wallet.wallet_privkey = wallet._generate_wallet_privkey() + + await wallet.event_manager.initialize_wallet( + wallet.wallet_privkey, force=True + ) console.print( f"[green]✅ Wallet configured with {len(selected_mints)} mints and saved to Nostr![/green]" ) @@ -681,64 +693,42 @@ async def prompt_user_for_mint_and_keyset( wallet, mint_unit: "CurrencyUnit", ) -> tuple[str, str]: - """Prompt user to select mint and optionally keyset when multiple options are available. + """Prompt user to select mint when multiple options are available. Args: wallet: Wallet instance mint_unit: Currency unit to mint Returns: - Tuple of (target_mint_url, target_keyset_id) + Tuple of (target_mint_url, keyset_id) - keyset_id is now just a placeholder Raises: typer.Exit: If user cancels selection or no valid options """ - # Update keysets from all mints - for mint_url in wallet.mint_urls: - await wallet._update_keysets_from_mint(mint_url) - - # Find all mints and keysets that support the requested unit - available_options: list[ - tuple[str, str, str] - ] = [] # (mint_url, keyset_id, keyset_display) - - for mint_url in wallet.mint_urls: - mint_keysets = wallet.keyset_manager.get_mint_keysets(mint_url) - for ks in mint_keysets: - if ks.unit == mint_unit and ks.active: - mint_display = mint_url[:40] + "..." if len(mint_url) > 43 else mint_url - keyset_display = f"{ks.id[:16]}... - {ks.unit.upper()}" - if ks.input_fee_ppk: - keyset_display += f" (fee: {ks.input_fee_ppk} ppk)" - available_options.append( - (mint_url, ks.id, f"{mint_display} | {keyset_display}") - ) + # Since keysets are no longer exposed in the wallet, we'll just select a mint + console.print(f"\n[cyan]🏦 Select a mint for {mint_unit.upper()}[/cyan]") + + # Get mints from wallet + available_mints = wallet.mint_urls - if not available_options: - console.print(f"[red]No mints found that support {mint_unit.upper()}[/red]") - console.print("\nAvailable currencies by mint:") - for mint_url in wallet.mint_urls: - mint_keysets = wallet.keyset_manager.get_mint_keysets(mint_url) - units = set(ks.unit for ks in mint_keysets if ks.active) - if units: - console.print(f" {mint_url}: {', '.join(u.upper() for u in units)}") + if not available_mints: + console.print("[red]No mints configured[/red]") raise typer.Exit(1) - # If only one option, use it automatically - if len(available_options) == 1: - return available_options[0][0], available_options[0][1] + # If only one mint, use it automatically + if len(available_mints) == 1: + return available_mints[0], "default" - # Multiple options - prompt user to choose - console.print(f"\n[cyan]🏦 Multiple mints support {mint_unit.upper()}[/cyan]") - console.print(f"Found {len(available_options)} minting options:") + # Multiple mints - prompt user to choose + console.print(f"Found {len(available_mints)} mints:") # Create selection table table = Table(show_header=True, header_style="bold cyan") table.add_column("#", style="dim", width=3) - table.add_column("Mint & Keyset", style="green") + table.add_column("Mint URL", style="green") - for i, (mint_url, keyset_id, display_text) in enumerate(available_options, 1): - table.add_row(str(i), display_text) + for i, mint_url in enumerate(available_mints, 1): + table.add_row(str(i), mint_url) console.print(table) @@ -746,32 +736,18 @@ async def prompt_user_for_mint_and_keyset( while True: try: choice = Prompt.ask( - f"\nSelect mint and keyset for {mint_unit.upper()} minting", default="1" + f"\nSelect mint for {mint_unit.upper()} minting", default="1" ) if choice.isdigit(): choice_num = int(choice) - if 1 <= choice_num <= len(available_options): - selected_mint, selected_keyset, _ = available_options[ - choice_num - 1 - ] - - # Show selection - keyset_info = wallet.keyset_manager.get_keyset(selected_keyset) - mint_display = ( - selected_mint[:40] + "..." - if len(selected_mint) > 43 - else selected_mint - ) - console.print(f"[green]✅ Selected: {mint_display}[/green]") - console.print( - f"[dim] Keyset: {selected_keyset[:16]}... ({keyset_info.unit.upper()})[/dim]" - ) - - return selected_mint, selected_keyset + if 1 <= choice_num <= len(available_mints): + selected_mint = available_mints[choice_num - 1] + console.print(f"[green]✅ Selected: {selected_mint}[/green]") + return selected_mint, "default" else: console.print( - f"[red]Invalid choice. Please enter 1-{len(available_options)}[/red]" + f"[red]Invalid choice. Please enter 1-{len(available_mints)}[/red]" ) else: console.print("[red]Please enter a number[/red]") @@ -792,8 +768,8 @@ async def _debug_nostr_state(wallet: Wallet) -> None: # 1. Show wallet configuration console.print("\n[yellow]📋 Wallet Configuration:[/yellow]") console.print(f" Public Key: {wallet._get_pubkey()}") - console.print(f" Configured Relays: {len(wallet.relays)}") - for i, relay in enumerate(wallet.relays): + console.print(f" Configured Relays: {len(wallet.relay_urls)}") + for i, relay in enumerate(wallet.relay_urls): console.print(f" {i + 1}. {relay}") # 2. Check relay connectivity @@ -904,15 +880,49 @@ async def _debug_nostr_state(wallet: Wallet) -> None: if content: # Try to decrypt if encrypted try: - decrypted = wallet._nip44_decrypt(content) + decrypted = nip44_decrypt(content, wallet._privkey) token_data = json.loads(decrypted) proofs = token_data.get("proofs", []) mint_url = token_data.get("mint", "unknown") - total_amount = sum( - p.get("amount", 0) for p in proofs + + # Group by unit to show proper amounts + unit_amounts: dict[str, int] = {} + for p in proofs: + unit = p.get("unit", p.get("u", "sat")) + amount = p.get("amount", 0) + unit_amounts[unit] = ( + unit_amounts.get(unit, 0) + amount + ) + + # Format unit display + unit_parts = [] + for unit, amount in sorted(unit_amounts.items()): + if unit in [ + "usd", + "eur", + "gbp", + "cad", + "chf", + "aud", + "jpy", + "cny", + "inr", + "usdt", + "usdc", + "dai", + ]: + display_amount = amount / 100 + unit_parts.append( + f"{display_amount:.2f} {unit}" + ) + else: + unit_parts.append(f"{amount} {unit}") + amount_str = ( + ", ".join(unit_parts) if unit_parts else "0" ) + console.print( - f" → {len(proofs)} proofs, {total_amount} sats from {mint_url}" + f" → {len(proofs)} proofs, {amount_str} from {mint_url}" ) except Exception: console.print( @@ -922,59 +932,8 @@ async def _debug_nostr_state(wallet: Wallet) -> None: console.print(f" → Parse error: {e}") else: console.print(" No events found on any relay") - - # 5. Check relay queue status - if wallet.relay_manager.use_queued_relays and wallet.relay_manager.relay_pool: - console.print("\n[yellow]📤 Relay Queue Status:[/yellow]") - try: - pending_proofs = wallet.relay_manager.relay_pool.get_pending_proofs() - console.print(f" Pending Proofs in Queue: {len(pending_proofs)}") - - if pending_proofs: - total_pending_sats = 0 - for token_data in pending_proofs: - proofs = token_data.get("proofs", []) - mint_url = token_data.get("mint", "unknown") - amount = sum(p.get("amount", 0) for p in proofs) - total_pending_sats += amount - console.print( - f" Mint {mint_url}: {len(proofs)} proofs, {amount} sats" - ) - - console.print(f" Total Pending Value: {total_pending_sats} sats") - console.print( - " ⚠️ These sats might be 'missing' until queue is processed!" - ) - - except Exception as e: - console.print(f" ❌ Queue status error: {e}") - - # 6. Compare with local state - console.print("\n[yellow]🔄 Local vs Relay Comparison:[/yellow]") - try: - local_state = await wallet.fetch_wallet_state(check_proofs=False) - console.print(f" Local Balance: {local_state.balance} sats") - console.print(f" Local Proofs: {len(local_state.proofs)}") - - # Show denomination breakdown - local_denoms: dict[int, int] = {} - for proof in local_state.proofs: - amount = proof.get("amount", 0) - local_denoms[amount] = local_denoms.get(amount, 0) + 1 - - if local_denoms: - console.print(" Local Denominations:") - for denom in sorted(local_denoms.keys(), reverse=True): - count = local_denoms[denom] - console.print(f" {denom} sats × {count} = {denom * count} sats") - - except Exception as e: - console.print(f" ❌ Local state error: {e}") - except Exception as e: - console.print(f"❌ Nostr debugging failed: {e}") - - console.print("\n" + "=" * 50) + console.print(f" ❌ Error fetching events: {e}") @app.command() @@ -1002,6 +961,7 @@ def balance( """Check wallet balance across all currencies and keysets.""" async def _balance() -> None: + nonlocal unit # Explicitly declare we're using the outer scope variable nsec = get_nsec() # Use create_wallet_with_mint_selection for automatic mint discovery and selection wallet = await create_wallet_with_mint_selection(nsec=nsec, mint_urls=mint_urls) @@ -1015,21 +975,43 @@ async def _balance() -> None: state = await wallet.fetch_wallet_state(check_proofs=validate) # Filter by unit if specified - if unit: - unit_cast = cast(CurrencyUnit, unit) - if unit_cast in state.balance_by_currency: - unit_balance = state.balance_by_currency[unit_cast] - console.print( - f"[green]✅ {unit.upper()} Balance: {unit_balance} {unit}[/green]" - ) + filter_unit = unit # Capture outer scope variable + if filter_unit: + unit_cast = cast(CurrencyUnit, filter_unit) + if unit_cast in state.balance_by_unit: + unit_balance = state.balance_by_unit[unit_cast] + # Convert from base units to user-friendly units for display + if unit_cast in [ + "usd", + "eur", + "gbp", + "cad", + "chf", + "aud", + "jpy", + "cny", + "inr", + "usdt", + "usdc", + "dai", + ]: + # For fiat and stablecoins, convert from cents to dollars + display_balance = unit_balance / 100 + console.print( + f"[green]✅ {unit_cast.upper()} Balance: {display_balance:.2f} {unit_cast}[/green]" + ) + else: + console.print( + f"[green]✅ {unit_cast.upper()} Balance: {unit_balance} {unit_cast}[/green]" + ) else: - console.print(f"[yellow]No balance in {unit.upper()}[/yellow]") + console.print(f"[yellow]No balance in {unit_cast.upper()}[/yellow]") return else: # Show all currency balances console.print("[green]✅ Balance by Currency:[/green]") - if not state.balance_by_currency: + if not state.balance_by_unit: console.print("[yellow]No balance found[/yellow]") return @@ -1039,7 +1021,7 @@ async def _balance() -> None: currency_table.add_column("Balance", style="green") currency_table.add_column("Approx USD", style="yellow") - for currency, balance in state.balance_by_currency.items(): + for currency, balance in state.balance_by_unit.items(): # Simple USD approximation (in production, use real exchange rates) usd_value = "" if currency == "sat": @@ -1052,114 +1034,123 @@ async def _balance() -> None: f"~${balance * 1.1 / 100:.2f}" # Assuming EUR/USD = 1.1 ) - currency_table.add_row( - currency.upper(), f"{balance} {currency}", usd_value - ) + # Convert from base units to user-friendly display + if currency in [ + "usd", + "eur", + "gbp", + "cad", + "chf", + "aud", + "jpy", + "cny", + "inr", + "usdt", + "usdc", + "dai", + ]: + # For fiat and stablecoins, show as dollars/euros/etc with 2 decimal places + display_balance = balance / 100 + currency_table.add_row( + currency.upper(), + f"{display_balance:.2f} {currency}", + usd_value, + ) + else: + currency_table.add_row( + currency.upper(), f"{balance} {currency}", usd_value + ) console.print(currency_table) if details: - # Create detailed keyset table with wider columns - keyset_table = Table(title="Keyset Details") - keyset_table.add_column("Mint", style="blue", min_width=35) - keyset_table.add_column("Keyset ID", style="cyan", min_width=20) - keyset_table.add_column("Unit", style="green", min_width=6) - keyset_table.add_column("Balance", style="yellow", min_width=9) - keyset_table.add_column("Proofs", style="magenta", min_width=8) - keyset_table.add_column("Status", style="white", min_width=11) - - # Group proofs by keyset - proofs_by_keyset: dict[str, list[ProofDict]] = {} + # Create mint balance table + mint_table = Table(title="Mint Details") + mint_table.add_column("Mint", style="blue") + mint_table.add_column("Balance", style="green") + mint_table.add_column("Proofs", style="magenta") + + # Group proofs by mint and currency + mint_currency_balances: dict[str, dict[str, tuple[int, int]]] = {} for proof in state.proofs: - keyset_id = proof.get("id", "unknown") - if keyset_id not in proofs_by_keyset: - proofs_by_keyset[keyset_id] = [] - proofs_by_keyset[keyset_id].append(proof) - - # Ensure we have keyset info for all keysets with balances - for keyset_id in state.balance_by_keyset.keys(): - if keyset_id not in state.keysets: - # Try to find the mint URL from proofs and fetch keyset info - proofs_for_keyset = proofs_by_keyset.get(keyset_id, []) - if proofs_for_keyset: - mint_url = proofs_for_keyset[0].get("mint", "") - if mint_url: - try: - await wallet._update_keysets_from_mint(mint_url) - except Exception as e: - console.print( - f"[dim]Warning: Could not fetch keyset info for {mint_url}: {e}[/dim]" - ) + mint_url = proof["mint"] + unit = cast(CurrencyUnit, proof["unit"]) + amount = proof["amount"] - # Refresh state keysets after any updates - state_keysets = wallet.keyset_manager.keysets.copy() + if mint_url not in mint_currency_balances: + mint_currency_balances[mint_url] = {} + if unit not in mint_currency_balances[mint_url]: + mint_currency_balances[mint_url][unit] = (0, 0) - for keyset_id, balance in state.balance_by_keyset.items(): - keyset_info = state_keysets.get(keyset_id) - proof_count = len(proofs_by_keyset.get(keyset_id, [])) + balance, count = mint_currency_balances[mint_url][unit] + mint_currency_balances[mint_url][unit] = ( + balance + amount, + count + 1, + ) - if keyset_info: - # Truncate mint URL more intelligently - mint_url = keyset_info.mint_url - if len(mint_url) > 35: - # Show beginning and end with ellipsis - mint_display = f"{mint_url[:16]}...{mint_url[-16:]}" - else: - mint_display = mint_url - - status = "✅ Active" if keyset_info.active else "❌ Inactive" - unit_display = keyset_info.unit.upper() - balance_display = f"{balance} {keyset_info.unit}" - - # Show more of keyset ID - keyset_display = f"{keyset_id[:16]}..." - - keyset_table.add_row( - keyset_display, - mint_display, - unit_display, - balance_display, - str(proof_count), - status, - ) - else: - # Handle missing keyset info - try to get mint from proofs - proofs_for_keyset = proofs_by_keyset.get(keyset_id, []) - mint_url = "unknown" - if proofs_for_keyset: - mint_url = proofs_for_keyset[0].get("mint", "unknown") - if len(mint_url) > 35: - mint_url = f"{mint_url[:16]}...{mint_url[-16:]}" - - keyset_table.add_row( - f"{keyset_id[:16]}...", - mint_url, - "unknown", - f"{balance} ???", - str(proof_count), - "❓ Unknown", - ) + # Display mint details + for mint_url, currency_balances in mint_currency_balances.items(): + # Truncate mint URL for display + mint_display = ( + mint_url[:40] + "..." if len(mint_url) > 43 else mint_url + ) + + # Format balance string with all currencies + balance_parts = [] + total_proofs = 0 + for currency_unit, (balance, count) in currency_balances.items(): + balance_parts.append(f"{balance} {currency_unit}") + total_proofs += count + + balance_str = ", ".join(balance_parts) + + mint_table.add_row( + mint_display, + balance_str, + str(total_proofs), + ) console.print("\n") - console.print(keyset_table) + console.print(mint_table) - # Show denomination breakdown per keyset if requested + # Show denomination breakdown per mint if requested if typer.confirm("\nShow denomination breakdown?", default=False): - for keyset_id, proofs in proofs_by_keyset.items(): - if proofs: - keyset_info = state.keysets.get(keyset_id) - console.print( - f"\n[cyan]Keyset {keyset_id[:16]}... ({keyset_info.unit if keyset_info else 'unknown'}):[/cyan]" - ) + for mint_url, mint_proofs in state.proofs_by_mint.items(): + if mint_proofs: + console.print(f"\n[cyan]Mint {mint_url[:40]}...:[/cyan]") + + # Group by currency first + currency_groups: dict[str, list[Proof]] = {} + for proof in mint_proofs: + proof_unit = cast(CurrencyUnit, proof["unit"]) + if proof_unit not in currency_groups: + currency_groups[proof_unit] = [] + currency_groups[proof_unit].append(proof) + + # Show denominations for each currency + for currency_unit, unit_proofs in currency_groups.items(): + if len(currency_groups) > 1: + console.print( + f" [yellow]{currency_unit.upper()}:[/yellow]" + ) - denominations: dict[int, int] = {} - for proof in proofs: - amount = proof["amount"] - denominations[amount] = denominations.get(amount, 0) + 1 + denominations: dict[int, int] = {} + for proof in unit_proofs: + amount = proof["amount"] + denominations[amount] = ( + denominations.get(amount, 0) + 1 + ) - for denom in sorted(denominations.keys()): - count = denominations[denom] - console.print(f" {denom} × {count} = {denom * count}") + for denom in sorted(denominations.keys()): + count = denominations[denom] + if len(currency_groups) > 1: + console.print( + f" {denom} × {count} = {denom * count}" + ) + else: + console.print( + f" {denom} × {count} = {denom * count}" + ) try: asyncio.run(_balance()) @@ -1212,20 +1203,18 @@ async def _send() -> None: # Get wallet state to check balances state = await wallet.fetch_wallet_state(check_proofs=False) - # Determine which unit to use - send_unit: CurrencyUnit = ( - cast(CurrencyUnit, unit) if unit else wallet.currency - ) + # Determine which unit to use (default to "sat" if not specified) + send_unit: CurrencyUnit = cast(CurrencyUnit, unit) if unit else "sat" # Check if we have balance in that unit - if send_unit not in state.balance_by_currency: + if send_unit not in state.balance_by_unit: console.print(f"[red]No balance in {send_unit.upper()}[/red]") console.print("\nAvailable currencies:") - for curr, bal in state.balance_by_currency.items(): + for curr, bal in state.balance_by_unit.items(): console.print(f" {curr.upper()}: {bal} {curr}") return - unit_balance = state.balance_by_currency[send_unit] + unit_balance = state.balance_by_unit[send_unit] if to_lnurl: # Send directly to Lightning address @@ -1234,26 +1223,28 @@ async def _send() -> None: ) # Check balance first (need extra for fees) - if unit_balance <= amount: + send_amount_base = wallet._convert_to_base_unit(amount, send_unit) + if unit_balance <= send_amount_base: + # Convert balance to display units for error message + display_balance = wallet._convert_from_base_unit( + unit_balance, send_unit + ) console.print( - f"[red]Insufficient balance! Need >{amount}, have {unit_balance} {send_unit}[/red]" + f"[red]Insufficient balance! Need >{amount}, have {display_balance:.2f} {send_unit}[/red]" ) console.print( "[dim]Lightning payments require fees (typically 1%)[/dim]" ) return - # If keyset specified, use it - keyset_id = None + # Note: keyset-specific sending is no longer supported in the refactored wallet if keyset: - if keyset not in state.keysets: - console.print(f"[red]Keyset {keyset} not found[/red]") - return - keyset_id = keyset - console.print(f"[dim]Using keyset: {keyset_id[:16]}...[/dim]") + console.print( + "[yellow]Warning: Keyset-specific sending is no longer supported[/yellow]" + ) actual_paid = await wallet.send_to_lnurl( - to_lnurl, amount, unit=send_unit, keyset_id=keyset_id + to_lnurl, send_amount_base, unit=send_unit ) console.print("[green]✅ Successfully sent![/green]") @@ -1261,7 +1252,7 @@ async def _send() -> None: # Show remaining balance new_state = await wallet.fetch_wallet_state(check_proofs=False) - new_balance = new_state.balance_by_currency.get(send_unit, 0) + new_balance = new_state.balance_by_unit.get(send_unit, 0) console.print(f"Remaining balance: {new_balance} {send_unit}") else: @@ -1270,27 +1261,32 @@ async def _send() -> None: f"[blue]Creating token for {amount} {send_unit}...[/blue]" ) - # Check balance - if unit_balance < amount: + # Check balance (convert amount to base units for comparison) + send_amount_base = wallet._convert_to_base_unit(amount, send_unit) + if unit_balance < send_amount_base: + # Convert balance to display units for error message + display_balance = wallet._convert_from_base_unit( + unit_balance, send_unit + ) console.print( - f"[red]Insufficient balance! Need {amount}, have {unit_balance} {send_unit}[/red]" + f"[red]Insufficient balance! Need {amount}, have {display_balance:.2f} {send_unit}[/red]" ) return - # If keyset specified, verify it matches the unit + # Note: keyset-specific sending is no longer supported in the refactored wallet if keyset: - keyset_info = state.keysets.get(keyset) - if not keyset_info: - console.print(f"[red]Keyset {keyset} not found[/red]") - return - if keyset_info.unit != send_unit: - console.print( - f"[red]Keyset {keyset[:16]}... uses {keyset_info.unit}, but you specified {send_unit}[/red]" - ) - return - console.print(f"[dim]Using keyset: {keyset[:16]}...[/dim]") + console.print( + "[yellow]Warning: Keyset-specific sending is no longer supported[/yellow]" + ) + + # Convert amount to base units if needed + send_amount = wallet._convert_to_base_unit(amount, send_unit) - token = await wallet.send(amount, unit=send_unit, keyset_id=keyset) + # Use the first mint URL if provided via command line + target_mint_url = mint_urls[0] if mint_urls else None + token = await wallet.send( + send_amount, mint_url=target_mint_url, unit=send_unit + ) console.print( f"\n[green]✅ Cashu Token Created ({amount} {send_unit}):[/green]" @@ -1306,7 +1302,7 @@ async def _send() -> None: # Show remaining balance new_state = await wallet.fetch_wallet_state(check_proofs=False) - new_balance = new_state.balance_by_currency.get(send_unit, 0) + new_balance = new_state.balance_by_unit.get(send_unit, 0) console.print( f"[dim]Remaining balance: {new_balance} {send_unit}[/dim]" ) @@ -1465,71 +1461,112 @@ async def _mint() -> None: # Use create_wallet_with_mint_selection for automatic mint discovery and selection wallet = await create_wallet_with_mint_selection(nsec=nsec, mint_urls=mint_urls) - # Determine which unit to mint - mint_unit: CurrencyUnit = cast(CurrencyUnit, unit) if unit else wallet.currency + # Determine which unit to mint (default to "sat" if not specified) + mint_unit: CurrencyUnit = cast(CurrencyUnit, unit) if unit else "sat" async with wallet: - # If keyset specified, verify it exists and get its info - target_mint_url = None + # If keyset specified, show warning that this is no longer supported if keyset: - # Update keysets from all mints - for mint_url in wallet.mint_urls: - await wallet._update_keysets_from_mint(mint_url) - - keyset_info = wallet.keyset_manager.get_keyset(keyset) - if not keyset_info: - console.print(f"[red]Keyset {keyset} not found[/red]") - console.print("\nAvailable keysets:") - - # Show available keysets - for mint_url in wallet.mint_urls: - mint_keysets = wallet.keyset_manager.get_mint_keysets(mint_url) - if mint_keysets: - console.print(f"\n{mint_url}:") - for ks in mint_keysets: - console.print(f" {ks.id[:16]}... - {ks.unit.upper()}") - return + console.print( + "[yellow]Warning: Keyset-specific minting is no longer supported in the refactored wallet[/yellow]" + ) + console.print( + "[dim]The wallet will automatically select the best mint for your request[/dim]" + ) + + console.print(f"[blue]Checking which mints support {mint_unit}...[/blue]") + + # Find mints that support the requested currency unit + supporting_mints = await wallet.get_mints_supporting_unit(mint_unit) - # Verify unit matches - if keyset_info.unit != mint_unit: + if not supporting_mints: + console.print( + f"[red]❌ No configured mints support {mint_unit.upper()}![/red]" + ) + console.print("\nConfigured mints:") + for mint in wallet.mint_urls: + console.print(f" • {mint}") + console.print( + f"\n[yellow]💡 Try adding a mint that supports {mint_unit.upper()}[/yellow]" + ) + return + + # Determine which mint to use + target_mint_url = None + + # If --mint was specified, check if it supports the unit + if mint_urls: + specified_mint = mint_urls[0] + if specified_mint in supporting_mints: + target_mint_url = specified_mint + else: console.print( - f"[red]Keyset {keyset[:16]}... uses {keyset_info.unit}, but you specified {mint_unit}[/red]" + f"[red]❌ {specified_mint} does not support {mint_unit.upper()}![/red]" ) + console.print(f"\nMints that support {mint_unit.upper()}:") + for mint in supporting_mints: + console.print(f" • {mint}") return - - target_mint_url = keyset_info.mint_url - target_keyset_id = keyset + elif len(supporting_mints) == 1: + # Only one mint supports this unit, use it automatically + target_mint_url = supporting_mints[0] console.print( - f"[dim]Using specified keyset: {keyset[:16]}... ({keyset_info.unit.upper()})[/dim]" + f"[dim]Using mint: {target_mint_url} (only mint supporting {mint_unit.upper()})[/dim]" ) else: - # Prompt user to select mint and keyset when multiple options are available - ( - target_mint_url, - target_keyset_id, - ) = await prompt_user_for_mint_and_keyset(wallet, mint_unit) + # Multiple mints support this unit, prompt user to select console.print( - f"[dim]Using selected mint and keyset: {target_keyset_id[:16]}... ({mint_unit.upper()})[/dim]" + f"\n[cyan]Multiple mints support {mint_unit.upper()}:[/cyan]" ) - console.print(f"[blue]Creating invoice for {amount} {mint_unit}...[/blue]") + table = Table(show_header=True, header_style="bold cyan") + table.add_column("#", style="dim", width=3) + table.add_column("Mint URL", style="green") - # Override wallet currency temporarily for this mint operation - original_currency = wallet.currency - wallet.currency = mint_unit + for i, mint_url in enumerate(supporting_mints, 1): + table.add_row(str(i), mint_url) + + console.print(table) + + while True: + choice = Prompt.ask( + f"\nSelect mint for {mint_unit.upper()} minting", default="1" + ) + + if choice.isdigit(): + choice_num = int(choice) + if 1 <= choice_num <= len(supporting_mints): + target_mint_url = supporting_mints[choice_num - 1] + console.print( + f"[green]✅ Selected: {target_mint_url}[/green]" + ) + break + else: + console.print( + f"[red]Invalid choice. Please enter 1-{len(supporting_mints)}[/red]" + ) + else: + console.print("[red]Please enter a number[/red]") + + console.print( + f"\n[blue]Creating invoice for {amount} {mint_unit}...[/blue]" + ) try: - # Pass the selected mint URL directly instead of reordering + # Create mint quote with the specified currency invoice, task = await wallet.mint_async( - amount, timeout=timeout, mint_url=target_mint_url + amount, + mint_url=target_mint_url, + currency=mint_unit, + timeout=timeout, ) # Display invoice for easy copying console.print( f"\n[yellow]⚡ Lightning Invoice ({amount} {mint_unit}):[/yellow]" ) - # Display invoice with normal wrapping so it can be copied completely - console.print(invoice) + # Display invoice without line wrapping so it can be copied completely + console.print(invoice, soft_wrap=True, no_wrap=True) # Display QR code unless disabled if not no_qr: @@ -1552,8 +1589,8 @@ async def _mint() -> None: # Show updated balance state = await wallet.fetch_wallet_state(check_proofs=False) - if mint_unit in state.balance_by_currency: - new_balance = state.balance_by_currency[mint_unit] + if mint_unit in state.balance_by_unit: + new_balance = state.balance_by_unit[mint_unit] console.print( f"New {mint_unit.upper()} balance: {new_balance} {mint_unit}" ) @@ -1572,9 +1609,9 @@ async def _mint() -> None: console.print( "[yellow]Invoice may still be valid - check with your Lightning wallet[/yellow]" ) - finally: - # Restore original currency - wallet.currency = original_currency + except Exception as e: + console.print(f"[red]❌ Failed to create invoice: {e}[/red]") + raise try: asyncio.run(_mint()) @@ -1589,7 +1626,7 @@ def info( Optional[list[str]], typer.Option("--mint", "-m", help="Mint URLs") ] = None, ) -> None: - """Show comprehensive wallet information including keysets and currencies.""" + """Show comprehensive wallet information including currencies.""" async def _info(): nsec = get_nsec() @@ -1601,10 +1638,6 @@ async def _info(): # Get wallet state state = await wallet.fetch_wallet_state(check_proofs=False) - # Update keysets from all mints - for mint_url in wallet.mint_urls: - await wallet._update_keysets_from_mint(mint_url) - # Create info table table = Table(title="Wallet Information") table.add_column("Property", style="cyan") @@ -1612,87 +1645,52 @@ async def _info(): # Basic info table.add_row("Public Key", wallet._get_pubkey()) - table.add_row("Default Currency", wallet.currency.upper()) - table.add_row("Total Balance (SAT)", f"{state.total_balance_sat} sats") table.add_row("Total Proofs", str(len(state.proofs))) # Currency breakdown - if state.balance_by_currency: + if state.balance_by_unit: table.add_row("", "") # Empty row for spacing table.add_row("[bold]Currency Balances[/bold]", "") - for currency, balance in sorted(state.balance_by_currency.items()): - table.add_row(f" {currency.upper()}", f"{balance} {currency}") + for currency, balance in sorted(state.balance_by_unit.items()): + # Format based on currency type + if currency in [ + "usd", + "eur", + "gbp", + "cad", + "chf", + "aud", + "jpy", + "cny", + "inr", + "usdt", + "usdc", + "dai", + ]: + display_balance = balance / 100 + table.add_row( + f" {currency.upper()}", f"{display_balance:.2f} {currency}" + ) + else: + table.add_row(f" {currency.upper()}", f"{balance} {currency}") - # Mint info with keyset details + # Mint info table.add_row("", "") # Empty row for spacing - table.add_row("[bold]Mints & Keysets[/bold]", "") + table.add_row("[bold]Mints[/bold]", "") table.add_row("Configured Mints", str(len(wallet.mint_urls))) for i, mint_url in enumerate(wallet.mint_urls): table.add_row(f" Mint {i + 1}", mint_url) - # Get keysets for this mint - mint_keysets = wallet.keyset_manager.get_mint_keysets(mint_url) - if mint_keysets: - # Group by currency - currencies = set(ks.unit for ks in mint_keysets if ks.active) - if currencies: - table.add_row( - " Currencies", - ", ".join(c.upper() for c in sorted(currencies)), - ) - - # Show keyset count - active_count = sum(1 for ks in mint_keysets if ks.active) - total_count = len(mint_keysets) - table.add_row( - " Keysets", f"{active_count} active / {total_count} total" - ) - # Relay info table.add_row("", "") # Empty row for spacing table.add_row("[bold]Relays[/bold]", "") - table.add_row("Configured Relays", str(len(wallet.relays))) - for i, relay_url in enumerate(wallet.relays): + table.add_row("Configured Relays", str(len(wallet.relay_urls))) + for i, relay_url in enumerate(wallet.relay_urls): table.add_row(f" Relay {i + 1}", relay_url) console.print(table) - # Show keyset details if requested - if typer.confirm("\nShow detailed keyset information?", default=False): - keyset_table = Table(title="Keyset Details") - keyset_table.add_column("Keyset ID", style="cyan") - keyset_table.add_column("Mint", style="blue") - keyset_table.add_column("Unit", style="green") - keyset_table.add_column("Status", style="yellow") - keyset_table.add_column("Input Fee", style="magenta") - - all_keysets = [] - for mint_url in wallet.mint_urls: - all_keysets.extend(wallet.keyset_manager.get_mint_keysets(mint_url)) - - for ks in sorted(all_keysets, key=lambda k: (k.mint_url, k.unit, k.id)): - mint_display = ( - ks.mint_url[:30] + "..." - if len(ks.mint_url) > 33 - else ks.mint_url - ) - status = "✅ Active" if ks.active else "❌ Inactive" - fee_display = ( - f"{ks.input_fee_ppk} ppk" if ks.input_fee_ppk else "None" - ) - - keyset_table.add_row( - ks.id[:16] + "...", - mint_display, - ks.unit.upper(), - status, - fee_display, - ) - - console.print("\n") - console.print(keyset_table) - try: asyncio.run(_info()) except Exception as e: @@ -1888,12 +1886,12 @@ async def _relays(): ) return - from .relay import NostrRelay + from .relay import Relay for i, relay_url in enumerate(test_relays, 1): console.print(f" {i}. Testing {relay_url}...") try: - relay = NostrRelay(relay_url) + relay = Relay(relay_url) await relay.connect() console.print(" [green]✅ Connected successfully[/green]") await relay.disconnect() @@ -1965,7 +1963,10 @@ async def _status(): ) async with wallet_obj: # Check if wallet exists - exists, existing_event = await wallet_obj.check_wallet_event_exists() + ( + exists, + existing_event, + ) = await wallet_obj.event_manager.check_wallet_event_exists() # Handle initialization if requested if init: @@ -1982,12 +1983,22 @@ async def _status(): console.print(" Use --force to create a new wallet event") else: # Initialize wallet (create event) + # Generate a new wallet privkey if not already set + if wallet_obj.wallet_privkey is None: + wallet_obj.wallet_privkey = ( + wallet_obj._generate_wallet_privkey() + ) + if force: console.print("🔄 Force creating new wallet event...") - created = await wallet_obj.initialize_wallet(force=True) + created = await wallet_obj.event_manager.initialize_wallet( + wallet_obj.wallet_privkey, force=True + ) else: console.print("🔄 Creating wallet event...") - created = await wallet_obj.initialize_wallet(force=False) + created = await wallet_obj.event_manager.initialize_wallet( + wallet_obj.wallet_privkey, force=False + ) if created: console.print( @@ -1997,7 +2008,7 @@ async def _status(): ( exists, existing_event, - ) = await wallet_obj.check_wallet_event_exists() + ) = await wallet_obj.event_manager.check_wallet_event_exists() else: console.print("[yellow]ℹ️ Wallet already existed[/yellow]") else: @@ -2014,7 +2025,9 @@ async def _status(): # Try to decrypt wallet content to show configuration try: - content = wallet_obj._nip44_decrypt(existing_event["content"]) + content = nip44_decrypt( + existing_event["content"], wallet_obj._privkey + ) import json wallet_data = json.loads(content) @@ -2033,8 +2046,8 @@ async def _status(): f" P2PK Key: {'✅ Configured' if has_privkey else '❌ Not set'}" ) - console.print(f" Relays: {len(wallet_obj.relays)}") - for i, relay in enumerate(wallet_obj.relays): + console.print(f" Relays: {len(wallet_obj.relay_urls)}") + for i, relay in enumerate(wallet_obj.relay_urls): console.print(f" {i + 1}. {relay}") except Exception as e: @@ -2195,25 +2208,28 @@ async def _erase(): wallet_exists = False history_count = 0 token_count = 0 - current_balance = 0 if clean_wallet: - exists, _ = await wallet_obj.check_wallet_event_exists() + ( + exists, + _, + ) = await wallet_obj.event_manager.check_wallet_event_exists() wallet_exists = exists if clean_history: - history_entries = await wallet_obj.fetch_spending_history() + history_entries = ( + await wallet_obj.event_manager.fetch_spending_history() + ) history_count = len(history_entries) if clean_tokens: - token_count = await wallet_obj.count_token_events() + token_count = await wallet_obj.event_manager.count_token_events() # Get current balance to show user what they're about to lose try: - current_balance = await wallet_obj.get_balance( - check_proofs=False - ) + state = await wallet_obj.fetch_wallet_state(check_proofs=False) + current_balance_by_unit = state.balance_by_unit except Exception: - current_balance = 0 + current_balance_by_unit = {} # Show what will be deleted if not wallet_exists and history_count == 0 and token_count == 0: @@ -2234,8 +2250,31 @@ async def _erase(): total_events += history_count if clean_tokens and token_count > 0: + # Format balance summary + balance_parts = [] + for currency, balance in sorted(current_balance_by_unit.items()): + if currency in [ + "usd", + "eur", + "gbp", + "cad", + "chf", + "aud", + "jpy", + "cny", + "inr", + "usdt", + "usdc", + "dai", + ]: + display_balance = balance / 100 + balance_parts.append(f"{display_balance:.2f} {currency}") + else: + balance_parts.append(f"{balance} {currency}") + balance_str = ", ".join(balance_parts) if balance_parts else "0" + erase_summary.append( - f"💰 {token_count} token storage events [red](containing {current_balance} sats!)[/red]" + f"💰 {token_count} token storage events [red](containing {balance_str}!)[/red]" ) total_events += token_count @@ -2256,7 +2295,7 @@ async def _erase(): if clean_tokens: console.print( - f"\n[red]💀 DANGER: You will lose {current_balance} sats stored on Nostr![/red]" + f"\n[red]💀 DANGER: You will lose {balance_str} stored on Nostr![/red]" ) console.print( "[red] This deletes your actual token storage, not just metadata![/red]" @@ -2274,8 +2313,10 @@ async def _erase(): # Extra confirmation for dangerous operations if clean_tokens: - confirm_msg = f"\n[red]Type 'DELETE {current_balance} SATS' to confirm token deletion[/red]" - expected_response = f"DELETE {current_balance} SATS" + # For confirmation, use a simplified balance string + confirm_balance_str = balance_str.upper().replace(",", " AND") + confirm_msg = f"\n[red]Type 'DELETE {confirm_balance_str}' to confirm token deletion[/red]" + expected_response = f"DELETE {confirm_balance_str}" user_response = Prompt.ask(confirm_msg) if user_response != expected_response: @@ -2297,24 +2338,28 @@ async def _erase(): if clean_wallet and wallet_exists: console.print("🗑️ Deleting wallet configuration events...") - wallet_deleted = await wallet_obj.delete_all_wallet_events() + wallet_deleted = ( + await wallet_obj.event_manager.delete_all_wallet_events() + ) total_deleted += wallet_deleted console.print(f" ✅ Deleted {wallet_deleted} wallet event(s)") if clean_history and history_count > 0: console.print("🗑️ Deleting transaction history events...") - history_deleted = await wallet_obj.clear_spending_history() + history_deleted = ( + await wallet_obj.event_manager.clear_spending_history() + ) total_deleted += history_deleted console.print(f" ✅ Deleted {history_deleted} history event(s)") if clean_tokens and token_count > 0: - console.print( - f"🗑️ Deleting token storage events ({current_balance} sats)..." + console.print(f"🗑️ Deleting token storage events ({balance_str})...") + tokens_deleted = ( + await wallet_obj.event_manager.clear_all_token_events() ) - tokens_deleted = await wallet_obj.clear_all_token_events() total_deleted += tokens_deleted console.print( - f" 💀 Deleted {tokens_deleted} token event(s) containing {current_balance} sats" + f" 💀 Deleted {tokens_deleted} token event(s) containing {balance_str}" ) if clean_nsec and nsec_existed: @@ -2334,7 +2379,9 @@ async def _erase(): ) if clean_tokens: - console.print("\n[red]⚠️ Your Nostr balance is now 0 sats[/red]") + console.print( + "\n[red]⚠️ Your Nostr balance is now 0 in all currencies[/red]" + ) console.print( " Any tokens you had are no longer accessible from Nostr relays" ) @@ -2350,7 +2397,7 @@ async def _erase(): except Exception as e: handle_wallet_error(e) - asyncio.run(_erase()) + asyncio.run(_erase()) @app.command() @@ -2387,11 +2434,32 @@ async def _cleanup(): ) async with wallet: # Get current state for confirmation - current_balance = await wallet.get_balance(check_proofs=False) - token_count = await wallet.count_token_events() + state = await wallet.fetch_wallet_state(check_proofs=False) + token_count = await wallet.event_manager.count_token_events() console.print("[cyan]🧹 Wallet Cleanup Tool[/cyan]") - console.print(f"Current balance: {current_balance} sats") + console.print("Current balance:") + for currency, balance in sorted(state.balance_by_unit.items()): + if currency in [ + "usd", + "eur", + "gbp", + "cad", + "chf", + "aud", + "jpy", + "cny", + "inr", + "usdt", + "usdc", + "dai", + ]: + display_balance = balance / 100 + console.print( + f" {currency.upper()}: {display_balance:.2f} {currency}" + ) + else: + console.print(f" {currency.upper()}: {balance} {currency}") console.print(f"Current token events: {token_count}") if not dry_run and not confirm: @@ -2414,7 +2482,163 @@ async def _cleanup(): return # Perform cleanup - stats = await wallet.cleanup_wallet_state(dry_run=dry_run) + print("🧹 Starting wallet state cleanup...") + + # Get current state + state = await wallet.fetch_wallet_state( + check_proofs=True, check_local_backups=False + ) + + # Fetch all events to analyze + all_events = await wallet.relay_manager.fetch_wallet_events( + wallet.pubkey + ) + token_events = [e for e in all_events if e["kind"] == EventKind.Token] + + # Categorize events + valid_events = [] + undecryptable_events = [] + empty_events = [] + + for event in token_events: + try: + decrypted = nip44_decrypt(event["content"], wallet._privkey) + token_data = json.loads(decrypted) + proofs = token_data.get("proofs", []) + + if proofs: + valid_events.append(event["id"]) + else: + empty_events.append(event["id"]) + + except Exception: + undecryptable_events.append(event["id"]) + + stats = { + "total_events": len(token_events), + "valid_events": len(valid_events), + "undecryptable_events": len(undecryptable_events), + "empty_events": len(empty_events), + "valid_proofs": len(state.proofs), + "balance_by_unit": state.balance_by_unit, + "events_consolidated": 0, + "events_marked_superseded": 0, + } + + # Format balance string + balance_parts = [] + for currency, balance in sorted(state.balance_by_unit.items()): + if currency in [ + "usd", + "eur", + "gbp", + "cad", + "chf", + "aud", + "jpy", + "cny", + "inr", + "usdt", + "usdc", + "dai", + ]: + display_balance = balance / 100 + balance_parts.append(f"{display_balance:.2f} {currency}") + else: + balance_parts.append(f"{balance} {currency}") + total_balance_str = ", ".join(balance_parts) if balance_parts else "0" + + print(f"📊 Analysis: {stats['total_events']} total events") + print(f" ✅ Valid: {stats['valid_events']}") + print(f" ❌ Undecryptable: {stats['undecryptable_events']}") + print(f" 📭 Empty: {stats['empty_events']}") + print( + f" 💰 Valid proofs: {stats['valid_proofs']} ({total_balance_str})" + ) + + if dry_run: + print("🔍 Dry run - no changes will be made") + return stats + + # Only consolidate if we have significant cleanup opportunity + cleanup_threshold = max( + 5, len(token_events) // 3 + ) # At least 5 events or 1/3 of total + events_to_cleanup = undecryptable_events + empty_events + + if len(events_to_cleanup) < cleanup_threshold: + print( + f"🎯 No significant cleanup needed (threshold: {cleanup_threshold})" + ) + return stats + + if not state.proofs: + print("⚠️ No valid proofs found - skipping consolidation") + return stats + + print( + f"🔄 Consolidating {len(state.proofs)} proofs into fresh events..." + ) + + # Create fresh consolidated events + new_event_ids = [] + # Group proofs by mint + proofs_by_mint: dict[str, list] = {} + for proof in state.proofs: + mint_url = proof.get("mint", "unknown") + if mint_url not in proofs_by_mint: + proofs_by_mint[mint_url] = [] + proofs_by_mint[mint_url].append(proof) + + for mint_url, mint_proofs in proofs_by_mint.items(): + try: + new_id = await wallet.event_manager.publish_token_event( + mint_proofs, + deleted_token_ids=events_to_cleanup, # Mark all old events as superseded + ) + new_event_ids.append(new_id) + stats["events_consolidated"] += 1 + print( + f" ✅ Created consolidated event for {mint_url}: {len(mint_proofs)} proofs" + ) + except Exception as e: + print(f" ❌ Failed to consolidate {mint_url}: {e}") + + if new_event_ids: + stats["events_marked_superseded"] = len(events_to_cleanup) + + # Try to delete old events (best effort) + deleted_count = 0 + for event_id in events_to_cleanup: + try: + await wallet.event_manager.delete_token_event(event_id) + deleted_count += 1 + except Exception: + # Deletion not supported - that's okay, 'del' field handles it + pass + + if deleted_count > 0: + print(f" 🗑️ Successfully deleted {deleted_count} old events") + else: + print(" 📝 Old events marked as superseded via 'del' field") + + # Create consolidation history + try: + # For consolidation, we use "sat" as default unit since it's a net-zero operation + await wallet.event_manager.publish_spending_history( + direction="in", # Consolidation is like receiving all proofs again + amount=0, # No net change in balance + unit="sat", # Default unit for consolidation + created_token_ids=new_event_ids, + destroyed_token_ids=events_to_cleanup, + ) + print(" 📋 Created consolidation history") + except Exception as e: + print(f" ⚠️ Could not create history: {e}") + + print( + f"🎉 Cleanup complete! Consolidated {stats['events_consolidated']} events" + ) # Show results console.print("\n[green]📋 Cleanup Results:[/green]") @@ -2425,7 +2649,30 @@ async def _cleanup(): ) console.print(f" Empty events: {stats['empty_events']}") console.print(f" Valid proofs: {stats['valid_proofs']}") - console.print(f" Balance: {stats['balance']} sats") + + # Show balance by currency + console.print(" Balance by currency:") + for currency, balance in sorted(stats["balance_by_unit"].items()): + if currency in [ + "usd", + "eur", + "gbp", + "cad", + "chf", + "aud", + "jpy", + "cny", + "inr", + "usdt", + "usdc", + "dai", + ]: + display_balance = balance / 100 + console.print( + f" {currency.upper()}: {display_balance:.2f} {currency}" + ) + else: + console.print(f" {currency.upper()}: {balance} {currency}") if not dry_run: console.print( @@ -2606,10 +2853,31 @@ async def _backup(): console.print("\n[green]✅ Recovery successful![/green]") # Check new balance - balance = await wallet.get_balance() - console.print( - f"\n💰 Current balance: [green]{balance} sats[/green]" - ) + state = await wallet.fetch_wallet_state(check_proofs=False) + console.print("\n💰 Current balance:") + for currency, balance in sorted(state.balance_by_unit.items()): + if currency in [ + "usd", + "eur", + "gbp", + "cad", + "chf", + "aud", + "jpy", + "cny", + "inr", + "usdt", + "usdc", + "dai", + ]: + display_balance = balance / 100 + console.print( + f" {currency.upper()}: [green]{display_balance:.2f} {currency}[/green]" + ) + else: + console.print( + f" {currency.upper()}: [green]{balance} {currency}[/green]" + ) else: console.print("\n[red]❌ No proofs were recovered[/red]") @@ -2728,8 +2996,8 @@ async def _history(): async with wallet: console.print("🔄 Fetching spending history...") - # Fetch history - history_entries = await wallet.fetch_spending_history() + # Fetch history from event manager + history_entries = await wallet.event_manager.fetch_spending_history() if not history_entries: console.print("[yellow]ℹ️ No spending history found[/yellow]") @@ -2767,7 +3035,8 @@ async def _history(): direction_display = f"{direction_emoji} {direction}" amount = entry.get("amount", "0") - amount_display = f"{amount} sats" + unit = entry.get("unit", "sat") + amount_display = f"{amount} {unit}" event_id = entry.get("event_id", "unknown") event_short = ( @@ -2858,16 +3127,30 @@ async def _debug_wallet_config(wallet_obj: Wallet) -> None: """Debug wallet configuration and keys.""" console.print("\n[yellow]🗂️ Wallet Configuration[/yellow]") console.print(f" Nostr Public Key: {wallet_obj._get_pubkey()}") - console.print( - f" Wallet Private Key: {wallet_obj.wallet_privkey[:8]}...{wallet_obj.wallet_privkey[-8:]}" - ) - console.print(f" Currency: {wallet_obj.currency}") + if wallet_obj.wallet_privkey: + console.print( + f" Wallet Private Key: {wallet_obj.wallet_privkey[:8]}...{wallet_obj.wallet_privkey[-8:]}" + ) + else: + console.print( + " Wallet Private Key: [yellow]Not loaded (no wallet event)[/yellow]" + ) console.print(f" Configured Mints: {len(wallet_obj.mint_urls)}") for i, mint_url in enumerate(wallet_obj.mint_urls): console.print(f" {i + 1}. {mint_url}") # Check wallet events - exists, current_event = await wallet_obj.check_wallet_event_exists() + # Note: check_wallet_event_exists is no longer available in refactored wallet + # We'll fetch events directly instead + pubkey = wallet_obj._get_pubkey() + all_events = await wallet_obj.relay_manager.fetch_wallet_events(pubkey) + wallet_events = [e for e in all_events if e["kind"] == EventKind.Wallet] + + exists = len(wallet_events) > 0 + current_event = ( + max(wallet_events, key=lambda e: e["created_at"]) if wallet_events else None + ) + if exists and current_event: from datetime import datetime @@ -2911,8 +3194,8 @@ async def _debug_wallet_config(wallet_obj: Wallet) -> None: async def _debug_nostr_relays(wallet_obj: Wallet) -> None: """Debug Nostr relay connectivity and events.""" console.print("\n[yellow]🌐 Nostr Relay Status[/yellow]") - console.print(f" Configured Relays: {len(wallet_obj.relays)}") - for i, relay in enumerate(wallet_obj.relays): + console.print(f" Configured Relays: {len(wallet_obj.relay_urls)}") + for i, relay in enumerate(wallet_obj.relay_urls): console.print(f" {i + 1}. {relay}") # Check relay connectivity @@ -2980,52 +3263,164 @@ async def _debug_balance_proofs(wallet_obj): try: # Get balance without validation first (faster) state_unvalidated = await wallet_obj.fetch_wallet_state(check_proofs=False) - console.print( - f" Raw Balance (unvalidated): {state_unvalidated.balance} sats" - ) console.print(f" Raw Proof Count: {len(state_unvalidated.proofs)}") + # Show raw balance by currency + console.print(" Raw Balance by Currency (unvalidated):") + for currency, balance in sorted(state_unvalidated.balance_by_unit.items()): + if currency in [ + "usd", + "eur", + "gbp", + "cad", + "chf", + "aud", + "jpy", + "cny", + "inr", + "usdt", + "usdc", + "dai", + ]: + display_balance = balance / 100 + console.print( + f" {currency.upper()}: {display_balance:.2f} {currency}" + ) + else: + console.print(f" {currency.upper()}: {balance} {currency}") + # Get balance with validation (slower but accurate) - console.print(" Validating proofs with mints...") + console.print("\n Validating proofs with mints...") state_validated = await wallet_obj.fetch_wallet_state(check_proofs=True) - console.print(f" Validated Balance: {state_validated.balance} sats") console.print(f" Valid Proof Count: {len(state_validated.proofs)}") + # Show validated balance by currency + console.print(" Validated Balance by Currency:") + for currency, balance in sorted(state_validated.balance_by_unit.items()): + if currency in [ + "usd", + "eur", + "gbp", + "cad", + "chf", + "aud", + "jpy", + "cny", + "inr", + "usdt", + "usdc", + "dai", + ]: + display_balance = balance / 100 + console.print( + f" {currency.upper()}: {display_balance:.2f} {currency}" + ) + else: + console.print(f" {currency.upper()}: {balance} {currency}") + # Show difference if any - balance_diff = state_unvalidated.balance - state_validated.balance proof_diff = len(state_unvalidated.proofs) - len(state_validated.proofs) - if balance_diff > 0 or proof_diff > 0: - console.print(" [red]⚠️ Found spent/invalid proofs:[/red]") - console.print(f" Lost Balance: {balance_diff} sats") + if proof_diff > 0: + console.print("\n [red]⚠️ Found spent/invalid proofs:[/red]") console.print(f" Invalid Proofs: {proof_diff}") + + # Calculate balance differences by currency + console.print(" Lost Balance by Currency:") + all_currencies = set(state_unvalidated.balance_by_unit.keys()) + for currency in sorted(all_currencies): + unval_balance = state_unvalidated.balance_by_unit.get(currency, 0) + val_balance = state_validated.balance_by_unit.get(currency, 0) + diff = unval_balance - val_balance + + if diff > 0: + if currency in [ + "usd", + "eur", + "gbp", + "cad", + "chf", + "aud", + "jpy", + "cny", + "inr", + "usdt", + "usdc", + "dai", + ]: + display_diff = diff / 100 + console.print( + f" {currency.upper()}: {display_diff:.2f} {currency}" + ) + else: + console.print( + f" {currency.upper()}: {diff} {currency}" + ) else: - console.print(" [green]✅ All proofs valid[/green]") + console.print("\n [green]✅ All proofs valid[/green]") - # Show proof breakdown by mint + # Show proof breakdown by mint and currency if state_validated.proofs: - console.print("\n Proof Breakdown by Mint:") - proofs_by_mint = {} + console.print("\n Proof Breakdown by Mint and Currency:") + + # Group proofs by mint and currency + proofs_by_mint_currency: dict[str, dict[str, list]] = {} for proof in state_validated.proofs: mint_url = proof.get("mint", "unknown") - if mint_url not in proofs_by_mint: - proofs_by_mint[mint_url] = [] - proofs_by_mint[mint_url].append(proof) + currency = proof.get("unit", "sat") - for mint_url, mint_proofs in proofs_by_mint.items(): - mint_balance = sum(p["amount"] for p in mint_proofs) - denominations = {} - for proof in mint_proofs: - amount = proof["amount"] - denominations[amount] = denominations.get(amount, 0) + 1 + if mint_url not in proofs_by_mint_currency: + proofs_by_mint_currency[mint_url] = {} + if currency not in proofs_by_mint_currency[mint_url]: + proofs_by_mint_currency[mint_url][currency] = [] + proofs_by_mint_currency[mint_url][currency].append(proof) - denom_str = ", ".join( - f"{amount}×{count}" - for amount, count in sorted(denominations.items()) - ) - console.print( - f" {mint_url}: {mint_balance} sats ({len(mint_proofs)} proofs: {denom_str})" + for mint_url, currency_proofs in proofs_by_mint_currency.items(): + # Show mint URL (truncated if too long) + mint_display = ( + mint_url[:50] + "..." if len(mint_url) > 53 else mint_url ) + console.print(f" {mint_display}:") + + for currency, proofs in sorted(currency_proofs.items()): + balance = sum(p["amount"] for p in proofs) + + # Group by denomination + denominations = {} + for proof in proofs: + amount = proof["amount"] + denominations[amount] = denominations.get(amount, 0) + 1 + + denom_str = ", ".join( + f"{amount}×{count}" + for amount, count in sorted(denominations.items()) + ) + + # Format display based on currency type + if currency in [ + "usd", + "eur", + "gbp", + "cad", + "chf", + "aud", + "jpy", + "cny", + "inr", + "usdt", + "usdc", + "dai", + ]: + # For fiat/stablecoins, show as dollars/euros with 2 decimal places + display_balance = balance / 100 + console.print( + f" {currency.upper()}: {display_balance:.2f} {currency} ({len(proofs)} proofs: {denom_str})" + ) + else: + # For crypto currencies, show as is + console.print( + f" {currency.upper()}: {balance} {currency} ({len(proofs)} proofs: {denom_str})" + ) except Exception as e: console.print(f" ❌ Balance validation error: {e}") @@ -3057,14 +3452,33 @@ async def _debug_proof_state(wallet_obj): for i, proof in enumerate(sample_proofs): proof_id = f"{proof['secret']}:{proof['C']}" mint_url = proof.get("mint", "unknown") + currency = proof.get("unit", "sat") # Check cache status is_cached, cached_state = wallet_obj._is_proof_state_cached(proof_id) cache_status = f"cached ({cached_state})" if is_cached else "not cached" - console.print( - f" {i + 1}. {proof['amount']} sats from {mint_url[:30]}..." - ) + # Format amount display based on currency + if currency in [ + "usd", + "eur", + "gbp", + "cad", + "chf", + "aud", + "jpy", + "cny", + "inr", + "usdt", + "usdc", + "dai", + ]: + display_amount = proof["amount"] / 100 + amount_str = f"{display_amount:.2f} {currency}" + else: + amount_str = f"{proof['amount']} {currency}" + + console.print(f" {i + 1}. {amount_str} from {mint_url[:30]}...") console.print(f" ID: {proof['id'][:16]}...") console.print(f" Secret: {proof['secret'][:16]}...") console.print(f" Cache: {cache_status}") @@ -3102,7 +3516,7 @@ async def _debug_history_decryption(wallet_obj: Wallet) -> None: for event in wallet_events: try: - decrypted = wallet_obj._nip44_decrypt(event["content"]) + decrypted = nip44_decrypt(event["content"], wallet_obj._privkey) wallet_data = json.loads(decrypted) for item in wallet_data: @@ -3113,9 +3527,12 @@ async def _debug_history_decryption(wallet_obj: Wallet) -> None: continue console.print(f" Unique Private Keys Found: {len(wallet_keys)}") - console.print( - f" Current Key: {wallet_obj.wallet_privkey[:8]}...{wallet_obj.wallet_privkey[-8:]}" - ) + if wallet_obj.wallet_privkey: + console.print( + f" Current Key: {wallet_obj.wallet_privkey[:8]}...{wallet_obj.wallet_privkey[-8:]}" + ) + else: + console.print(" Current Key: [yellow]Not loaded[/yellow]") # Test history decryption history_events = [e for e in all_events if e["kind"] == 7376] @@ -3129,7 +3546,7 @@ async def _debug_history_decryption(wallet_obj: Wallet) -> None: success_count = 0 for i, event in enumerate(history_events[:sample_size]): try: - decrypted = wallet_obj._nip44_decrypt(event["content"]) + decrypted = nip44_decrypt(event["content"], wallet_obj._privkey) history_data = json.loads(decrypted) direction = next( ( @@ -3143,8 +3560,12 @@ async def _debug_history_decryption(wallet_obj: Wallet) -> None: (item[1] for item in history_data if item[0] == "amount"), "unknown", ) + unit = next( + (item[1] for item in history_data if item[0] == "unit"), + "sat", + ) console.print( - f" {i + 1}. ✅ Success: {direction} {amount} sats" + f" {i + 1}. ✅ Success: {direction} {amount} {unit}" ) success_count += 1 except Exception as e: @@ -3207,246 +3628,13 @@ def swap( """ async def _swap(): - nsec = get_nsec() - wallet = await create_wallet_with_mint_selection(nsec=nsec, mint_urls=mint_urls) - - # Cast units to proper type - from_currency = cast(CurrencyUnit, from_unit.lower()) - to_currency = cast(CurrencyUnit, to_unit.lower()) - - async with wallet: - console.print( - f"[blue]Preparing to swap {amount} {from_unit.upper()} → {to_unit.upper()}...[/blue]" - ) - - # Get current state - state = await wallet.fetch_wallet_state(check_proofs=False) - - # Check source balance - from_balance = state.balance_by_currency.get(from_currency, 0) - if from_balance < amount: - console.print( - f"[red]Insufficient {from_unit.upper()} balance! " - f"Need {amount}, have {from_balance}[/red]" - ) - return - - # Update keysets from all mints - for mint_url in wallet.mint_urls: - await wallet._update_keysets_from_mint(mint_url) - - # Find suitable keysets for the swap - from_keysets = wallet.keyset_manager.get_currency_keysets(from_currency) - to_keysets = wallet.keyset_manager.get_currency_keysets(to_currency) - - if not from_keysets: - console.print(f"[red]No keysets found for {from_unit.upper()}[/red]") - return - - if not to_keysets: - console.print(f"[red]No keysets found for {to_unit.upper()}[/red]") - return - - # Find optimal swap path - swap_path = None - same_mint_possible = False - - # Check if same mint supports both currencies - for from_ks in from_keysets: - for to_ks in to_keysets: - if from_ks.mint_url == to_ks.mint_url: - same_mint_possible = True - swap_path = { - "type": "same_mint", - "mint": from_ks.mint_url, - "from_keyset": from_ks.id, - "to_keyset": to_ks.id, - } - break - if swap_path: - break - - # If same mint not possible but required, error out - if same_mint and not same_mint_possible: - console.print( - f"[red]No single mint supports both {from_unit.upper()} and {to_unit.upper()}[/red]" - ) - console.print("Remove --same-mint flag to allow cross-mint swaps") - return - - # If no same-mint path found, use cross-mint swap - if not swap_path: - # Find mints with balance for source currency - from_mints = set(ks.mint_url for ks in from_keysets) - to_mints = set(ks.mint_url for ks in to_keysets) - - # For now, pick first available mints - from_mint = None - for m in from_mints: - has_balance = False - for p in state.proofs: - if p.get("mint") == m: - keyset = wallet.keyset_manager.get_keyset(p.get("id", "")) - if keyset and keyset.unit == from_currency: - has_balance = True - break - if has_balance: - from_mint = m - break - - if not from_mint: - console.print( - f"[red]No mint with {from_unit.upper()} balance found[/red]" - ) - return - - to_mint = next(iter(to_mints)) - - swap_path = { - "type": "cross_mint", - "from_mint": from_mint, - "to_mint": to_mint, - "from_keyset": next( - ks.id for ks in from_keysets if ks.mint_url == from_mint - ), - "to_keyset": next( - ks.id for ks in to_keysets if ks.mint_url == to_mint - ), - } - - # Show swap details - console.print("\n[cyan]Swap Details:[/cyan]") - console.print(f" Amount: {amount} {from_unit.upper()} → {to_unit.upper()}") - - if swap_path["type"] == "same_mint": - console.print(f" Type: Same-mint swap") - console.print(f" Mint: {swap_path['mint']}") - else: - console.print(f" Type: Cross-mint swap (via Lightning)") - console.print(f" From: {swap_path['from_mint']}") - console.print(f" To: {swap_path['to_mint']}") - console.print( - f" [yellow]Note: Cross-mint swaps incur Lightning fees[/yellow]" - ) - - # Confirm unless auto-confirmed - if not confirm: - if not typer.confirm("\nProceed with swap?"): - console.print("[yellow]Swap cancelled[/yellow]") - return - - # Execute the swap - console.print("\n[blue]Executing swap...[/blue]") - - try: - if swap_path["type"] == "same_mint": - # Same-mint swap: just reorganize denominations - # Select proofs from source keyset - source_proofs = [ - p - for p in state.proofs - if p.get("id") == swap_path["from_keyset"] - and p.get("mint") == swap_path["mint"] - ] - - # Filter to get exactly the amount we need - selected_proofs = [] - selected_amount = 0 - for proof in sorted( - source_proofs, key=lambda p: p["amount"], reverse=True - ): - if selected_amount >= amount: - break - selected_proofs.append(proof) - selected_amount += proof["amount"] - - if selected_amount < amount: - console.print( - f"[red]Could not select exactly {amount} {from_unit.upper()}[/red]" - ) - return - - # TODO: Implement keyset swap at the mint level - # For now, we'll use the existing swap mechanism - console.print( - "[yellow]Same-mint currency swap not yet implemented[/yellow]" - ) - console.print( - "[dim]This feature requires mint support for cross-keyset swaps[/dim]" - ) - - else: - # Cross-mint swap: melt from one mint, mint at another - console.print( - f"[dim]Step 1/3: Creating invoice at target mint...[/dim]" - ) - - # Create invoice at target mint for the amount - # Temporarily switch currency for the mint operation - original_currency = wallet.currency - wallet.currency = to_currency - - try: - target_mint = swap_path["to_mint"] - invoice, mint_task = await wallet.mint_async(amount, timeout=60) - - console.print( - f"[dim]Step 2/3: Paying invoice from source mint...[/dim]" - ) - - # Pay the invoice using funds from source mint - # The melt operation will automatically select proofs from the right mint/currency - wallet.currency = from_currency - await wallet.melt(invoice, target_mint=swap_path["from_mint"]) - - console.print( - f"[dim]Step 3/3: Receiving tokens at target mint...[/dim]" - ) - - # Wait for the mint to complete - success = await mint_task - - if success: - console.print( - f"[green]✅ Successfully swapped {amount} {from_unit.upper()} → {to_unit.upper()}![/green]" - ) - - # Show new balances - new_state = await wallet.fetch_wallet_state( - check_proofs=False - ) - - console.print("\n[cyan]Updated Balances:[/cyan]") - old_from = from_balance - new_from = new_state.balance_by_currency.get( - from_currency, 0 - ) - old_to = state.balance_by_currency.get(to_currency, 0) - new_to = new_state.balance_by_currency.get(to_currency, 0) - - console.print( - f" {from_unit.upper()}: {old_from} → {new_from} (-{old_from - new_from})" - ) - console.print( - f" {to_unit.upper()}: {old_to} → {new_to} (+{new_to - old_to})" - ) - - if (old_from - new_from) > amount: - fee = (old_from - new_from) - amount - console.print( - f" [yellow]Lightning fees: {fee} {from_unit}[/yellow]" - ) - else: - console.print( - "[red]❌ Swap failed - mint did not receive payment[/red]" - ) - - finally: - # Restore original currency - wallet.currency = original_currency - - except Exception as e: - console.print(f"[red]❌ Swap failed: {e}[/red]") + console.print( + "[yellow]⚠️ The swap command is temporarily unavailable after the keyset refactor.[/yellow]" + ) + console.print( + "[dim]Currency swaps will be re-implemented in a future update.[/dim]" + ) + return try: asyncio.run(_swap()) diff --git a/sixty_nuts/denominations.py b/sixty_nuts/denominations.py deleted file mode 100644 index e636fb8..0000000 --- a/sixty_nuts/denominations.py +++ /dev/null @@ -1,147 +0,0 @@ -"""Dynamic denomination system for Cashu mints.""" - -from __future__ import annotations - - -from .types import KeysetInfo - - -class DenominationSystem: - """Manages dynamic denomination calculation based on keyset information.""" - - @staticmethod - def get_keyset_denominations(keyset_info: KeysetInfo) -> list[int]: - """Extract denominations from keyset keys. - - Args: - keyset_info: Keyset information containing keys - - Returns: - Sorted list of denominations (ascending order) - """ - denominations = [] - - # Keys are stored as dict with amount strings as keys - if isinstance(keyset_info.keys, dict): - for amount_str in keyset_info.keys: - try: - amount = int(amount_str) - denominations.append(amount) - except (ValueError, TypeError): - continue - - return sorted(denominations) - - @staticmethod - def calculate_optimal_split( - amount: int, available_denominations: list[int] - ) -> dict[int, int]: - """Calculate optimal denomination breakdown for an amount. - - Uses a greedy algorithm to minimize the number of tokens while - preferring the available denominations from the keyset. - - Args: - amount: Total amount to split - available_denominations: List of available denominations (sorted) - - Returns: - Dict of denomination -> count - """ - if not available_denominations: - # Fallback to default powers of 2 - return DenominationSystem._default_split(amount) - - denominations = {} - remaining = amount - - # Sort in descending order for greedy algorithm - for denom in sorted(available_denominations, reverse=True): - if remaining >= denom: - count = remaining // denom - denominations[denom] = count - remaining -= denom * count - - # If we couldn't split perfectly, add smallest denomination - if remaining > 0 and available_denominations: - smallest = min(available_denominations) - if smallest in denominations: - denominations[smallest] += 1 - else: - denominations[smallest] = 1 - - return denominations - - @staticmethod - def _default_split(amount: int) -> dict[int, int]: - """Default split using powers of 2.""" - denominations = {} - remaining = amount - - # Standard powers of 2 - for denom in [ - 16384, - 8192, - 4096, - 2048, - 1024, - 512, - 256, - 128, - 64, - 32, - 16, - 8, - 4, - 2, - 1, - ]: - if remaining >= denom: - count = remaining // denom - denominations[denom] = count - remaining -= denom * count - - return denominations - - @staticmethod - def validate_denominations( - keyset_info: KeysetInfo, requested_denominations: dict[int, int] - ) -> tuple[bool, str | None]: - """Validate if requested denominations are available in keyset. - - Args: - keyset_info: Keyset information to validate against - requested_denominations: Dict of denomination -> count - - Returns: - Tuple of (is_valid, error_message) - """ - available_denoms = DenominationSystem.get_keyset_denominations(keyset_info) - available_set = set(available_denoms) - - for denom in requested_denominations: - if denom not in available_set: - return False, f"Denomination {denom} not available in keyset" - - return True, None - - @staticmethod - def merge_denominations(denominations_list: list[dict[int, int]]) -> dict[int, int]: - """Merge multiple denomination dicts into one. - - Args: - denominations_list: List of denomination dicts to merge - - Returns: - Merged denomination dict - """ - merged: dict[int, int] = {} - - for denoms in denominations_list: - for denom, count in denoms.items(): - if denom in merged: - merged[denom] += count - else: - merged[denom] = count - - return merged diff --git a/sixty_nuts/events.py b/sixty_nuts/events.py index cc83d00..7466554 100644 --- a/sixty_nuts/events.py +++ b/sixty_nuts/events.py @@ -9,18 +9,9 @@ from coincurve import PrivateKey -from .types import ProofDict, WalletError -from .relay import ( - NostrEvent, - RelayManager, - EventKind, - create_event, -) -from .crypto import ( - get_pubkey, - nip44_encrypt, - nip44_decrypt, -) +from .types import Proof, WalletError +from .relay import NostrEvent, RelayClient, EventKind, create_event +from .crypto import get_pubkey, nip44_encrypt, nip44_decrypt class EventManager: @@ -28,7 +19,7 @@ class EventManager: def __init__( self, - relay_manager: RelayManager, + relay_manager: RelayClient, privkey: PrivateKey, mint_urls: list[str], ) -> None: @@ -38,7 +29,7 @@ def __init__( # ───────────────────────── NIP-60 Conversion Helpers ───────────────────────── - def _convert_proof_to_nip60(self, proof: ProofDict) -> ProofDict: + def _convert_proof_to_nip60(self, proof: Proof) -> Proof: """Convert a proof from internal format (hex secret) to NIP-60 format (base64 secret). Args: @@ -63,7 +54,7 @@ def _convert_proof_to_nip60(self, proof: ProofDict) -> ProofDict: return nip60_proof - def _convert_proof_from_nip60(self, proof: ProofDict) -> ProofDict: + def _convert_proof_from_nip60(self, proof: Proof) -> Proof: """Convert a proof from NIP-60 format (base64 secret) to internal format (hex secret). Args: @@ -154,7 +145,9 @@ async def create_wallet_event( content_data = [ ["privkey", wallet_privkey], ] - for mint_url in self.mint_urls: + # Deduplicate mint URLs before storing + unique_mint_urls = list(dict.fromkeys(self.mint_urls)) # Preserves order + for mint_url in unique_mint_urls: content_data.append(["mint", mint_url]) # Encrypt content @@ -163,7 +156,7 @@ async def create_wallet_event( # NIP-60 requires at least one mint tag in the tags array (unencrypted) # This is critical for wallet discovery! - tags = [["mint", url] for url in self.mint_urls] + tags = [["mint", url] for url in unique_mint_urls] # Create replaceable wallet event event = create_event( @@ -287,7 +280,7 @@ async def fetch_spending_history(self) -> list[dict]: key = item[0] value = item[1] - if key in ["direction", "amount"]: + if key in ["direction", "amount", "unit"]: history_entry[key] = value elif key == "e": # Event references ref_type = item[3] if len(item) > 3 else "unknown" @@ -332,6 +325,7 @@ async def publish_spending_history( *, direction: Literal["in", "out"], amount: int, + unit: str = "sat", created_token_ids: list[str] | None = None, destroyed_token_ids: list[str] | None = None, redeemed_event_id: str | None = None, @@ -341,6 +335,7 @@ async def publish_spending_history( content_data = [ ["direction", direction], ["amount", str(amount)], + ["unit", unit], ] # Add e-tags for created tokens (encrypted) @@ -369,7 +364,6 @@ async def publish_spending_history( tags=tags, ) - # TODO: make this async in background return await self.relay_manager.publish_to_relays(event) async def clear_spending_history(self) -> int: @@ -422,7 +416,7 @@ async def count_token_events(self) -> int: async def _split_large_token_events( self, - proofs: list[ProofDict], + proofs: list[Proof], mint_url: str, deleted_token_ids: list[str] | None = None, ) -> list[str]: @@ -436,7 +430,7 @@ async def _split_large_token_events( # Maximum event size (leaving buffer for encryption overhead) max_size = 60000 # 60KB limit with buffer event_ids: list[str] = [] - current_batch: list[ProofDict] = [] + current_batch: list[Proof] = [] for proof in proofs: # Test adding this proof to current batch @@ -528,7 +522,7 @@ async def _split_large_token_events( async def publish_token_event( self, - proofs: list[ProofDict], + proofs: list[Proof], *, deleted_token_ids: list[str] | None = None, ) -> str: diff --git a/sixty_nuts/mint.py b/sixty_nuts/mint.py index b73c769..91837da 100644 --- a/sixty_nuts/mint.py +++ b/sixty_nuts/mint.py @@ -1,7 +1,9 @@ -"""Cashu Mint API client wrapper.""" +""" +Cashu Mint API client wrapper.""" from __future__ import annotations +import os from typing import TypedDict, cast, Any import httpx @@ -15,193 +17,6 @@ ) -# ────────────────────────────────────────────────────────────────────────────── -# Type definitions based on NUT-01 and OpenAPI spec -# ────────────────────────────────────────────────────────────────────────────── - - -class ProofOptional(TypedDict, total=False): - """Optional fields for Proof (NUT-00 specification).""" - - Y: str # Optional for P2PK (hex string) - witness: str # Optional witness data - dleq: dict[str, Any] # Optional DLEQ proof (NUT-12) - - -# Full Proof type combining required and optional fields -class ProofComplete(Proof, ProofOptional): - """Complete Proof type with both required and optional fields.""" - - pass - - -class MintInfo(TypedDict, total=False): - """Mint information response.""" - - name: str - pubkey: str - version: str - description: str - description_long: str - contact: list[dict[str, str]] - icon_url: str - motd: str - nuts: dict[str, dict[str, Any]] - - -# NUT-01 compliant keyset definitions -class Keyset(TypedDict): - """Individual keyset per NUT-01 specification.""" - - id: str # keyset identifier - unit: CurrencyUnit # currency unit - keys: dict[str, str] # amount -> compressed secp256k1 pubkey mapping - - -class KeysResponse(TypedDict): - """NUT-01 compliant mint keys response from GET /v1/keys.""" - - keysets: list[Keyset] - - -class KeysetInfoRequired(TypedDict): - """Required fields for keyset information.""" - - id: str - unit: CurrencyUnit - active: bool - - -class KeysetInfoOptional(TypedDict, total=False): - """Optional fields for keyset information.""" - - input_fee_ppk: int # input fee in parts per thousand - - -class KeysetInfo(KeysetInfoRequired, KeysetInfoOptional): - """Extended keyset information for /v1/keysets endpoint.""" - - pass - - -class KeysetsResponse(TypedDict): - """Active keysets response from GET /v1/keysets.""" - - keysets: list[KeysetInfo] - - -class PostMintQuoteRequest(TypedDict, total=False): - """Request body for mint quote.""" - - unit: CurrencyUnit - amount: int - description: str - pubkey: str # for P2PK - - -class PostMintQuoteResponse(TypedDict): - """Mint quote response.""" - - # Required fields - quote: str # quote id - request: str # bolt11 invoice - amount: int - unit: CurrencyUnit - state: str # "UNPAID", "PAID", "ISSUED" - - # Optional fields - use TypedDict with total=False for these if needed - expiry: int - pubkey: str - paid: bool - - -class PostMintRequest(TypedDict, total=False): - """Request body for minting tokens.""" - - quote: str - outputs: list[BlindedMessage] - signature: str # optional for P2PK - - -class PostMintResponse(TypedDict): - """Mint response with signatures.""" - - signatures: list[BlindedSignature] - - -class PostMeltQuoteRequest(TypedDict, total=False): - """Request body for melt quote.""" - - unit: CurrencyUnit - request: str # bolt11 invoice - options: dict[str, Any] - - -class PostMeltQuoteResponse(TypedDict): - """Melt quote response.""" - - # Required fields - quote: str - amount: int - fee_reserve: int - unit: CurrencyUnit - - # Optional fields - request: str - paid: bool - state: str - expiry: int - payment_preimage: str - change: list[BlindedSignature] - - -class PostMeltRequest(TypedDict, total=False): - """Request body for melting tokens.""" - - quote: str - inputs: list[ProofComplete] - outputs: list[BlindedMessage] # for change - - -class PostSwapRequest(TypedDict): - """Request body for swapping proofs.""" - - inputs: list[ProofComplete] - outputs: list[BlindedMessage] - - -class PostSwapResponse(TypedDict): - """Swap response.""" - - signatures: list[BlindedSignature] - - -class PostCheckStateRequest(TypedDict): - """Request body for checking proof states.""" - - Ys: list[str] # Y values from proofs - - -class PostCheckStateResponse(TypedDict): - """Check state response.""" - - states: list[dict[str, str]] # Y -> state mapping - - -class PostRestoreRequest(TypedDict): - """Request body for restoring proofs.""" - - outputs: list[BlindedMessage] - - -class PostRestoreResponse(TypedDict, total=False): - """Restore response.""" - - outputs: list[BlindedMessage] - signatures: list[BlindedSignature] - promises: list[BlindedSignature] # deprecated - - # ────────────────────────────────────────────────────────────────────────────── # Mint API client # ────────────────────────────────────────────────────────────────────────────── @@ -212,22 +27,16 @@ class InvalidKeysetError(MintError): class Mint: - """Async HTTP client wrapper for Cashu mint API with NUT-01 compliance.""" - - def __init__(self, url: str, *, client: httpx.AsyncClient | None = None) -> None: - """Initialize mint client. - - Args: - url: Base URL of the mint (e.g. "https://testnut.cashu.space") - client: Optional httpx client to reuse connections - """ - self.url = url.rstrip("/") - self.client = client or httpx.AsyncClient() - self._owns_client = client is None + def __init__(self, url: str) -> None: + self.url = url + self.client = httpx.AsyncClient() + self._active_keysets: list[Keyset] = [] + self._currencies: list[CurrencyUnit] = [] + # self.exchange_rates: dict[CurrencyUnit, dict[CurrencyUnit, float]] = {} # TODO implement async def aclose(self) -> None: """Close the HTTP client if we created it.""" - if self._owns_client: + if self.client: await self.client.aclose() async def _request( @@ -326,13 +135,169 @@ def _validate_keys_response(self, response: dict[str, Any]) -> KeysResponse: return cast(KeysResponse, response) + def validate_keysets_response(self, response: dict) -> bool: + if "keysets" not in response: + return False + + keysets = response["keysets"] + if not isinstance(keysets, list): + return False + + # Validate each keyset + for keyset in keysets: + if not isinstance(keyset, dict): + return False + if not self.validate_keyset(keyset): + return False + + return True + + # ───────────────────────── Denomination Management ───────────────────────────────── + + async def get_denominations_for_currency(self, unit: CurrencyUnit) -> list[int]: + """Extract denominations for a given currency unit from active keyset. + + Args: + unit: Currency unit to get denominations for + + Returns: + Sorted list of denominations (ascending order) + """ + keysets = await self.get_active_keysets() + matching_keysets = [ks for ks in keysets if ks["unit"] == unit] + + if not matching_keysets: + raise MintError(f"No keyset found for unit {unit}") + + keyset = matching_keysets[0] + denominations = [] + + if isinstance(keyset["keys"], dict): + for amount_str in keyset["keys"]: + try: + amount = int(amount_str) + denominations.append(amount) + except (ValueError, TypeError): + continue + + return sorted(denominations) + + @staticmethod + def calculate_optimal_split( + amount: int, available_denominations: list[int] + ) -> dict[int, int]: + """Calculate optimal denomination breakdown for an amount. + + Uses a greedy algorithm to minimize the number of tokens while + preferring the available denominations from the keyset. + + Args: + amount: Total amount to split + available_denominations: List of available denominations (sorted) + + Returns: + Dict of denomination -> count + """ + if not available_denominations: + return Mint._default_split(amount) + + denominations: dict[int, int] = {} + remaining = amount + + for denom in sorted(available_denominations, reverse=True): + if remaining >= denom: + count = remaining // denom + denominations[denom] = count + remaining -= denom * count + + if remaining > 0 and available_denominations: + smallest = min(available_denominations) + if smallest in denominations: + denominations[smallest] += 1 + else: + denominations[smallest] = 1 + + return denominations + + @staticmethod + def _default_split(amount: int) -> dict[int, int]: + """Default split using powers of 2.""" + denominations: dict[int, int] = {} + remaining = amount + + for denom in [ + 16384, + 8192, + 4096, + 2048, + 1024, + 512, + 256, + 128, + 64, + 32, + 16, + 8, + 4, + 2, + 1, + ]: + if remaining >= denom: + count = remaining // denom + denominations[denom] = count + remaining -= denom * count + + return denominations + + async def validate_denominations_for_currency( + self, unit: CurrencyUnit, requested_denominations: dict[int, int] + ) -> tuple[bool, str | None]: + """Validate if requested denominations are available for the currency. + + Args: + unit: Currency unit to validate for + requested_denominations: Dict of denomination -> count + + Returns: + Tuple of (is_valid, error_message) + """ + available_denoms = await self.get_denominations_for_currency(unit) + available_set = set(available_denoms) + + for denom in requested_denominations: + if denom not in available_set: + return False, f"Denomination {denom} not available for unit {unit}" + + return True, None + + @staticmethod + def merge_denominations(denominations_list: list[dict[int, int]]) -> dict[int, int]: + """Merge multiple denomination dicts into one. + + Args: + denominations_list: List of denomination dicts to merge + + Returns: + Merged denomination dict + """ + merged: dict[int, int] = {} + + for denoms in denominations_list: + for denom, count in denoms.items(): + if denom in merged: + merged[denom] += count + else: + merged[denom] = count + + return merged + # ───────────────────────── Info & Keys ───────────────────────────────── async def get_info(self) -> MintInfo: """Get mint information.""" return cast(MintInfo, await self._request("GET", "/v1/info")) - async def get_keys(self, keyset_id: str | None = None) -> KeysResponse: + async def get_active_keysets(self) -> list[Keyset]: """Get mint public keys for a keyset (or newest if not specified). Implements NUT-01 specification for mint public key exchange. @@ -343,25 +308,48 @@ async def get_keys(self, keyset_id: str | None = None) -> KeysResponse: Returns: NUT-01 compliant KeysResponse with validated structure """ - path = f"/v1/keys/{keyset_id}" if keyset_id else "/v1/keys" - response = await self._request("GET", path) - return self._validate_keys_response(response) - - async def get_keysets(self) -> KeysetsResponse: + if self._active_keysets: + return self._active_keysets + response = await self._request("GET", "/v1/keys") + keysets = self._validate_keys_response(response)["keysets"] + self._active_keysets = [Keyset(**keyset) for keyset in keysets] + self._currencies = [keyset["unit"] for keyset in self._active_keysets] + return self._active_keysets + + async def get_keyset(self, id: str) -> Keyset: + """Get keyset details.""" + response = await self._request("GET", f"/v1/keys/{id}") + keyset = self._validate_keys_response(response)["keysets"][0] + return Keyset(**keyset) + + async def get_keysets_info(self) -> list[KeysetInfo]: """Get all active keyset IDs.""" - return cast(KeysetsResponse, await self._request("GET", "/v1/keysets")) + response = await self._request("GET", "/v1/keysets") + return cast(list[KeysetInfo], response["keysets"]) + + async def get_currencies(self) -> list[CurrencyUnit]: + return self._currencies or [ + keyset["unit"] for keyset in (await self.get_active_keysets()) + ] # ───────────────────────── Minting (receive) ───────────────────────────────── async def create_mint_quote( self, *, - unit: CurrencyUnit, amount: int, + unit: CurrencyUnit | None = None, description: str | None = None, pubkey: str | None = None, ) -> PostMintQuoteResponse: """Request a Lightning invoice to mint tokens.""" + if unit is None: + currencies = await self.get_currencies() + if "sat" in currencies: + unit = "sat" + else: + unit = currencies[0] + body: dict[str, Any] = { "unit": unit, "amount": amount, @@ -406,12 +394,19 @@ async def mint( async def create_melt_quote( self, - *, - unit: CurrencyUnit, request: str, + *, + unit: CurrencyUnit | None = None, options: dict[str, Any] | None = None, ) -> PostMeltQuoteResponse: """Get a quote for paying a Lightning invoice.""" + if unit is None: + currencies = await self.get_currencies() + if "sat" in currencies: + unit = "sat" + else: + unit = currencies[0] + body: dict[str, Any] = { "unit": unit, "request": request, @@ -491,7 +486,6 @@ async def check_quote_status_and_mint( amount: int | None = None, *, minted_quotes: set[str], - mint_url: str, ) -> tuple[dict[str, object], list[dict] | None]: """Check whether a quote has been paid and mint proofs if so. @@ -533,8 +527,12 @@ async def check_quote_status_and_mint( quote_unit = quote_status.get("unit") # Get active keyset for the quote's unit - keysets_resp = await self.get_keysets() - keysets = keysets_resp.get("keysets", []) + keysets_info = await self.get_keysets_info() + keysets = [ + keyset + for keyset in keysets_info + if keyset.get("active", True) and keyset.get("unit") == quote_unit + ] # Filter for active keysets with the quote's unit matching_keysets = [ @@ -557,16 +555,9 @@ async def check_quote_status_and_mint( mint_resp = await self.mint(quote=quote_id, outputs=outputs) # Get mint public key for unblinding - keys_resp = await self.get_keys(keyset_id_active) - mint_keys = None - for ks in keys_resp.get("keysets", []): - if ks["id"] == keyset_id_active: - keys_data: str | dict[str, str] = ks.get("keys", {}) - if isinstance(keys_data, dict) and keys_data: - mint_keys = keys_data - break - - if not mint_keys: + keyset = await self.get_keyset(keyset_id_active) + + if not (mint_keys := keyset["keys"]): raise MintError("Could not find mint keys") # Convert to proofs @@ -591,7 +582,8 @@ async def check_quote_status_and_mint( "amount": sig["amount"], "secret": secrets[i], "C": C.format(compressed=True).hex(), - "mint": mint_url, + "mint": self.url, + "unit": quote_unit, # Add the unit from the quote } ) @@ -677,43 +669,369 @@ def validate_keyset(self, keyset: dict) -> bool: return True - def validate_keysets_response(self, response: dict) -> bool: - """Validate a complete keysets response structure. - Args: - response: Response dictionary from /v1/keysets endpoint +# ────────────────────────────────────────────────────────────────────────────── +# Mint Environment +# ────────────────────────────────────────────────────────────────────────────── - Returns: - True if response is valid, False otherwise - """ - if "keysets" not in response: - return False - keysets = response["keysets"] - if not isinstance(keysets, list): - return False +# Popular public mints for user selection +POPULAR_MINTS = [ + # "https://mint.routstr.com" # coming soon + "https://mint.minibits.cash/Bitcoin", + "https://mint.cubabitcoin.org", + "https://stablenut.umint.cash", + "https://mint.macadamia.cash", +] + + +def get_mints_from_env() -> list[str]: + """Get mint URLs from environment variable or .env file. + + Expected format: comma-separated URLs + Example: CASHU_MINTS="https://mint1.com,https://mint2.com" + + Priority order: + 1. Environment variable CASHU_MINTS + 2. .env file in current working directory + + Returns: + List of mint URLs from environment or .env file, empty list if not set + """ + # First check environment variable + env_mints = os.getenv("CASHU_MINTS") + if env_mints: + # Split by comma and clean up + mints = [mint.strip() for mint in env_mints.split(",")] + # Filter out empty strings and remove duplicates while preserving order + mints = list(dict.fromkeys(mint for mint in mints if mint)) + return mints + + # Then check .env file in current working directory + try: + from pathlib import Path + + env_file = Path.cwd() / ".env" + if env_file.exists(): + content = env_file.read_text() + for line in content.splitlines(): + line = line.strip() + if line.startswith("CASHU_MINTS="): + # Extract value after the equals sign + value = line.split("=", 1)[1] + # Remove quotes if present + value = value.strip("\"'") + if value: + # Split by comma and clean up + mints = [mint.strip() for mint in value.split(",")] + # Filter out empty strings and remove duplicates while preserving order + mints = list(dict.fromkeys(mint for mint in mints if mint)) + return mints + except Exception: + # If reading .env file fails, continue + pass + + return [] + + +def set_mints_in_env(mints: list[str]) -> None: + """Set mint URLs in .env file for persistent caching. + + Args: + mints: List of mint URLs to cache + """ + if not mints: + return + + from pathlib import Path + + mint_str = ",".join(mints) + env_file = Path.cwd() / ".env" + env_line = f'CASHU_MINTS="{mint_str}"\n' + + try: + if env_file.exists(): + # Check if CASHU_MINTS already exists in the file + content = env_file.read_text() + lines = content.splitlines() + + # Look for existing CASHU_MINTS line + mint_line_found = False + new_lines = [] + for line in lines: + if line.strip().startswith("CASHU_MINTS="): + # Replace existing CASHU_MINTS line + new_lines.append(env_line.rstrip()) + mint_line_found = True + else: + new_lines.append(line) + + if not mint_line_found: + # Add new CASHU_MINTS line at the end + new_lines.append(env_line.rstrip()) + + # Write back to file + env_file.write_text("\n".join(new_lines) + "\n") + else: + # Create new .env file + env_file.write_text(env_line) + + except Exception as e: + # If writing to .env file fails, fall back to environment variable + print(f"Warning: Could not write to .env file: {e}") + print("Falling back to session environment variable") + os.environ["CASHU_MINTS"] = mint_str + + +def clear_mints_from_env() -> bool: + """Clear mint URLs from .env file and environment variable. + + Returns: + True if mints were cleared, False if none were set + """ + cleared = False + + # Clear from environment variable + if "CASHU_MINTS" in os.environ: + del os.environ["CASHU_MINTS"] + cleared = True + + # Clear from .env file + try: + from pathlib import Path + + env_file = Path.cwd() / ".env" + if env_file.exists(): + content = env_file.read_text() + lines = content.splitlines() + + # Remove CASHU_MINTS line + new_lines = [] + for line in lines: + if not line.strip().startswith("CASHU_MINTS="): + new_lines.append(line) + else: + cleared = True + + if new_lines: + # Write back remaining lines + env_file.write_text("\n".join(new_lines) + "\n") + else: + # If file would be empty, remove it + env_file.unlink() + + except Exception: + # If clearing from .env file fails, that's okay + pass + + return cleared + + +def validate_mint_url(url: str) -> bool: + """Validate that a mint URL has the correct format. - # Validate each keyset - for keyset in keysets: - if not isinstance(keyset, dict): - return False - if not self.validate_keyset(keyset): - return False + Args: + url: Mint URL to validate - return True + Returns: + True if URL appears valid, False otherwise + """ + if not url: + return False + + # Basic URL validation - should start with http:// or https:// + if not (url.startswith("http://") or url.startswith("https://")): + return False + + # Should not end with slash for consistency + if url.endswith("/"): + return False + + return True - async def get_validated_keysets(self) -> KeysetsResponse: - """Get keysets with validation according to NUT-02. - Returns: - Validated keysets response +# ────────────────────────────────────────────────────────────────────────────── +# Type definitions based on NUT-01 and OpenAPI spec +# ────────────────────────────────────────────────────────────────────────────── + + +class ProofOptional(TypedDict, total=False): + """Optional fields for Proof (NUT-00 specification).""" + + Y: str # Optional for P2PK (hex string) + witness: str # Optional witness data + dleq: dict[str, Any] # Optional DLEQ proof (NUT-12) + + +# Full Proof type combining required and optional fields +class ProofComplete(Proof, ProofOptional): + """Complete Proof type with both required and optional fields.""" + + pass + + +class MintInfo(TypedDict, total=False): + """Mint information response.""" + + name: str + pubkey: str + version: str + description: str + description_long: str + contact: list[dict[str, str]] + icon_url: str + motd: str + nuts: dict[str, dict[str, Any]] + + +# NUT-01 compliant keyset definitions +class Keyset(TypedDict): + """Individual keyset per NUT-01 specification.""" + + id: str # keyset identifier + unit: CurrencyUnit # currency unit + keys: dict[str, str] # amount -> compressed secp256k1 pubkey mapping - Raises: - MintError: If response is invalid or validation fails - """ - response = await self.get_keysets() - if not self.validate_keysets_response(dict(response)): - raise MintError("Invalid keysets response from mint") +class KeysResponse(TypedDict): + """NUT-01 compliant mint keys response from GET /v1/keys.""" + + keysets: list[Keyset] + + +class KeysetInfoRequired(TypedDict): + """Required fields for keyset information.""" + + id: str + unit: CurrencyUnit + active: bool + + +class KeysetInfoOptional(TypedDict, total=False): + """Optional fields for keyset information.""" + + input_fee_ppk: int # input fee in parts per thousand + + +class KeysetInfo(KeysetInfoRequired, KeysetInfoOptional): + """Extended keyset information for /v1/keysets endpoint.""" + + pass + + +class KeysetsResponse(TypedDict): + """Active keysets response from GET /v1/keysets.""" - return response + keysets: list[KeysetInfo] + + +class PostMintQuoteRequest(TypedDict, total=False): + """Request body for mint quote.""" + + unit: CurrencyUnit + amount: int + description: str + pubkey: str # for P2PK + + +class PostMintQuoteResponse(TypedDict): + """Mint quote response.""" + + # Required fields + quote: str # quote id + request: str # bolt11 invoice + amount: int + unit: CurrencyUnit + state: str # "UNPAID", "PAID", "ISSUED" + + # Optional fields - use TypedDict with total=False for these if needed + expiry: int + pubkey: str + paid: bool + + +class PostMintRequest(TypedDict, total=False): + """Request body for minting tokens.""" + + quote: str + outputs: list[BlindedMessage] + signature: str # optional for P2PK + + +class PostMintResponse(TypedDict): + """Mint response with signatures.""" + + signatures: list[BlindedSignature] + + +class PostMeltQuoteRequest(TypedDict, total=False): + """Request body for melt quote.""" + + unit: CurrencyUnit + request: str # bolt11 invoice + options: dict[str, Any] + + +class PostMeltQuoteResponse(TypedDict): + """Melt quote response.""" + + # Required fields + quote: str + amount: int + fee_reserve: int + unit: CurrencyUnit + + # Optional fields + request: str + paid: bool + state: str + expiry: int + payment_preimage: str + change: list[BlindedSignature] + + +class PostMeltRequest(TypedDict, total=False): + """Request body for melting tokens.""" + + quote: str + inputs: list[ProofComplete] + outputs: list[BlindedMessage] # for change + + +class PostSwapRequest(TypedDict): + """Request body for swapping proofs.""" + + inputs: list[ProofComplete] + outputs: list[BlindedMessage] + + +class PostSwapResponse(TypedDict): + """Swap response.""" + + signatures: list[BlindedSignature] + + +class PostCheckStateRequest(TypedDict): + """Request body for checking proof states.""" + + Ys: list[str] # Y values from proofs + + +class PostCheckStateResponse(TypedDict): + """Check state response.""" + + states: list[dict[str, str]] # Y -> state mapping + + +class PostRestoreRequest(TypedDict): + """Request body for restoring proofs.""" + + outputs: list[BlindedMessage] + + +class PostRestoreResponse(TypedDict, total=False): + """Restore response.""" + + outputs: list[BlindedMessage] + signatures: list[BlindedSignature] + promises: list[BlindedSignature] # deprecated diff --git a/sixty_nuts/relay.py b/sixty_nuts/relay.py index 9dcf2cc..1db0c53 100644 --- a/sixty_nuts/relay.py +++ b/sixty_nuts/relay.py @@ -132,7 +132,7 @@ async def discover_relays_from_nip65( print(f" Trying bootstrap relay: {bootstrap_url}") try: - relay = NostrRelay(bootstrap_url) + relay = Relay(bootstrap_url) await relay.connect() # Fetch NIP-65 relay list events (kind 10002) @@ -223,7 +223,7 @@ async def publish_relay_list_nip65(relays: list[str], privkey: PrivateKey) -> bo for bootstrap_url in BOOTSTRAP_RELAYS: try: - relay_client = NostrRelay(bootstrap_url) + relay_client = Relay(bootstrap_url) await relay_client.connect() from typing import cast @@ -736,7 +736,7 @@ def size(self) -> int: return len(self._queue) -class NostrRelay: +class Relay: """Minimal Nostr relay client for NIP-60 wallet operations.""" def __init__(self, url: str) -> None: @@ -983,7 +983,7 @@ async def fetch_relay_recommendations(self, pubkey: str) -> list[str]: return relays -class QueuedNostrRelay(NostrRelay): +class QueuedRelay(Relay): """Nostr relay client with event queuing and batching support.""" def __init__( @@ -1137,21 +1137,21 @@ async def disconnect(self) -> None: class RelayPool: - """Pool of QueuedNostrRelay instances with shared queue.""" + """Pool of QueuedRelay instances with shared queue.""" def __init__(self, urls: list[str], **relay_kwargs: Any) -> None: """Initialize relay pool with shared queue. Args: urls: List of relay URLs - **relay_kwargs: Arguments passed to QueuedNostrRelay + **relay_kwargs: Arguments passed to QueuedRelay """ - self.relays: list[QueuedNostrRelay] = [] + self.relays: list[QueuedRelay] = [] self.shared_queue = EventQueue() # Create relays with shared queue for url in urls: - relay = QueuedNostrRelay(url, **relay_kwargs) + relay = QueuedRelay(url, **relay_kwargs) # Replace individual queue with shared one relay.queue = self.shared_queue self.relays.append(relay) @@ -1198,7 +1198,7 @@ def create_event( } -class RelayManager: +class RelayClient: """Manages relay connections, discovery, and publishing for NIP-60 wallets.""" def __init__( @@ -1223,13 +1223,13 @@ def __init__( self.min_relay_interval = min_relay_interval # Relay instances - self.relay_instances: list[NostrRelay | QueuedNostrRelay] = [] + self.relay_instances: list[Relay | QueuedRelay] = [] self.relay_pool: RelayPool | None = None # Rate limiting self._last_relay_operation = 0.0 - async def get_relay_connections(self) -> list[NostrRelay]: + async def get_relay_connections(self) -> list[Relay]: """Get relay connections, discovering if needed.""" # If no relay URLs are configured, try to discover them if not self.relay_urls: @@ -1264,7 +1264,7 @@ async def get_relay_connections(self) -> list[NostrRelay]: from typing import cast self.relay_instances = cast( - list[NostrRelay | QueuedNostrRelay], self.relay_pool.relays + list[Relay | QueuedRelay], self.relay_pool.relays ) elif not self.use_queued_relays and not self.relay_instances: @@ -1280,7 +1280,7 @@ async def get_relay_connections(self) -> list[NostrRelay]: # Try to connect to relays for url in self.relay_urls[:5]: # Try up to 5 relays try: - relay = NostrRelay(url) + relay = Relay(url) await relay.connect() self.relay_instances.append(relay) diff --git a/sixty_nuts/temp.py b/sixty_nuts/temp.py index 9be3801..e9bdb5d 100644 --- a/sixty_nuts/temp.py +++ b/sixty_nuts/temp.py @@ -16,9 +16,7 @@ def __init__( self, *, mint_urls: list[str] | None = None, - currency: CurrencyUnit = "sat", - wallet_privkey: str | None = None, - relays: list[str] | None = None, + relay_urls: list[str] | None = None, ) -> None: """Initialize temporary wallet with a new random private key. @@ -36,9 +34,7 @@ def __init__( super().__init__( nsec=temp_nsec, mint_urls=mint_urls, - currency=currency, - wallet_privkey=wallet_privkey, - relays=relays, + relay_urls=relay_urls, ) def _encode_nsec(self, privkey: PrivateKey) -> str: @@ -87,12 +83,7 @@ async def create( # type: ignore[override] Returns: Temporary wallet instance (call initialize_wallet() to create events if needed) """ - wallet = cls( - mint_urls=mint_urls, - currency=currency, - wallet_privkey=wallet_privkey, - relays=relays, - ) + wallet = cls(mint_urls=mint_urls, relay_urls=relays) if auto_init: try: diff --git a/sixty_nuts/types.py b/sixty_nuts/types.py index cc00f16..fa07c7a 100644 --- a/sixty_nuts/types.py +++ b/sixty_nuts/types.py @@ -3,10 +3,10 @@ from __future__ import annotations from dataclasses import dataclass, field -from typing import Any, Literal, NotRequired, TypedDict +from typing import Any, Literal, TypedDict -class ProofDict(TypedDict): +class Proof(TypedDict): """Extended proof structure for NIP-60 wallet use. Extends the basic Proof with mint URL tracking for multi-mint support. @@ -17,7 +17,7 @@ class ProofDict(TypedDict): secret: str C: str mint: str - unit: NotRequired[CurrencyUnit] # currency unit for this proof + unit: CurrencyUnit class WalletError(Exception): @@ -65,15 +65,6 @@ class LNURLError(Exception): ] -class Proof(TypedDict): - """Proof format for mint API communication (NUT-00 compliant).""" - - id: str # keyset ID - amount: int - secret: str # hex encoded secret - C: str # hex encoded signature - - class BlindedMessage(TypedDict): """Blinded message for mint operations.""" @@ -130,21 +121,31 @@ class EventKind: @dataclass -class EnhancedWalletState: - """Enhanced wallet state with keyset tracking.""" +class WalletState: + """Wallet state with balance tracking.""" - balance_by_keyset: dict[str, int] # keyset_id -> balance - balance_by_currency: dict[CurrencyUnit, int] # currency -> balance - proofs: list[ProofDict] - keysets: dict[str, KeysetInfo] # keyset_id -> KeysetInfo + proofs: list[Proof] proof_to_event_id: dict[str, str] | None = None + @property + def balance_by_mint(self) -> dict[str, int]: + return {p["mint"]: p["amount"] for p in self.proofs} + + @property + def balance_by_unit(self) -> dict[CurrencyUnit, int]: + """Get total balance grouped by currency unit.""" + balances: dict[CurrencyUnit, int] = {} + for proof in self.proofs: + unit = proof["unit"] + balances[unit] = balances.get(unit, 0) + proof["amount"] + return balances + @property def total_balance_sat(self) -> int: """Get total balance in satoshis (only BTC-based currencies).""" total = 0 - for unit, balance in self.balance_by_currency.items(): + for unit, balance in self.balance_by_unit.items(): if unit == "sat": total += balance elif unit == "msat": @@ -160,9 +161,9 @@ def balance(self) -> int: return self.total_balance_sat @property - def proofs_by_keyset(self) -> dict[str, list[ProofDict]]: + def proofs_by_keyset(self) -> dict[str, list[Proof]]: """Group proofs by keyset ID.""" - grouped: dict[str, list[ProofDict]] = {} + grouped: dict[str, list[Proof]] = {} for proof in self.proofs: keyset_id = proof["id"] if keyset_id not in grouped: @@ -171,9 +172,9 @@ def proofs_by_keyset(self) -> dict[str, list[ProofDict]]: return grouped @property - def proofs_by_mint(self) -> dict[str, list[ProofDict]]: + def proofs_by_mint(self) -> dict[str, list[Proof]]: """Group proofs by mint URL.""" - grouped: dict[str, list[ProofDict]] = {} + grouped: dict[str, list[Proof]] = {} for proof in self.proofs: mint_url = proof["mint"] if mint_url not in grouped: @@ -181,11 +182,6 @@ def proofs_by_mint(self) -> dict[str, list[ProofDict]]: grouped[mint_url].append(proof) return grouped - @property - def proofs_by_mints(self) -> dict[str, list[ProofDict]]: - """Group proofs by mint URL (backward compatibility alias).""" - return self.proofs_by_mint - @property def mint_balances(self) -> dict[str, int]: """Get balances for all mints (backward compatibility).""" diff --git a/sixty_nuts/wallet.py b/sixty_nuts/wallet.py index 21c4283..1326ccc 100644 --- a/sixty_nuts/wallet.py +++ b/sixty_nuts/wallet.py @@ -1,8 +1,8 @@ from __future__ import annotations -from typing import cast + +from typing import Literal, cast import base64 -import os import json import secrets import time @@ -10,26 +10,19 @@ from pathlib import Path import httpx -from coincurve import PrivateKey, PublicKey +from coincurve import PublicKey -from .mint import ( - Mint, - ProofComplete, -) -from .relay import ( - RelayManager, - EventKind, - NostrEvent, -) +from .mint import Mint, ProofComplete, get_mints_from_env +from .relay import RelayClient, EventKind, get_relays_from_env from .crypto import ( unblind_signature, hash_to_curve, create_blinded_message_with_secret, get_mint_pubkey_for_amount, decode_nsec, - generate_privkey, get_pubkey, nip44_decrypt, + generate_privkey, ) from .lnurl import ( get_lnurl_data, @@ -38,13 +31,11 @@ LNURLError, ) from .types import ( - ProofDict, + Proof, WalletError, - EnhancedWalletState, - KeysetInfo, + WalletState, CurrencyUnit, BlindedMessage, - Proof, ) from .events import EventManager @@ -54,281 +45,6 @@ cbor2 = None # type: ignore -# Environment variable for mint URLs -MINTS_ENV_VAR = "CASHU_MINTS" - -# Popular public mints for user selection -POPULAR_MINTS = [ - # "https://mint.routstr.com" # coming soon - "https://mint.minibits.cash/Bitcoin", - "https://mint.cubabitcoin.org", - "https://stablenut.umint.cash", - "https://mint.macadamia.cash", -] - - -def get_mints_from_env() -> list[str]: - """Get mint URLs from environment variable or .env file. - - Expected format: comma-separated URLs - Example: CASHU_MINTS="https://mint1.com,https://mint2.com" - - Priority order: - 1. Environment variable CASHU_MINTS - 2. .env file in current working directory - - Returns: - List of mint URLs from environment or .env file, empty list if not set - """ - # First check environment variable - env_mints = os.getenv(MINTS_ENV_VAR) - if env_mints: - # Split by comma and clean up - mints = [mint.strip() for mint in env_mints.split(",")] - # Filter out empty strings - mints = [mint for mint in mints if mint] - return mints - - # Then check .env file in current working directory - try: - from pathlib import Path - - env_file = Path.cwd() / ".env" - if env_file.exists(): - content = env_file.read_text() - for line in content.splitlines(): - line = line.strip() - if line.startswith(f"{MINTS_ENV_VAR}="): - # Extract value after the equals sign - value = line.split("=", 1)[1] - # Remove quotes if present - value = value.strip("\"'") - if value: - # Split by comma and clean up - mints = [mint.strip() for mint in value.split(",")] - # Filter out empty strings - mints = [mint for mint in mints if mint] - return mints - except Exception: - # If reading .env file fails, continue - pass - - return [] - - -def set_mints_in_env(mints: list[str]) -> None: - """Set mint URLs in .env file for persistent caching. - - Args: - mints: List of mint URLs to cache - """ - if not mints: - return - - from pathlib import Path - - mint_str = ",".join(mints) - env_file = Path.cwd() / ".env" - env_line = f'{MINTS_ENV_VAR}="{mint_str}"\n' - - try: - if env_file.exists(): - # Check if CASHU_MINTS already exists in the file - content = env_file.read_text() - lines = content.splitlines() - - # Look for existing CASHU_MINTS line - mint_line_found = False - new_lines = [] - for line in lines: - if line.strip().startswith(f"{MINTS_ENV_VAR}="): - # Replace existing CASHU_MINTS line - new_lines.append(env_line.rstrip()) - mint_line_found = True - else: - new_lines.append(line) - - if not mint_line_found: - # Add new CASHU_MINTS line at the end - new_lines.append(env_line.rstrip()) - - # Write back to file - env_file.write_text("\n".join(new_lines) + "\n") - else: - # Create new .env file - env_file.write_text(env_line) - - except Exception as e: - # If writing to .env file fails, fall back to environment variable - print(f"Warning: Could not write to .env file: {e}") - print("Falling back to session environment variable") - os.environ[MINTS_ENV_VAR] = mint_str - - -def clear_mints_from_env() -> bool: - """Clear mint URLs from .env file and environment variable. - - Returns: - True if mints were cleared, False if none were set - """ - cleared = False - - # Clear from environment variable - if MINTS_ENV_VAR in os.environ: - del os.environ[MINTS_ENV_VAR] - cleared = True - - # Clear from .env file - try: - from pathlib import Path - - env_file = Path.cwd() / ".env" - if env_file.exists(): - content = env_file.read_text() - lines = content.splitlines() - - # Remove CASHU_MINTS line - new_lines = [] - for line in lines: - if not line.strip().startswith(f"{MINTS_ENV_VAR}="): - new_lines.append(line) - else: - cleared = True - - if new_lines: - # Write back remaining lines - env_file.write_text("\n".join(new_lines) + "\n") - else: - # If file would be empty, remove it - env_file.unlink() - - except Exception: - # If clearing from .env file fails, that's okay - pass - - return cleared - - -def validate_mint_url(url: str) -> bool: - """Validate that a mint URL has the correct format. - - Args: - url: Mint URL to validate - - Returns: - True if URL appears valid, False otherwise - """ - if not url: - return False - - # Basic URL validation - should start with http:// or https:// - if not (url.startswith("http://") or url.startswith("https://")): - return False - - # Should not end with slash for consistency - if url.endswith("/"): - return False - - return True - - -# ────────────────────────────────────────────────────────────────────────────── -# Keyset Management System -# ────────────────────────────────────────────────────────────────────────────── - - -class KeysetManager: - """Centralized keyset management for multi-currency support.""" - - def __init__(self) -> None: - self.keysets: dict[str, KeysetInfo] = {} # keyset_id -> KeysetInfo - self.mint_keysets: dict[str, list[str]] = {} # mint_url -> [keyset_ids] - self.currency_keysets: dict[ - CurrencyUnit, list[str] - ] = {} # currency -> [keyset_ids] - self._cache_expiry = 3600 # 1 hour cache - self._cache_timestamps: dict[str, float] = {} # keyset_id -> timestamp - - def add_keyset(self, keyset_info: KeysetInfo) -> None: - """Add or update a keyset.""" - keyset_id = keyset_info.id - mint_url = keyset_info.mint_url - currency = keyset_info.unit - - # Store keyset - self.keysets[keyset_id] = keyset_info - self._cache_timestamps[keyset_id] = time.time() - - # Update mint index - if mint_url not in self.mint_keysets: - self.mint_keysets[mint_url] = [] - if keyset_id not in self.mint_keysets[mint_url]: - self.mint_keysets[mint_url].append(keyset_id) - - # Update currency index - if currency not in self.currency_keysets: - self.currency_keysets[currency] = [] - if keyset_id not in self.currency_keysets[currency]: - self.currency_keysets[currency].append(keyset_id) - - def get_keyset(self, keyset_id: str) -> KeysetInfo | None: - """Get a keyset by ID.""" - return self.keysets.get(keyset_id) - - def get_mint_keysets( - self, mint_url: str, *, active_only: bool = True - ) -> list[KeysetInfo]: - """Get all keysets for a mint.""" - keyset_ids = self.mint_keysets.get(mint_url, []) - keysets = [self.keysets[kid] for kid in keyset_ids if kid in self.keysets] - - if active_only: - keysets = [ks for ks in keysets if ks.active] - - return keysets - - def get_currency_keysets( - self, currency: CurrencyUnit, *, active_only: bool = True - ) -> list[KeysetInfo]: - """Get all keysets for a currency.""" - keyset_ids = self.currency_keysets.get(currency, []) - keysets = [self.keysets[kid] for kid in keyset_ids if kid in self.keysets] - - if active_only: - keysets = [ks for ks in keysets if ks.active] - - return keysets - - def find_keyset( - self, mint_url: str, currency: CurrencyUnit, *, active_only: bool = True - ) -> KeysetInfo | None: - """Find the best keyset for a mint and currency.""" - mint_keysets = self.get_mint_keysets(mint_url, active_only=active_only) - currency_keysets = [ks for ks in mint_keysets if ks.unit == currency] - - if not currency_keysets: - return None - - # Prefer keysets with lower fees - currency_keysets.sort(key=lambda ks: ks.input_fee_ppk) - return currency_keysets[0] - - def is_cached(self, keyset_id: str) -> bool: - """Check if keyset is cached and not expired.""" - if keyset_id not in self._cache_timestamps: - return False - - age = time.time() - self._cache_timestamps[keyset_id] - return age < self._cache_expiry - - def clear_cache(self) -> None: - """Clear all cached keysets.""" - self.keysets.clear() - self.mint_keysets.clear() - self.currency_keysets.clear() - self._cache_timestamps.clear() - - # ────────────────────────────────────────────────────────────────────────────── # Wallet implementation # ────────────────────────────────────────────────────────────────────────────── @@ -341,53 +57,34 @@ def __init__( self, nsec: str, # nostr private key *, - mint_urls: list[str] | None = None, # cashu mint urls (can have multiple) - currency: CurrencyUnit = "sat", # TODO remove bc multi currency by default - wallet_privkey: str | None = None, # TODO remove contained in wallet event - relays: list[str] | None = None, # nostr relays to use + mint_urls: list[str] | None = None, + relay_urls: list[str] | None = None, ) -> None: - self.nsec = nsec self._privkey = decode_nsec(nsec) + self.pubkey = get_pubkey(self._privkey) - # Initialize mint URLs as a set that accumulates from all sources - self.mint_urls: set[str] = set(mint_urls) if mint_urls else set() - - self.currency: CurrencyUnit = currency - # Validate currency unit is supported - self._validate_currency_unit(currency) - - # Generate wallet privkey if not provided - if wallet_privkey is None: - wallet_privkey = generate_privkey() - self.wallet_privkey = wallet_privkey - self._wallet_privkey_obj = PrivateKey(bytes.fromhex(wallet_privkey)) - - # Store relays - will be determined later if not provided - self.relays: list[str] = relays or [] - - # Mint instances + self.mint_urls: list[str] = mint_urls or get_mints_from_env() + self.relay_urls: list[str] = relay_urls or get_relays_from_env() self.mints: dict[str, Mint] = {} - # Keyset manager for multi-currency support - self.keyset_manager = KeysetManager() - # Relay manager - will be initialized with proper relays later - self.relay_manager = RelayManager( - relay_urls=self.relays, # May be empty initially + self.relay_manager = RelayClient( + relay_urls=self.relay_urls, # May be empty initially privkey=self._privkey, # Already a PrivateKey object use_queued_relays=True, min_relay_interval=1.0, ) - # Event manager - will be initialized with mint URLs later - self.event_manager: EventManager | None = None - - # Track minted quotes to prevent double-minting + self.event_manager: EventManager = EventManager( + relay_manager=self.relay_manager, + privkey=self._privkey, + mint_urls=self.mint_urls, + ) self._minted_quotes: set[str] = set() - - # Shared HTTP client reused by all Mint objects self.mint_client = httpx.AsyncClient() + self.wallet_privkey: str | None = None + # Cache for proof validation results to prevent re-checking spent proofs self._proof_state_cache: dict[ str, dict[str, str] @@ -397,80 +94,13 @@ def __init__( # Track known spent proofs to avoid re-validation self._known_spent_proofs: set[str] = set() - async def _initialize_mint_urls(self) -> None: - """Initialize mint URLs from various sources. - - Accumulates mint URLs from all available sources: - - Constructor arguments (already added) - - Environment variables - - Existing NIP-60 wallet event - """ - # 1. Constructor mints are already in self.mint_urls - - # 2. Add from environment variables - env_mints = get_mints_from_env() - if env_mints: - self.mint_urls.update(env_mints) - - # 3. Add from existing wallet event - try: - exists, wallet_event = await self.check_wallet_event_exists() - if exists and wallet_event: - content = nip44_decrypt(wallet_event["content"], self._privkey) - wallet_data = json.loads(content) - - # Extract mint URLs from wallet event - event_mints = [] - for item in wallet_data: - if item[0] == "mint": - event_mints.append(item[1]) - - if event_mints: - self.mint_urls.update(event_mints) - except Exception: - # Failed to decrypt or parse wallet event - continue - pass - - # 4. Check if we have any mint URLs - if not self.mint_urls: - raise WalletError( - "No mint URLs configured. Please provide mint URLs via:\n" - f' - Environment variable: {MINTS_ENV_VAR}="https://mint1.com,https://mint2.com"\n' - f' - .env file in current directory: {MINTS_ENV_VAR}="https://mint1.com,https://mint2.com"\n' - ' - Constructor argument: mint_urls=["https://mint1.com"]\n' - " - Or use the CLI to select from popular mints" - ) - - async def _initialize_event_manager(self) -> None: - """Initialize event manager after mint URLs are determined.""" - if not self.mint_urls: - raise WalletError("Cannot initialize event manager without mint URLs") - - self.event_manager = EventManager( - relay_manager=self.relay_manager, - privkey=self._privkey, - mint_urls=list(self.mint_urls), # Convert set to list for EventManager - ) - - async def _ensure_event_manager(self) -> EventManager: - """Ensure event manager is initialized and return it.""" - if self.event_manager is None: - if not self.mint_urls: - await self._initialize_mint_urls() - await self._initialize_event_manager() - - assert self.event_manager is not None # For type checker - return self.event_manager - @classmethod async def create( cls, nsec: str, *, mint_urls: list[str] | None = None, - currency: CurrencyUnit = "sat", - wallet_privkey: str | None = None, - relays: list[str] | None = None, + relay_urls: list[str] | None = None, auto_init: bool = True, prompt_for_relays: bool = True, ) -> "Wallet": @@ -479,9 +109,7 @@ async def create( Args: nsec: Nostr private key mint_urls: Cashu mint URLs - currency: Currency unit - wallet_privkey: Private key for P2PK operations - relays: Nostr relay URLs (if None, will discover automatically) + relay_urls: Nostr relay URLs (if None, will discover automatically) auto_init: If True, check for existing wallet state (but don't create new events) prompt_for_relays: If True, prompt user for relays if none found @@ -492,30 +120,25 @@ async def create( from .relay import get_relays_for_wallet # If no relays provided, discover them - if not relays: + if not relay_urls: privkey = decode_nsec(nsec) - relays = await get_relays_for_wallet( + relay_urls = await get_relays_for_wallet( privkey, prompt_if_needed=prompt_for_relays ) - wallet = cls( - nsec=nsec, - mint_urls=mint_urls, - currency=currency, - wallet_privkey=wallet_privkey, - relays=relays, - ) + wallet = cls(nsec=nsec, mint_urls=mint_urls, relay_urls=relay_urls) - # Initialize mint URLs from various sources - try: - await wallet._initialize_mint_urls() - except WalletError: - # If this is CLI usage, we'll handle mint selection there - # For non-CLI usage, re-raise the error - raise + # TODO: fix this + # # Initialize mint URLs from various sources + # try: + # await wallet._initialize_mint_urls() + # except WalletError: + # # If this is CLI usage, we'll handle mint selection there + # # For non-CLI usage, re-raise the error + # raise - # Initialize event manager now that we have mint URLs - await wallet._initialize_event_manager() + # # Initialize event manager now that we have mint URLs + # await wallet._initialize_event_manager() if auto_init: try: @@ -565,28 +188,35 @@ async def redeem(self, token: str, *, auto_swap: bool = True) -> tuple[int, str] # Calculate optimal denominations for the amount after fees output_amount = total_amount - input_fees - optimal_denoms = self._calculate_optimal_denominations(output_amount) + optimal_denoms = await self._calculate_optimal_denominations( + output_amount, mint_url, unit + ) # Use the abstracted swap method to get new proofs new_proofs = await self._swap_proof_denominations( - proofs, optimal_denoms, mint_url + proofs, optimal_denoms, mint_url, unit ) # Publish new token event - event_manager = await self._ensure_event_manager() - token_event_id = await event_manager.publish_token_event(new_proofs) # type: ignore + token_event_id = await self.event_manager.publish_token_event(new_proofs) # type: ignore # Publish spending history - await event_manager.publish_spending_history( + await self.event_manager.publish_spending_history( direction="in", amount=output_amount, # Use actual amount added after fees + unit=unit, created_token_ids=[token_event_id], ) return output_amount, unit # Return actual amount added to wallet after fees async def mint_async( - self, amount: int, *, timeout: int = 300, mint_url: str | None = None + self, + amount: int, + *, + mint_url: str | None = None, + currency: CurrencyUnit | None = None, + timeout: int = 300, ) -> tuple[str, asyncio.Task[bool]]: """Create a Lightning invoice and return a task that completes when paid. @@ -608,11 +238,11 @@ async def mint_async( # Do other things... paid = await task # Wait for payment """ - target_mint_url = mint_url or self._primary_mint_url() - ## TODO maybe better self._get_mint(mint_url) # returns primary mint if None - ## then mint.create_quote(amount) - invoice, quote_id = await self.create_quote(amount, target_mint_url) - mint = self._get_mint(target_mint_url) + mint = self._get_mint(mint_url or self._primary_mint_url()) + # Convert amount to base unit for the currency (e.g., dollars to cents) + base_amount = self._convert_to_base_unit(amount, currency or "sat") + quote_resp = await mint.create_mint_quote(amount=base_amount, unit=currency) + quote_id, invoice = quote_resp["quote"], quote_resp["request"] async def poll_payment() -> bool: start_time = time.time() @@ -621,38 +251,41 @@ async def poll_payment() -> bool: while (time.time() - start_time) < timeout: # Check quote status and mint if paid quote_status, new_proofs = await mint.check_quote_status_and_mint( - quote_id, - amount, - minted_quotes=self._minted_quotes, - mint_url=target_mint_url, # TODO this should not be needed, mint instance should contain url + quote_id, base_amount, minted_quotes=self._minted_quotes ) # If new proofs were minted, publish wallet events if new_proofs: - # Convert dict proofs to ProofDict - proof_dicts: list[ProofDict] = [] + # Convert dict proofs to Proof + proof_dicts: list[Proof] = [] for proof in new_proofs: + print("DEBUG PROOFS", proof, proof["unit"], mint.url) proof_dicts.append( - ProofDict( + Proof( id=proof["id"], amount=proof["amount"], secret=proof["secret"], C=proof["C"], - mint=proof["mint"], + mint=mint.url, + unit=proof["unit"], ) ) # Publish token event - event_manager = await self._ensure_event_manager() - token_event_id = await event_manager.publish_token_event( + token_event_id = await self.event_manager.publish_token_event( proof_dicts ) # Publish spending history mint_amount = sum(p["amount"] for p in new_proofs) - await event_manager.publish_spending_history( + # Get unit from first proof (all proofs should have same unit from single mint operation) + mint_unit = ( + new_proofs[0].get("unit", "sat") if new_proofs else "sat" + ) + await self.event_manager.publish_spending_history( direction="in", amount=mint_amount, + unit=mint_unit, created_token_ids=[token_event_id], ) @@ -674,8 +307,7 @@ async def melt( self, invoice: str, *, - target_mint: str | None = None, # TODO remove because keyset contain mint info - target_keyset_id: str | None = None, # TODO implement + target_mint: str | None = None, ) -> None: """Pay a Lightning invoice by melting tokens with automatic multi-mint support. @@ -690,38 +322,35 @@ async def melt( await wallet.melt("lnbc100n1...") """ try: - invoice_amount = parse_lightning_invoice_amount(invoice, self.currency) + invoice_amount_sat = parse_lightning_invoice_amount(invoice, "sat") except LNURLError as e: raise WalletError(f"Invalid Lightning invoice: {e}") from e - # Get current state and check balance state = await self.fetch_wallet_state(check_proofs=True) - total_needed = int(invoice_amount * 1.01) + total_needed = int(invoice_amount_sat * 1.01) self.raise_if_insufficient_balance(state.total_balance_sat, total_needed) if target_mint is None: mint_balances = state.mint_balances - if not mint_balances: - raise WalletError("No mints available") target_mint = max(mint_balances, key=lambda k: mint_balances[k]) if mint_balances[target_mint] < total_needed: - await self.rebalance_until_target(target_mint, total_needed) + await self.transfer_balance_to_mint(total_needed, target_mint) return await self.melt(invoice, target_mint=target_mint) # Create melt quote to get fees mint = self._get_mint(target_mint) - melt_quote = await mint.create_melt_quote(unit=self.currency, request=invoice) + melt_quote = await mint.create_melt_quote(request=invoice) fee_reserve = melt_quote.get("fee_reserve", 0) - total_needed = invoice_amount + fee_reserve - # TODO: check if we have enough balance in the target mint with real fee caclulation + total_needed = invoice_amount_sat + fee_reserve + self.raise_if_insufficient_balance(state.total_balance_sat, total_needed) # Select proofs for the total amount needed (invoice + fees) selected_proofs, consumed_proofs = await self._select_proofs( - state.proofs, total_needed, target_mint + state.proofs, total_needed, target_mint, None ) # Convert selected proofs to mint format - mint_proofs = [self._proofdict_to_mint_proof(p) for p in selected_proofs] + mint_proofs = selected_proofs # Execute the melt operation # Cast to ProofComplete since melt expects it (ProofComplete extends Proof with optional fields) @@ -736,38 +365,30 @@ async def melt( ) # Handle any change returned from the mint - change_proofs: list[ProofDict] = [] + change_proofs: list[Proof] = [] if "change" in melt_resp and melt_resp["change"]: + print( + "recieved change TODO pls handle this: ", + melt_resp["change"], + change_proofs, + ) # TODO: handle change - # Convert BlindedSignatures to ProofDict format + # Convert BlindedSignatures to Proof format # This would require unblinding logic, but for now we'll skip change handling # In practice, most melts shouldn't have change if amounts are selected properly + # await self.store_proofs(change_proofs) pass # Mark the consumed input proofs as spent await self._mark_proofs_as_spent(consumed_proofs) - # Store any change proofs - if change_proofs: - await self.store_proofs(change_proofs) - - # Publish spending history - event_manager = await self._ensure_event_manager() - await event_manager.publish_spending_history( - direction="out", - amount=invoice_amount, # The actual invoice amount paid - destroyed_token_ids=[], # Will be handled by _mark_proofs_as_spent - ) - async def send( self, amount: int, - target_mint: str | None = None, # TODO remove bc keyset contains mint info - keyset_id: str | None = None, # TODO implement *, - token_version: int = 4, # Default to V4 (CashuB) - unit: CurrencyUnit - | None = None, # TODO remove bc keyset should handle currency + mint_url: str | None = None, + unit: CurrencyUnit = "sat", + token_version: Literal[3, 4] = 4, # Default to V4 (CashuB) ) -> str: """Create a Cashu token for sending. @@ -777,10 +398,9 @@ async def send( Args: amount: Amount to send in the specified currency unit - target_mint: Target mint URL (defaults to primary mint) + mint_url: Target mint URL (defaults to mint with sufficient balance) + unit: Currency unit to send (defaults to "sat") token_version: Token format version (3 for CashuA, 4 for CashuB) - unit: Currency unit to send (defaults to wallet's currency) - keyset_id: Specific keyset ID to send from Returns: Cashu token string that can be sent to another wallet @@ -799,102 +419,60 @@ async def send( # Send USD tokens token = await wallet.send(50, unit="usd") - # Send from specific keyset - token = await wallet.send(100, keyset_id="abc123...") + # Send from specific mint + token = await wallet.send(100, mint_url="https://mint.example.com") """ if token_version not in [3, 4]: raise ValueError(f"Unsupported token version: {token_version}. Use 3 or 4.") - # Use specified unit or default to wallet currency - send_unit = unit or self.currency - - # Get wallet state and filter proofs by unit/keyset + # Get wallet state once state = await self.fetch_wallet_state(check_proofs=True) - # Filter proofs based on unit and keyset_id - filtered_proofs = state.proofs - if keyset_id: - # If keyset_id specified, use only proofs from that keyset - filtered_proofs = [p for p in filtered_proofs if p.get("id") == keyset_id] - if not filtered_proofs: - raise WalletError(f"No proofs found for keyset {keyset_id}") - - # Verify keyset matches requested unit - keyset_info = self.keyset_manager.get_keyset(keyset_id) - if keyset_info and keyset_info.unit != send_unit: - raise WalletError( - f"Keyset {keyset_id} uses {keyset_info.unit}, but requested {send_unit}" - ) - else: - # Filter by currency unit - unit_filtered = [] - for proof in filtered_proofs: - proof_keyset = self.keyset_manager.get_keyset(proof.get("id", "")) - if proof_keyset and proof_keyset.unit == send_unit: - unit_filtered.append(proof) - filtered_proofs = unit_filtered + # If no mint URL specified, find a mint with sufficient balance of the requested unit + if mint_url is None: + mint_url = await self._select_mint_for_amount(amount, unit, state.proofs) - if not filtered_proofs: - raise WalletError(f"No proofs found for currency {send_unit}") + mint = self._get_mint(mint_url) - # Calculate balance from filtered proofs - filtered_balance = sum(p["amount"] for p in filtered_proofs) + # Filter proofs by the requested currency unit AND mint + unit_proofs = [p for p in state.proofs if p.get("unit") == unit] + mint_unit_proofs = [p for p in unit_proofs if p.get("mint") == mint.url] + mint_unit_balance = sum(p["amount"] for p in mint_unit_proofs) - if target_mint is None: - # Find mint with balance in the requested currency - mint_urls = set(p.get("mint", "") for p in filtered_proofs) - if not mint_urls: - raise WalletError(f"No mints found with {send_unit} balance") - - # Use mint with highest balance - best_mint = "" - best_balance = 0 - for mint_url in mint_urls: - mint_proofs = [p for p in filtered_proofs if p.get("mint") == mint_url] - mint_balance = sum(p["amount"] for p in mint_proofs) - if mint_balance > best_balance: - best_balance = mint_balance - best_mint = mint_url - target_mint = best_mint - - if filtered_balance < amount: - raise WalletError( - f"Insufficient balance. Need at least {amount} {send_unit}, " - f"but have {filtered_balance}" - ) + if mint_unit_balance < amount: + # Check if we have enough proofs of this unit at ANY mint + total_unit_balance = sum(p["amount"] for p in unit_proofs) + if total_unit_balance < amount: + raise WalletError( + f"Insufficient {unit.upper()} balance: need {amount}, have {total_unit_balance}" + ) + else: + raise WalletError( + f"Insufficient {unit.upper()} balance at {mint.url}: need {amount}, have {mint_unit_balance}. " + f"Total {unit.upper()} balance across all mints: {total_unit_balance}" + ) selected_proofs, consumed_proofs = await self._select_proofs( - filtered_proofs, amount, target_mint + unit_proofs, amount, mint.url, unit ) token = self._serialize_proofs_for_token( - selected_proofs, target_mint, token_version + selected_proofs, mint.url, token_version, currency=unit ) - # Mark the consumed input proofs as spent (not the output proofs!) - # This creates proper NIP-60 state transitions with rollover events await self._mark_proofs_as_spent(consumed_proofs) - # Note: Spending history is now created automatically in _mark_proofs_as_spent - # TODO: store pending token somewhere to check on status and potentially undo - return token async def send_to_lnurl( - self, - lnurl: str, - amount: int, - *, - unit: CurrencyUnit | None = None, - keyset_id: str | None = None, + self, lnurl: str, amount: int, *, unit: CurrencyUnit = "sat" ) -> int: """Send funds to an LNURL address. Args: lnurl: LNURL string (can be lightning:, user@host, bech32, or direct URL) amount: Amount to send in the specified currency unit - unit: Currency unit to send (defaults to wallet's currency) - keyset_id: Specific keyset ID to send from + unit: Currency unit to send (defaults to "sat") Returns: Amount actually paid in the specified currency unit @@ -911,47 +489,28 @@ async def send_to_lnurl( # Send USD to Lightning Address paid = await wallet.send_to_lnurl("user@getalby.com", 50, unit="usd") """ - - # Use specified unit or default to wallet currency - send_unit = unit or self.currency - - # Get current balance for the specified unit state = await self.fetch_wallet_state(check_proofs=True) - # Get balance for the specific unit - unit_balance = state.balance_by_currency.get(send_unit, 0) - - # Estimate fees (1% with minimum) - estimated_fee_sats = max(amount * 0.01, 2) - if send_unit == "msat": - estimated_fee = estimated_fee_sats * 1000 - else: - estimated_fee = estimated_fee_sats - - if unit_balance < amount + estimated_fee: - raise WalletError( - f"Insufficient balance. Need at least {amount + estimated_fee} {send_unit} " - f"(amount: {amount} + estimated fees: {estimated_fee} {send_unit}), but have {unit_balance}" - ) + total_needed_estimated = int(amount * 1.01) + self.raise_if_insufficient_balance( + state.total_balance_sat, total_needed_estimated + ) - # Get LNURL data lnurl_data = await get_lnurl_data(lnurl) - # Convert amounts based on currency - if send_unit == "sat": + if unit == "sat": amount_msat = amount * 1000 min_sendable_sat = lnurl_data["min_sendable"] // 1000 max_sendable_sat = lnurl_data["max_sendable"] // 1000 unit_str = "sat" - elif send_unit == "msat": + elif unit == "msat": amount_msat = amount min_sendable_sat = lnurl_data["min_sendable"] max_sendable_sat = lnurl_data["max_sendable"] unit_str = "msat" else: - raise WalletError(f"Currency {send_unit} not supported for LNURL") + raise WalletError(f"Currency {unit} not supported for LNURL") - # Check amount limits if not ( lnurl_data["min_sendable"] <= amount_msat <= lnurl_data["max_sendable"] ): @@ -959,61 +518,35 @@ async def send_to_lnurl( f"Amount {amount} {unit_str} is outside LNURL limits " f"({min_sendable_sat} - {max_sendable_sat} {unit_str})" ) - print(amount_msat, min_sendable_sat, max_sendable_sat) - # Get Lightning invoice - bolt11_invoice, invoice_data = await get_lnurl_invoice( + bolt11_invoice, _ = await get_lnurl_invoice( lnurl_data["callback_url"], amount_msat ) - print(bolt11_invoice) - # Pay the invoice using melt await self.melt(bolt11_invoice) - return amount # Return the amount we intended to pay + return amount + + # ───────────────────────── Proof Management ───────────────────────────────── + + # async def create_quote(self, amount: int, mint_url: str) -> tuple[str, str]: + # # TODO: Implement quote tracking as per NIP-60: + # # await self.publish_quote_tracker( + # # quote_id=quote_resp["quote"], + # # mint_url=mint_url, + # # expiration=int(time.time()) + 14 * 24 * 60 * 60 # 2 weeks + # # ) async def roll_over_proofs( self, *, - spent_proofs: list[ProofDict], - unspent_proofs: list[ProofDict], + spent_proofs: list[Proof], + unspent_proofs: list[Proof], deleted_event_ids: list[str], ) -> str: - """Roll over unspent proofs after a partial spend and return new token id.""" - # TODO: Implement roll over logic - return "" - - # ───────────────────────── Proof Management ───────────────────────────────── - - async def create_quote(self, amount: int, mint_url: str) -> tuple[str, str]: - """Create a Lightning invoice (quote) at the mint and return the BOLT-11 string and quote ID. - - Returns: - Tuple of (lightning_invoice, quote_id) - """ - mint = self._get_mint(mint_url) - - # Create mint quote - quote_resp = await mint.create_mint_quote( - unit=self.currency, - amount=amount, - ) - - # Optionally publish quote tracker event - # (skipping for simplicity) - - # TODO: Implement quote tracking as per NIP-60: - # await self.publish_quote_tracker( - # quote_id=quote_resp["quote"], - # mint_url=mint_url, - # expiration=int(time.time()) + 14 * 24 * 60 * 60 # 2 weeks - # ) - - return quote_resp.get("request", ""), quote_resp.get( - "quote", "" - ) # Return both invoice and quote_id + return "" # TODO: Implement roll over logic for wallet cleanup async def _consolidate_proofs( - self, proofs: list[ProofDict], target_mint: str | None = None + self, proofs: list[Proof], target_mint: str | None = None ) -> None: """Cleanup proofs by deleting events and updating wallet state. @@ -1028,9 +561,13 @@ async def _consolidate_proofs( if not proofs: state = await self.fetch_wallet_state(check_proofs=True) proofs = state.proofs + # TODO: these proofs are not used anywhere following logic needs to be updated + + # Get currency unit from the first proof + unit = proofs[0].get("unit") or "sat" # Process each mint - for mint_url, mint_proofs in state.proofs_by_mints.items(): + for mint_url, mint_proofs in state.proofs_by_mint.items(): if not mint_proofs: continue @@ -1040,11 +577,17 @@ async def _consolidate_proofs( # Check if already optimally denominated current_denoms: dict[int, int] = {} for proof in mint_proofs: + if proof.get("unit") != unit: + raise WalletError( + f"All proofs must have the same unit. Mint {mint_url} has proofs with different units: {unit} != {proof.get('unit', 'sat')}" + ) amount = proof["amount"] current_denoms[amount] = current_denoms.get(amount, 0) + 1 # Calculate optimal denominations for the balance - optimal_denoms = self._calculate_optimal_denominations(current_balance) + optimal_denoms = await self._calculate_optimal_denominations( + current_balance, mint_url, unit + ) # Check if current denominations match optimal needs_consolidation = False @@ -1059,7 +602,7 @@ async def _consolidate_proofs( try: # Use the new abstracted swap method new_proofs = await self._swap_proof_denominations( - mint_proofs, optimal_denoms, mint_url + mint_proofs, optimal_denoms, mint_url, unit ) # Store new proofs on Nostr @@ -1068,41 +611,23 @@ async def _consolidate_proofs( print(f"Warning: Failed to consolidate proofs for {mint_url}: {e}") continue - def _calculate_optimal_denominations( - self, amount: int, keyset_id: str | None = None, mint_url: str | None = None + async def _calculate_optimal_denominations( + self, amount: int, mint_url: str, currency: CurrencyUnit ) -> dict[int, int]: - """Calculate optimal denomination breakdown for an amount. - - Args: - amount: Amount to split into denominations - keyset_id: Specific keyset to use (optional) - mint_url: Mint URL to find keyset from (optional) - - Returns: - Dict of denomination -> count - """ - from .denominations import DenominationSystem - - # Try to get keyset info for dynamic denominations - keyset_info = None - - if keyset_id: - keyset_info = self.keyset_manager.get_keyset(keyset_id) - elif mint_url: - # Find best keyset for the mint and currency - keyset_info = self.keyset_manager.find_keyset(mint_url, self.currency) - - if keyset_info: - # Use dynamic denominations from keyset - available_denoms = DenominationSystem.get_keyset_denominations(keyset_info) - return DenominationSystem.calculate_optimal_split(amount, available_denoms) - else: - # Fallback to default powers of 2 - return DenominationSystem._default_split(amount) + mint = self._get_mint(mint_url or self._primary_mint_url()) + currencies = await mint.get_currencies() + if currency not in currencies: + raise WalletError(f"Currency {currency} not supported by mint {mint.url}") + available_denoms = await mint.get_denominations_for_currency(currency) + return Mint.calculate_optimal_split(amount, available_denoms) async def _select_proofs( - self, proofs: list[ProofDict], amount: int, target_mint: str - ) -> tuple[list[ProofDict], list[ProofDict]]: + self, + proofs: list[Proof], + amount: int, + target_mint: str, + unit: CurrencyUnit | None, + ) -> tuple[list[Proof], list[Proof]]: """Select proofs for spending a specific amount using optimal selection. Uses a greedy algorithm to minimize the number of proofs and change. @@ -1128,22 +653,17 @@ async def _select_proofs( f"Insufficient balance: need {amount}, have {valid_available}" ) - if target_mint is None: - target_mint = self._get_primary_mint_url() - # check if enough balance in proofs from target mint target_mint_proofs = [p for p in valid_proofs if p.get("mint") == target_mint] target_mint_balance = sum(p["amount"] for p in target_mint_proofs) if target_mint_balance < amount: - await self.transfer_balance_to_mint( - amount - target_mint_balance, target_mint + raise WalletError( + f"Insufficient balance at mint {target_mint}: need {amount}, have {target_mint_balance}" ) - state = await self.fetch_wallet_state(check_proofs=True) - return await self._select_proofs(state.proofs, amount, target_mint) # Use greedy algorithm to select minimum proofs needed target_mint_proofs.sort(key=lambda p: p["amount"], reverse=True) - selected_input_proofs: list[ProofDict] = [] + selected_input_proofs: list[Proof] = [] selected_total = 0 for proof in target_mint_proofs: @@ -1171,8 +691,16 @@ async def _select_proofs( # So: outputs = inputs - fees = selected_total - input_fees output_amount = int(selected_total - input_fees) + if unit is None: + # TODO: this is a hack to get the default unit for the mint + # TODO: this should be handled in more complex way where it denominates proofs per unit + print("No unit provided, using default 'sat'") + unit = "sat" + # Recalculate denominations for the actual output amount - send_denoms = self._calculate_optimal_denominations(amount) + send_denoms = await self._calculate_optimal_denominations( + amount, target_mint, unit + ) change_amount = int(output_amount - amount) # Ensure integer if change_amount < 0: @@ -1181,7 +709,9 @@ async def _select_proofs( f"(after {input_fees} sats in fees)" ) - change_denoms = self._calculate_optimal_denominations(change_amount) + change_denoms = await self._calculate_optimal_denominations( + change_amount, target_mint, unit + ) # Combine send and change denominations target_denoms = send_denoms.copy() @@ -1190,12 +720,12 @@ async def _select_proofs( # Swap the selected proofs for the target denominations new_proofs = await self._swap_proof_denominations( - selected_input_proofs, target_denoms, target_mint + selected_input_proofs, target_denoms, target_mint, unit ) # Select exactly the amount needed for sending - selected_proofs: list[ProofDict] = [] - change_proofs: list[ProofDict] = [] + selected_proofs: list[Proof] = [] + change_proofs: list[Proof] = [] used_proofs: set[str] = set() remaining_amount = amount @@ -1225,10 +755,11 @@ async def _select_proofs( async def _swap_proof_denominations( self, - proofs: list[ProofDict], + proofs: list[Proof], target_denominations: dict[int, int], mint_url: str, - ) -> list[ProofDict]: + currency: CurrencyUnit, + ) -> list[Proof]: """Swap proofs to specific target denominations. This method abstracts the process of swapping proofs for new ones with @@ -1278,24 +809,15 @@ async def _swap_proof_denominations( # return proofs if they are # Convert to mint proof format - mint_proofs = [self._proofdict_to_mint_proof(p) for p in proofs] - - # Get active keyset filtered by currency unit - keysets_resp = await mint.get_keysets() - keysets = keysets_resp.get("keysets", []) - active_keysets = [ - ks - for ks in keysets - if ks.get("active", True) and ks.get("unit") == self.currency - ] + mint_proofs = proofs - if not active_keysets: + active_keysets = await mint.get_active_keysets() + keyset = next((ks for ks in active_keysets if ks.get("unit") == currency), None) + if not keyset: raise WalletError( - f"No active keysets found for currency unit '{self.currency}'" + f"No keyset found for currency {currency} on mint {mint.url}" ) - keyset_id = str(active_keysets[0]["id"]) - # Create blinded messages for target denominations outputs: list[BlindedMessage] = [] secrets: list[str] = [] @@ -1304,7 +826,7 @@ async def _swap_proof_denominations( for denomination, count in sorted(target_denominations.items()): for _ in range(count): secret, r_hex, blinded_msg = create_blinded_message_with_secret( - denomination, keyset_id + denomination, keyset["id"] ) outputs.append(blinded_msg) secrets.append(secret) @@ -1316,23 +838,10 @@ async def _swap_proof_denominations( inputs=cast(list[ProofComplete], mint_proofs), outputs=outputs ) - # Get mint keys for unblinding - keys_resp = await mint.get_keys(keyset_id) - mint_keysets = keys_resp.get("keysets", []) - mint_keys = None - - for ks in mint_keysets: - if str(ks.get("id")) == keyset_id: - keys_data: dict[str, str] | str = ks.get("keys", {}) - if isinstance(keys_data, dict) and keys_data: - mint_keys = keys_data - break - - if not mint_keys: - raise WalletError("Could not find mint keys for unblinding") + mint_keys = keyset["keys"] # Unblind signatures to create new proofs - new_proofs: list[ProofDict] = [] + new_proofs: list[Proof] = [] for i, sig in enumerate(swap_resp["signatures"]): # Get the public key for this amount amount = sig["amount"] @@ -1346,7 +855,7 @@ async def _swap_proof_denominations( C = unblind_signature(C_, r, mint_pubkey) new_proofs.append( - ProofDict( + Proof( id=sig["id"], amount=sig["amount"], secret=secrets[ @@ -1354,12 +863,13 @@ async def _swap_proof_denominations( ], # Already hex from create_blinded_message_with_secret C=C.format(compressed=True).hex(), mint=mint_url, + unit=currency, ) ) return new_proofs - async def _mark_proofs_as_spent(self, spent_proofs: list[ProofDict]) -> None: + async def _mark_proofs_as_spent(self, spent_proofs: list[Proof]) -> None: """Mark proofs as spent following NIP-60 state transitions. This creates proper rollover events with 'del' fields to mark old events as superseded, @@ -1368,6 +878,7 @@ async def _mark_proofs_as_spent(self, spent_proofs: list[ProofDict]) -> None: Args: spent_proofs: List of proofs to mark as spent """ + # TODO check this if not spent_proofs: return @@ -1382,7 +893,7 @@ async def _mark_proofs_as_spent(self, spent_proofs: list[ProofDict]) -> None: # 2. Find which events need updating (contain spent proofs) spent_proof_ids = {f"{p['secret']}:{p['C']}" for p in spent_proofs} - events_with_spent_proofs: dict[str, list[ProofDict]] = {} + events_with_spent_proofs: dict[str, list[Proof]] = {} # Group all proofs by their event IDs for proof in state.proofs: @@ -1419,8 +930,7 @@ async def _mark_proofs_as_spent(self, spent_proofs: list[ProofDict]) -> None: if unspent_proofs: # Create new event with remaining proofs try: - event_manager = await self._ensure_event_manager() - new_id = await event_manager.publish_token_event( + new_id = await self.event_manager.publish_token_event( unspent_proofs, deleted_token_ids=[event_id] ) new_event_ids.append(new_id) @@ -1433,8 +943,7 @@ async def _mark_proofs_as_spent(self, spent_proofs: list[ProofDict]) -> None: # 4. Try to delete old events (best effort - don't fail if relay doesn't support it) for event_id in events_to_delete: try: - event_manager = await self._ensure_event_manager() - await event_manager.delete_token_event(event_id) + await self.event_manager.delete_token_event(event_id) except Exception as e: # Deletion failed - that's okay, the 'del' field handles supersession print( @@ -1444,13 +953,27 @@ async def _mark_proofs_as_spent(self, spent_proofs: list[ProofDict]) -> None: # 5. Create spending history (optional but recommended) if events_to_delete or new_event_ids: try: - event_manager = await self._ensure_event_manager() - await event_manager.publish_spending_history( - direction="out", - amount=sum(p["amount"] for p in spent_proofs), - created_token_ids=new_event_ids, - destroyed_token_ids=events_to_delete, - ) + # Group spent proofs by unit for proper history tracking + spent_by_unit: dict[str, int] = {} + for proof in spent_proofs: + unit_str = str(proof.get("unit", "sat")) # Ensure it's a string + spent_by_unit[unit_str] = ( + spent_by_unit.get(unit_str, 0) + proof["amount"] + ) + + # Create spending history for each unit + for unit, amount in spent_by_unit.items(): + await self.event_manager.publish_spending_history( + direction="out", + amount=amount, + unit=unit, + created_token_ids=new_event_ids + if unit == list(spent_by_unit.keys())[0] + else None, + destroyed_token_ids=events_to_delete + if unit == list(spent_by_unit.keys())[0] + else None, + ) except Exception as e: print(f"Warning: Failed to create spending history: {e}") @@ -1459,7 +982,7 @@ async def _mark_proofs_as_spent(self, spent_proofs: list[ProofDict]) -> None: proof_id = f"{proof['secret']}:{proof['C']}" self._cache_proof_state(proof_id, "SPENT") - async def store_proofs(self, proofs: list[ProofDict]) -> None: + async def store_proofs(self, proofs: list[Proof]) -> None: """Make sure proofs are stored on Nostr. This method ensures proofs are backed up to Nostr relays for recovery. @@ -1513,7 +1036,7 @@ async def store_proofs(self, proofs: list[ProofDict]) -> None: return # Group new proofs by mint - new_proofs_by_mint: dict[str, list[ProofDict]] = {} + new_proofs_by_mint: dict[str, list[Proof]] = {} for proof in new_proofs: mint_url = proof.get("mint", "") if mint_url: @@ -1528,8 +1051,7 @@ async def store_proofs(self, proofs: list[ProofDict]) -> None: for mint_url, mint_proofs in new_proofs_by_mint.items(): try: # Publish token event - event_manager = await self._ensure_event_manager() - event_id = await event_manager.publish_token_event(mint_proofs) + event_id = await self.event_manager.publish_token_event(mint_proofs) published_count += len(mint_proofs) # Verify event was published by fetching it @@ -1573,7 +1095,7 @@ async def store_proofs(self, proofs: list[ProofDict]) -> None: print(" Background retry tasks have been started.") async def _retry_store_proofs( - self, proofs: list[ProofDict], mint_url: str, backup_file: Path + self, proofs: list[Proof], mint_url: str, backup_file: Path ) -> None: """Background task to retry storing proofs.""" max_retries = 5 @@ -1583,8 +1105,7 @@ async def _retry_store_proofs( await asyncio.sleep(base_delay * (2**retry)) # Exponential backoff try: - event_manager = await self._ensure_event_manager() - event_id = await event_manager.publish_token_event(proofs) + event_id = await self.event_manager.publish_token_event(proofs) print(event_id) print( f"✅ Successfully published proofs for {mint_url} on retry {retry + 1}" @@ -1652,109 +1173,17 @@ async def _retry_store_proofs( print(f" Manual recovery may be needed from: {backup_file}") async def transfer_proofs( - self, proofs: list[ProofDict], target_mint: str - ) -> list[ProofDict]: - """Transfer proofs to a specific mint by converting via tokens. - - Args: - proofs: Proofs to transfer (can be from multiple source mints) - target_mint: Target mint URL to transfer to - - Returns: - New proofs at the target mint - - Raises: - WalletError: If transfer fails or insufficient balance after fees - """ - if not proofs: - return [] - - # Group proofs by source mint - proofs_by_mint: dict[str, list[ProofDict]] = {} - for proof in proofs: - source_mint = proof.get("mint") or "" - if not source_mint or source_mint == target_mint: - # Already at target mint or no mint specified, no transfer needed - continue - if source_mint not in proofs_by_mint: - proofs_by_mint[source_mint] = [] - proofs_by_mint[source_mint].append(proof) - - # If no proofs need transfer, return proofs from target mint - target_mint_proofs = [p for p in proofs if p.get("mint") == target_mint] - if not proofs_by_mint: - return target_mint_proofs - - transferred_proofs: list[ProofDict] = [] - - # Process each source mint - for source_mint, mint_proofs in proofs_by_mint.items(): - try: - # Create a temporary token from source proofs - total_amount = sum(p["amount"] for p in mint_proofs) - - # Use V4 token format for efficiency - token = self._serialize_proofs_for_token(mint_proofs, source_mint, 4) - - # Parse the token to get standardized format - parsed_mint, parsed_unit, parsed_proofs = self._parse_cashu_token(token) - - # Calculate input fees for source proofs - source_mint_instance = self._get_mint(source_mint) - input_fees = await self.calculate_total_input_fees( - source_mint_instance, mint_proofs - ) - - # The amount we can actually mint at target is total - fees - available_amount = total_amount - input_fees - if available_amount <= 0: - raise WalletError( - f"Insufficient amount after fees: {total_amount} - {input_fees} = {available_amount}" - ) - - # Calculate optimal denominations for the transfer amount - target_denoms = self._calculate_optimal_denominations(available_amount) - - # Create new proofs at target mint using swap - new_proofs = await self._create_proofs_at_mint( - target_mint, available_amount, target_denoms - ) - - # Only mark source proofs as spent AFTER the transfer succeeds - await self._mark_proofs_as_spent(mint_proofs) - - transferred_proofs.extend(new_proofs) - - # Store new proofs - await self.store_proofs(new_proofs) - - except Exception as e: - # If transfer fails, the proofs are not marked as spent yet (good!) - # Just propagate the error with more context - if "Lightning payment infrastructure" in str(e): - raise WalletError( - "Multi-mint transfers require Lightning infrastructure which is not yet implemented. " - "Your proofs are safe and not consumed." - ) from e - else: - raise WalletError( - f"Failed to transfer proofs from {source_mint}: {e}" - ) from e - - # Return both existing target mint proofs and newly transferred proofs - return target_mint_proofs + transferred_proofs - - async def rebalance_until_target(self, target_mint: str, total_needed: int) -> None: - """Rebalance until the target mint has at least the total needed.""" + self, proofs: list[Proof], target_mint: str + ) -> list[Proof]: raise NotImplementedError("Not implemented") # TODO: Implement - # mint_balances = await self.mint_balances() - # if mint_balances[target_mint] >= total_needed: - # return - # await self.transfer_balance_to_mint(total_needed, target_mint) async def _create_proofs_at_mint( - self, mint_url: str, amount: int, denominations: dict[int, int] - ) -> list[ProofDict]: + self, + mint_url: str, + amount: int, + denominations: dict[int, int], + currency: CurrencyUnit = "sat", + ) -> list[Proof]: """Create new proofs at a specific mint with given denominations. This is a simplified version that creates proofs without requiring @@ -1766,19 +1195,13 @@ async def _create_proofs_at_mint( mint = self._get_mint(mint_url) - # Get active keyset for this mint - keysets_resp = await mint.get_keysets() - keysets = keysets_resp.get("keysets", []) - active_keysets = [ - ks - for ks in keysets - if ks.get("active", True) and ks.get("unit") == self.currency - ] - - if not active_keysets: - raise WalletError(f"No active keysets found for {mint_url}") - - keyset_id = str(active_keysets[0]["id"]) + active_keysets = await mint.get_active_keysets() + keyset = next((ks for ks in active_keysets if ks.get("unit") == currency), None) + if not keyset: + raise WalletError( + f"No keyset found for currency {currency} on mint {mint.url}" + ) + keyset_id = str(keyset["id"]) # Create blinded messages for target denominations outputs: list[BlindedMessage] = [] @@ -1800,7 +1223,7 @@ async def _create_proofs_at_mint( # Create mint quote quote_resp = await mint.create_mint_quote( - unit=self.currency, + unit=currency, amount=amount, ) quote_id = quote_resp["quote"] @@ -1835,7 +1258,7 @@ async def transfer_balance_to_mint(self, amount: int, target_mint: str) -> None: raise WalletError("No proofs available from other mints for transfer") # Group by mint and calculate available balance per mint - mint_balances: dict[str, tuple[int, list[ProofDict]]] = {} + mint_balances: dict[str, tuple[int, list[Proof]]] = {} for proof in source_proofs: mint_url = proof.get("mint") or "" if not mint_url: @@ -1846,7 +1269,7 @@ async def transfer_balance_to_mint(self, amount: int, target_mint: str) -> None: mint_balances[mint_url] = (balance + proof["amount"], proofs_list + [proof]) # Calculate transfer costs and net transferable amounts - transfer_options: list[tuple[str, int, int, list[ProofDict]]] = [] + transfer_options: list[tuple[str, int, int, list[Proof]]] = [] for mint_url, (balance, mint_proofs) in mint_balances.items(): try: @@ -1883,7 +1306,7 @@ async def transfer_balance_to_mint(self, amount: int, target_mint: str) -> None: # Select proofs to transfer, starting with largest balances remaining_needed = amount - proofs_to_transfer: list[ProofDict] = [] + proofs_to_transfer: list[Proof] = [] for mint_url, gross_balance, net_transferable, mint_proofs in transfer_options: if remaining_needed <= 0: @@ -1931,83 +1354,84 @@ async def transfer_balance_to_mint(self, amount: int, target_mint: str) -> None: ) from e raise - async def summon_mint_with_balance(self, amount: int) -> str: - """Summon a mint with at least the given amount of balance.""" - state = await self.fetch_wallet_state(check_proofs=True) - total_balance = state.balance - if total_balance * 0.99 < amount: - raise WalletError( - f"Insufficient balance. Need at least {amount} {self.currency} " - f"(amount: {amount}), but have {total_balance}" - ) - mint_balances = state.mint_balances - target_mint = max(mint_balances, key=lambda k: mint_balances[k]) - if mint_balances[target_mint] < amount: - await self.rebalance_until_target(target_mint, amount) - return target_mint - - # ───────────────────────── Helper Methods ───────────────────────────────── + async def summon_mint_with_balance( + self, amount: int, unit: CurrencyUnit = "sat" + ) -> str: + """Summon a mint with at least the given amount of balance in the specified unit. - def _get_mint(self, mint_url: str) -> Mint: - """Get or create mint instance for URL.""" - if mint_url not in self.mints: - self.mints[mint_url] = Mint(mint_url, client=self.mint_client) - return self.mints[mint_url] + Args: + amount: Amount needed in the specified unit + unit: Currency unit (defaults to "sat") - async def _update_keysets_from_mint(self, mint_url: str) -> None: - """Fetch and update keysets from a mint.""" - mint = self._get_mint(mint_url) + Returns: + Mint URL with sufficient balance + Raises: + WalletError: If insufficient balance or transfer fails + """ try: - # Get all keysets from the mint - keysets_resp = await mint.get_keysets() - keysets = keysets_resp.get("keysets", []) + # Try to find a mint with sufficient balance + return await self._select_mint_for_amount(amount, unit) + except WalletError: + # No single mint has enough balance, try to consolidate + state = await self.fetch_wallet_state(check_proofs=True) - for keyset_data in keysets: - keyset_id = str(keyset_data.get("id", "")) - if not keyset_id: - continue + # Filter proofs by unit + unit_proofs = [p for p in state.proofs if p.get("unit") == unit] + total_unit_balance = sum(p["amount"] for p in unit_proofs) - # Get detailed keys for this keyset - keys_resp = await mint.get_keys(keyset_id) - keys_data = {} + # Check if we have enough total balance + if total_unit_balance < amount: + raise WalletError( + f"Insufficient {unit.upper()} balance. Need at least {amount}, " + f"but have {total_unit_balance}" + ) - for ks in keys_resp.get("keysets", []): - if str(ks.get("id")) == keyset_id: - keys_data = ks.get("keys", {}) - break + # Select a target mint (prefer one with the most balance already) + mint_balances: dict[str, int] = {} + for proof in unit_proofs: + mint_url = proof.get("mint", "") + if mint_url: + mint_balances[mint_url] = ( + mint_balances.get(mint_url, 0) + proof["amount"] + ) - # Create KeysetInfo and add to manager - # Get unit and cast to CurrencyUnit type - unit_str = keyset_data.get("unit", "sat") - unit: CurrencyUnit = cast(CurrencyUnit, unit_str) - - keyset_info = KeysetInfo( - id=keyset_id, - mint_url=mint_url, - unit=unit, - active=keyset_data.get("active", True), - input_fee_ppk=keyset_data.get("input_fee_ppk", 0), - keys=keys_data if isinstance(keys_data, dict) else {}, - ) + if not mint_balances: + raise WalletError(f"No mints found with {unit.upper()} balance") - self.keyset_manager.add_keyset(keyset_info) + target_mint = max(mint_balances, key=lambda k: mint_balances[k]) - except Exception as e: - print(f"Warning: Failed to update keysets from {mint_url}: {e}") + # Transfer balance from other mints to the target mint + await self.transfer_balance_to_mint(amount, target_mint) + + return target_mint + + # ───────────────────────── Helper Methods ───────────────────────────────── + + def _get_mint(self, mint_url: str) -> Mint: + """Get or create mint instance for URL.""" + if mint_url not in self.mints: + self.mints[mint_url] = Mint(mint_url) + return self.mints[mint_url] def _serialize_proofs_for_token( - self, proofs: list[ProofDict], mint_url: str, token_version: int + self, + proofs: list[Proof], + mint_url: str, + token_version: int, + currency: CurrencyUnit, ) -> str: """Serialize proofs into a Cashu token format (V3 or V4).""" if token_version == 3: - return self._serialize_proofs_v3(proofs, mint_url) + return self._serialize_proofs_v3(proofs, mint_url, currency) elif token_version == 4: - return self._serialize_proofs_v4(proofs, mint_url) + return self._serialize_proofs_v4(proofs, mint_url, currency) else: raise ValueError(f"Unsupported token version: {token_version}") - def _serialize_proofs_v3(self, proofs: list[ProofDict], mint_url: str) -> str: + def _serialize_proofs_v3( + self, proofs: list[Proof], mint_url: str, currency: CurrencyUnit + ) -> str: """Serialize proofs into CashuA (V3) token format.""" # Proofs are already stored with hex secrets internally token_proofs = [] @@ -2024,20 +1448,22 @@ def _serialize_proofs_v3(self, proofs: list[ProofDict], mint_url: str) -> str: # CashuA token format: cashuA token_data = { "token": [{"mint": mint_url, "proofs": token_proofs}], - "unit": self.currency or "sat", + "unit": currency, "memo": "NIP-60 wallet transfer", } json_str = json.dumps(token_data, separators=(",", ":")) encoded = base64.urlsafe_b64encode(json_str.encode()).decode().rstrip("=") return f"cashuA{encoded}" - def _serialize_proofs_v4(self, proofs: list[ProofDict], mint_url: str) -> str: + def _serialize_proofs_v4( + self, proofs: list[Proof], mint_url: str, currency: CurrencyUnit + ) -> str: """Serialize proofs into CashuB (V4) token format using CBOR.""" if cbor2 is None: raise ImportError("cbor2 library required for CashuB (V4) tokens") # Group proofs by keyset ID for V4 format - proofs_by_keyset: dict[str, list[ProofDict]] = {} + proofs_by_keyset: dict[str, list[Proof]] = {} for proof in proofs: keyset_id = proof["id"] if keyset_id not in proofs_by_keyset: @@ -2071,7 +1497,7 @@ def _serialize_proofs_v4(self, proofs: list[ProofDict], mint_url: str) -> str: # CashuB token structure token_data = { "m": mint_url, # mint URL - "u": self.currency or "sat", # unit + "u": currency, # unit "t": tokens, # tokens array } @@ -2080,9 +1506,7 @@ def _serialize_proofs_v4(self, proofs: list[ProofDict], mint_url: str) -> str: encoded = base64.urlsafe_b64encode(cbor_bytes).decode().rstrip("=") return f"cashuB{encoded}" - def _parse_cashu_token( - self, token: str - ) -> tuple[str, CurrencyUnit, list[ProofDict]]: + def _parse_cashu_token(self, token: str) -> tuple[str, CurrencyUnit, list[Proof]]: """Parse Cashu token and return (mint_url, unit, proofs).""" if not token.startswith("cashu"): raise ValueError("Invalid token format") @@ -2106,15 +1530,16 @@ def _parse_cashu_token( token_proofs = mint_info["proofs"] # Return proofs with hex secrets (standard Cashu format) - parsed_proofs: list[ProofDict] = [] + parsed_proofs: list[Proof] = [] for proof in token_proofs: parsed_proofs.append( - ProofDict( + Proof( id=proof["id"], amount=proof["amount"], secret=proof["secret"], # Already hex in Cashu tokens C=proof["C"], mint=mint_info["mint"], + unit=token_unit, ) ) @@ -2145,14 +1570,15 @@ def _parse_cashu_token( keyset_id = token_entry["i"].hex() # Convert bytes to hex for proof in token_entry["p"]: # CBOR format already has hex secret - # Convert CBOR proof format to our ProofDict format + # Convert CBOR proof format to our Proof format proofs.append( - ProofDict( + Proof( id=keyset_id, amount=proof["a"], secret=proof["s"], # Already hex in CBOR format C=proof["c"].hex(), # Convert bytes to hex mint=mint_url, + unit=cbor_unit, ) ) @@ -2163,13 +1589,12 @@ def _parse_cashu_token( def raise_if_insufficient_balance(self, balance: int, amount: int) -> None: if balance < amount: raise WalletError( - f"Insufficient balance. Need at least {amount} {self.currency} " - f"(amount: {amount}), but have {balance}" + f"Insufficient balance. Need at least {amount} sat, but have {balance} sat" ) # ───────────────────────── Proof Validation ──────────────────────────────── - def _compute_proof_y_values(self, proofs: list[ProofDict]) -> list[str]: + def _compute_proof_y_values(self, proofs: list[Proof]) -> list[str]: """Compute Y values for proofs to use in check_state API. Args: @@ -2215,12 +1640,10 @@ def clear_spent_proof_cache(self) -> None: self._proof_state_cache.clear() self._known_spent_proofs.clear() - async def _validate_proofs_with_cache( - self, proofs: list[ProofDict] - ) -> list[ProofDict]: + async def _validate_proofs_with_cache(self, proofs: list[Proof]) -> list[Proof]: """Validate proofs using cache to avoid re-checking spent proofs.""" valid_proofs = [] - proofs_to_check: list[ProofDict] = [] + proofs_to_check: list[Proof] = [] # First pass: check cache and filter out known spent proofs for proof in proofs: @@ -2272,7 +1695,7 @@ async def _validate_proofs_with_cache( async def fetch_wallet_state( self, *, check_proofs: bool = True, check_local_backups: bool = True - ) -> EnhancedWalletState: + ) -> WalletState: """Fetch wallet, token events and compute balance. Args: @@ -2302,7 +1725,7 @@ async def fetch_wallet_state( decrypted = nip44_decrypt(wallet_event["content"], self._privkey) wallet_data = json.loads(decrypted) - # Update mint URLs from wallet event (only if event contains mint URLs) + # Parse wallet event data event_mint_urls = [] for item in wallet_data: if item[0] == "mint": @@ -2310,9 +1733,13 @@ async def fetch_wallet_state( elif item[0] == "privkey": self.wallet_privkey = item[1] - # Only update mint URLs if the event actually contains some + # If wallet event contains mint URLs, use those as the source of truth + # This replaces any mint URLs from constructor or environment if event_mint_urls: - self.mint_urls.update(event_mint_urls) + # Deduplicate mint URLs from wallet event (in case they were stored with duplicates) + self.mint_urls = list(dict.fromkeys(event_mint_urls)) + # Also update the event manager with the correct mint URLs + self.event_manager.mint_urls = self.mint_urls except Exception as e: # Skip wallet event if it can't be decrypted print(f"Warning: Could not decrypt wallet event: {e}") @@ -2329,7 +1756,7 @@ async def fetch_wallet_state( deleted_ids.add(tag[1]) # Aggregate unspent proofs taking into account NIP-60 roll-overs and avoiding duplicates - all_proofs: list[ProofDict] = [] + all_proofs: list[Proof] = [] proof_to_event_id: dict[str, str] = {} # Index events newest → oldest so that when we encounter a replacement first we can ignore the ones it deletes later @@ -2340,9 +1767,6 @@ async def fetch_wallet_state( invalid_token_ids: set[str] = set(deleted_ids) proof_seen: set[str] = set() - # Track undecryptable events for potential cleanup - undecryptable_events = [] - for event in token_events_sorted: if event["id"] in invalid_token_ids: continue @@ -2352,8 +1776,6 @@ async def fetch_wallet_state( token_data = json.loads(decrypted) except Exception: # Skip this event if it can't be decrypted - likely from old key or corrupted - # print(f"Warning: Could not decrypt token event {event['id']}: {e}") - undecryptable_events.append(event["id"]) continue # Mark tokens referenced in the "del" field as superseded @@ -2361,9 +1783,6 @@ async def fetch_wallet_state( if del_ids: for old_id in del_ids: invalid_token_ids.add(old_id) - # Also remove from undecryptable list if it was there - if old_id in undecryptable_events: - undecryptable_events.remove(old_id) # Check again if this event was marked invalid by a newer event if event["id"] in invalid_token_ids: @@ -2392,12 +1811,17 @@ async def fetch_wallet_state( proof_seen.add(proof_id) # Add mint URL to proof with hex secret - proof_with_mint: ProofDict = ProofDict( + # Get unit from proof if available, otherwise default to "sat" + proof_unit = cast( + CurrencyUnit, proof.get("unit", proof.get("u", "sat")) + ) + proof_with_mint: Proof = Proof( id=proof["id"], amount=proof["amount"], secret=hex_secret, # Store as hex internally C=proof["C"], mint=mint_url, + unit=proof_unit, ) all_proofs.append(proof_with_mint) proof_to_event_id[proof_id] = event["id"] @@ -2430,13 +1854,19 @@ async def fetch_wallet_state( continue proof_seen.add(proof_id) + # Get unit from proof if available, otherwise default to "sat" + proof_unit = cast( + CurrencyUnit, proof.get("unit", proof.get("u", "sat")) + ) + # Mark pending proofs with a special event ID - pending_proof_with_mint: ProofDict = ProofDict( + pending_proof_with_mint: Proof = Proof( id=proof["id"], amount=proof["amount"], secret=hex_secret, # Store as hex internally C=proof["C"], mint=mint_url, + unit=proof_unit, ) all_proofs.append(pending_proof_with_mint) proof_to_event_id[proof_id] = "__pending__" # Special marker @@ -2463,28 +1893,6 @@ async def fetch_wallet_state( # Add back pending proofs (assume they're valid) all_proofs = validated_proofs + pending_proofs - # Fetch mint keysets - mint_keysets: dict[str, list[dict[str, str]]] = {} - for mint_url in self.mint_urls: - mint = self._get_mint(mint_url) - try: - keys_resp = await mint.get_keys() - # Convert Keyset type to dict[str, str] for wallet state - keysets_as_dicts: list[dict[str, str]] = [] - for keyset in keys_resp.get("keysets", []): - # Convert each keyset to a simple dict - keyset_dict: dict[str, str] = { - "id": keyset["id"], - "unit": keyset["unit"], - } - # Add keys if present - if "keys" in keyset and isinstance(keyset["keys"], dict): - keyset_dict.update(keyset["keys"]) - keysets_as_dicts.append(keyset_dict) - mint_keysets[mint_url] = keysets_as_dicts - except Exception: - mint_keysets[mint_url] = [] - # Check local backups for missing proofs if requested if check_local_backups: backup_dir = Path.cwd() / "proof_backups" @@ -2503,8 +1911,8 @@ async def fetch_wallet_state( if not should_check: # Skip backup check if we just did it - return await self._build_enhanced_wallet_state( - all_proofs, mint_keysets, proof_to_event_id + return WalletState( + proofs=all_proofs, proof_to_event_id=proof_to_event_id ) # Check if we have any backup files with missing proofs existing_proof_ids = set(f"{p['secret']}:{p['C']}" for p in all_proofs) @@ -2559,9 +1967,7 @@ async def fetch_wallet_state( ) await self._cleanup_spent_proof_backups() - return await self._build_enhanced_wallet_state( - all_proofs, mint_keysets, proof_to_event_id - ) + return WalletState(proofs=all_proofs, proof_to_event_id=proof_to_event_id) async def get_balance(self, *, check_proofs: bool = True) -> int: """Get current wallet balance. @@ -2604,15 +2010,15 @@ async def aclose(self) -> None: async def __aenter__(self) -> "Wallet": """Enter async context and connect to relays without auto-creating wallet events.""" # Discover relays if none are set - if not self.relays: + if not self.relay_urls: try: from .relay import get_relays_for_wallet - self.relays = await get_relays_for_wallet( + self.relay_urls = await get_relays_for_wallet( self._privkey, prompt_if_needed=True ) # Update relay manager with discovered relays - self.relay_manager.relay_urls = self.relays + self.relay_manager.relay_urls = self.relay_urls except Exception: # If relay discovery fails, continue with empty relays # This allows offline operations @@ -2631,23 +2037,8 @@ async def __aenter__(self) -> "Wallet": async def __aexit__(self, exc_type, exc, tb) -> None: # noqa: D401 (simple return) await self.aclose() - # ───────────────────────── Conversion Methods ────────────────────────────── - - # TODO: check why this method is needed - def _proofdict_to_mint_proof(self, proof_dict: ProofDict) -> Proof: - """Convert ProofDict to Proof format for mint. - - Since we store hex secrets internally, this is now a simple conversion. - """ - return Proof( - id=proof_dict["id"], - amount=proof_dict["amount"], - secret=proof_dict["secret"], # Already hex - C=proof_dict["C"], - ) - # ───────────────────────── Fee Calculation ────────────────────────────── - def calculate_input_fees(self, proofs: list[ProofDict], keyset_info: dict) -> int: + def calculate_input_fees(self, proofs: list[Proof], keyset_info: dict) -> int: """Calculate input fees based on number of proofs and keyset fee rate. Args: @@ -2676,9 +2067,7 @@ def calculate_input_fees(self, proofs: list[ProofDict], keyset_info: dict) -> in sum_fees = len(proofs) * input_fee_ppk return (sum_fees + 999) // 1000 - async def calculate_total_input_fees( - self, mint: Mint, proofs: list[ProofDict] - ) -> int: + async def calculate_total_input_fees(self, mint: Mint, proofs: list[Proof]) -> int: """Calculate total input fees for proofs across different keysets. Args: @@ -2690,11 +2079,11 @@ async def calculate_total_input_fees( """ try: # Get keyset information from mint - keysets_resp = await mint.get_keysets() + keysets = await mint.get_keysets_info() keyset_fees = {} # Build mapping of keyset_id -> input_fee_ppk - for keyset in keysets_resp["keysets"]: + for keyset in keysets: keyset_fees[keyset["id"]] = keyset.get("input_fee_ppk", 0) # Sum fees for each proof based on its keyset @@ -2719,7 +2108,7 @@ async def calculate_total_input_fees( def estimate_transaction_fees( self, - input_proofs: list[ProofDict], + input_proofs: list[Proof], keyset_info: dict, lightning_fee_reserve: int = 0, ) -> tuple[int, int]: @@ -2740,6 +2129,87 @@ def estimate_transaction_fees( # ───────────────────────── Currency Validation ──────────────────────────── + def _convert_to_base_unit(self, amount: int, unit: CurrencyUnit) -> int: + """Convert amount to the base unit (smallest denomination) for the currency. + + Args: + amount: Amount in user-friendly units (e.g., dollars for USD) + unit: Currency unit + + Returns: + Amount in base units (e.g., cents for USD, satoshis for BTC) + """ + # For fiat currencies, convert to cents (multiply by 100) + if unit in ["usd", "eur", "gbp", "cad", "chf", "aud", "jpy", "cny", "inr"]: + return amount * 100 + # For crypto, most are already in base units + elif unit in ["sat", "msat"]: + return amount + elif unit == "btc": + return amount * 100_000_000 # Convert BTC to satoshis + # For stablecoins, typically use cents as well + elif unit in ["usdt", "usdc", "dai"]: + return amount * 100 + else: + # Default to no conversion for unknown units + return amount + + def _convert_from_base_unit(self, amount: int, unit: CurrencyUnit) -> float: + """Convert amount from base unit to user-friendly units. + + Args: + amount: Amount in base units (e.g., cents for USD) + unit: Currency unit + + Returns: + Amount in user-friendly units (e.g., dollars for USD) + """ + # For fiat currencies, convert from cents (divide by 100) + if unit in ["usd", "eur", "gbp", "cad", "chf", "aud", "jpy", "cny", "inr"]: + return amount / 100 + # For crypto, most are already in user-friendly units + elif unit in ["sat", "msat"]: + return float(amount) + elif unit == "btc": + return amount / 100_000_000 # Convert satoshis to BTC + # For stablecoins, typically use cents as well + elif unit in ["usdt", "usdc", "dai"]: + return amount / 100 + else: + # Default to no conversion for unknown units + return float(amount) + + async def get_mints_supporting_unit(self, unit: CurrencyUnit) -> list[str]: + """Get list of mint URLs that support the specified currency unit. + + Args: + unit: Currency unit to check support for + + Returns: + List of mint URLs that support the unit + """ + supporting_mints = [] + + for mint_url in self.mint_urls: + try: + mint = self._get_mint(mint_url) + keysets = await mint.get_keysets_info() + + # Check if any keyset supports the requested unit + for keyset in keysets: + if keyset.get("unit") == unit: + supporting_mints.append(mint_url) + break + + except Exception as e: + # Only print error if it's not a connection error + error_msg = str(e).lower() + if "connection" not in error_msg and "timeout" not in error_msg: + print(f"Error checking mint {mint_url}: {e}") + continue + + return supporting_mints + def _validate_currency_unit(self, unit: CurrencyUnit) -> None: """Validate currency unit is supported per NUT-01. @@ -2772,292 +2242,84 @@ def _get_pubkey(self) -> str: """Get the Nostr public key for this wallet.""" return get_pubkey(self._privkey) - async def _build_enhanced_wallet_state( - self, - all_proofs: list[ProofDict], - mint_keysets: dict[str, list[dict[str, str]]], - proof_to_event_id: dict[str, str] | None, - ) -> EnhancedWalletState: - """Build enhanced wallet state from proofs and keysets.""" - # Update keyset manager with latest data from mints - for mint_url in self.mint_urls: - if not self.keyset_manager.is_cached(f"{mint_url}:check"): - await self._update_keysets_from_mint(mint_url) - # Mark as checked (simple cache mechanism) - self.keyset_manager._cache_timestamps[f"{mint_url}:check"] = time.time() - - # Calculate balance by keyset and currency - balance_by_keyset: dict[str, int] = {} - balance_by_currency: dict[CurrencyUnit, int] = {} - keysets: dict[str, KeysetInfo] = {} - - # Get all keysets from the manager - for keyset_id, keyset_info in self.keyset_manager.keysets.items(): - keysets[keyset_id] = keyset_info - - # Calculate balances - for proof in all_proofs: - keyset_id = proof["id"] - amount = proof["amount"] - - # Update keyset balance - if keyset_id not in balance_by_keyset: - balance_by_keyset[keyset_id] = 0 - balance_by_keyset[keyset_id] += amount - - # Get keyset info to determine currency - proof_keyset_info: KeysetInfo | None = self.keyset_manager.get_keyset( - keyset_id - ) - if proof_keyset_info: - currency = proof_keyset_info.unit - else: - # Fallback to wallet currency if keyset not found - currency = self.currency - - # Update currency balance - if currency not in balance_by_currency: - balance_by_currency[currency] = 0 - balance_by_currency[currency] += amount - - return EnhancedWalletState( - balance_by_keyset=balance_by_keyset, - balance_by_currency=balance_by_currency, - proofs=all_proofs, - keysets=keysets, - proof_to_event_id=proof_to_event_id, - ) + def _generate_wallet_privkey(self) -> str: + """Generate a new wallet private key for P2PK operations. - async def check_wallet_event_exists(self) -> tuple[bool, NostrEvent | None]: - """Check if a wallet event already exists for this wallet. + This should only be called when creating a new wallet event for the first time. Returns: - Tuple of (exists, wallet_event_dict) + Hex-encoded private key """ - event_manager = await self._ensure_event_manager() - return await event_manager.check_wallet_event_exists() - - async def initialize_wallet(self, *, force: bool = False) -> bool: - """Initialize wallet by checking for existing events or creating new ones. + return generate_privkey() - Args: - force: If True, create wallet event even if one already exists - - Returns: - True if wallet was initialized (new event created), False if already existed - """ - event_manager = await self._ensure_event_manager() - return await event_manager.initialize_wallet(self.wallet_privkey, force=force) - - async def delete_all_wallet_events(self) -> int: - """Delete all wallet events for this wallet. - - Returns: - Number of wallet events deleted - """ - event_manager = await self._ensure_event_manager() - return await event_manager.delete_all_wallet_events() - - async def fetch_spending_history(self) -> list[dict]: - """Fetch and decrypt spending history events. - - Returns: - List of spending history entries with metadata - """ - event_manager = await self._ensure_event_manager() - return await event_manager.fetch_spending_history() - - async def clear_spending_history(self) -> int: - """Delete all spending history events for this wallet. - - Returns: - Number of history events deleted - """ - event_manager = await self._ensure_event_manager() - return await event_manager.clear_spending_history() - - async def count_token_events(self) -> int: - """Count the number of token events for this wallet. + def _primary_mint_url(self) -> str: + """Get the primary mint URL (first one when sorted). Returns: - Number of token events found - """ - event_manager = await self._ensure_event_manager() - return await event_manager.count_token_events() - - async def clear_all_token_events(self) -> int: - """Delete all token events for this wallet. - - WARNING: This will delete your actual token storage! + Primary mint URL - Returns: - Number of token events deleted + Raises: + WalletError: If no mint URLs configured """ - event_manager = await self._ensure_event_manager() - return await event_manager.clear_all_token_events() + if not self.mint_urls: + raise WalletError("No mint URLs configured") + return sorted(self.mint_urls)[0] # Use sorted order for consistency - def _nip44_decrypt(self, content: str) -> str: - """Decrypt NIP-44 encrypted content. + async def _select_mint_for_amount( + self, amount: int, unit: CurrencyUnit, proofs: list[Proof] | None = None + ) -> str: + """Select the best mint that has sufficient balance for the given amount and unit. Args: - content: Encrypted content to decrypt + amount: Amount needed in the specified unit + unit: Currency unit + proofs: Optional list of proofs to use (if None, fetches wallet state) Returns: - Decrypted content - """ - return nip44_decrypt(content, self._privkey) - - async def cleanup_wallet_state(self, *, dry_run: bool = False) -> dict[str, int]: - """Clean up wallet state by consolidating old/undecryptable events. - - This method identifies old or corrupted token events and consolidates - all valid proofs into fresh events, marking old events as superseded. + Mint URL with sufficient balance - Args: - dry_run: If True, only report what would be cleaned up without making changes - - Returns: - Dictionary with cleanup statistics + Raises: + WalletError: If no mint has sufficient balance """ - print("🧹 Starting wallet state cleanup...") - - # Get current state - state = await self.fetch_wallet_state( - check_proofs=True, check_local_backups=False - ) - - # Fetch all events to analyze - all_events = await self.relay_manager.fetch_wallet_events( - get_pubkey(self._privkey) - ) - token_events = [e for e in all_events if e["kind"] == EventKind.Token] - - # Categorize events - valid_events = [] - undecryptable_events = [] - empty_events = [] - - for event in token_events: - try: - decrypted = nip44_decrypt(event["content"], self._privkey) - token_data = json.loads(decrypted) - proofs = token_data.get("proofs", []) - - if proofs: - valid_events.append(event["id"]) - else: - empty_events.append(event["id"]) - - except Exception: - undecryptable_events.append(event["id"]) - - stats = { - "total_events": len(token_events), - "valid_events": len(valid_events), - "undecryptable_events": len(undecryptable_events), - "empty_events": len(empty_events), - "valid_proofs": len(state.proofs), - "balance": state.balance, - "events_consolidated": 0, - "events_marked_superseded": 0, - } - - print(f"📊 Analysis: {stats['total_events']} total events") - print(f" ✅ Valid: {stats['valid_events']}") - print(f" ❌ Undecryptable: {stats['undecryptable_events']}") - print(f" 📭 Empty: {stats['empty_events']}") - print(f" 💰 Valid proofs: {stats['valid_proofs']} ({stats['balance']} sats)") - - if dry_run: - print("🔍 Dry run - no changes will be made") - return stats - - # Only consolidate if we have significant cleanup opportunity - cleanup_threshold = max( - 5, len(token_events) // 3 - ) # At least 5 events or 1/3 of total - events_to_cleanup = undecryptable_events + empty_events - - if len(events_to_cleanup) < cleanup_threshold: - print(f"🎯 No significant cleanup needed (threshold: {cleanup_threshold})") - return stats - - if not state.proofs: - print("⚠️ No valid proofs found - skipping consolidation") - return stats - - print(f"🔄 Consolidating {len(state.proofs)} proofs into fresh events...") - - # Create fresh consolidated events - new_event_ids = [] - for mint_url, mint_proofs in state.proofs_by_mints.items(): - try: - event_manager = await self._ensure_event_manager() - new_id = await event_manager.publish_token_event( - mint_proofs, - deleted_token_ids=events_to_cleanup, # Mark all old events as superseded - ) - new_event_ids.append(new_id) - stats["events_consolidated"] += 1 - print( - f" ✅ Created consolidated event for {mint_url}: {len(mint_proofs)} proofs" - ) - except Exception as e: - print(f" ❌ Failed to consolidate {mint_url}: {e}") - - if new_event_ids: - stats["events_marked_superseded"] = len(events_to_cleanup) + # Get proofs if not provided + if proofs is None: + state = await self.fetch_wallet_state(check_proofs=True) + proofs = state.proofs - # Try to delete old events (best effort) - deleted_count = 0 - for event_id in events_to_cleanup: - try: - event_manager = await self._ensure_event_manager() - await event_manager.delete_token_event(event_id) - deleted_count += 1 - except Exception: - # Deletion not supported - that's okay, 'del' field handles it - pass + # Filter proofs by the requested currency unit + unit_proofs = [p for p in proofs if p.get("unit") == unit] - if deleted_count > 0: - print(f" 🗑️ Successfully deleted {deleted_count} old events") - else: - print(" 📝 Old events marked as superseded via 'del' field") + if not unit_proofs: + raise WalletError(f"No {unit.upper()} balance available") - # Create consolidation history - try: - event_manager = await self._ensure_event_manager() - await event_manager.publish_spending_history( - direction="in", # Consolidation is like receiving all proofs again - amount=0, # No net change in balance - created_token_ids=new_event_ids, - destroyed_token_ids=events_to_cleanup, + # Calculate balances per mint for this unit + mint_unit_balances: dict[str, int] = {} + for proof in unit_proofs: + proof_mint = proof.get("mint", "") + if proof_mint: + mint_unit_balances[proof_mint] = ( + mint_unit_balances.get(proof_mint, 0) + proof["amount"] ) - print(" 📋 Created consolidation history") - except Exception as e: - print(f" ⚠️ Could not create history: {e}") - print( - f"🎉 Cleanup complete! Consolidated {stats['events_consolidated']} events" - ) - return stats - - def _primary_mint_url(self) -> str: - """Get the primary mint URL (first one when sorted). + # Find mints with sufficient balance + suitable_mints = [ + m for m, balance in mint_unit_balances.items() if balance >= amount + ] - Returns: - Primary mint URL + if not suitable_mints: + total_unit_balance = sum(p["amount"] for p in unit_proofs) + mint_details = ", ".join( + f"{m}: {b}" for m, b in sorted(mint_unit_balances.items()) + ) + raise WalletError( + f"Insufficient {unit.upper()} balance: need {amount}, have {total_unit_balance} " + f"(distributed across mints: {mint_details})" + ) - Raises: - WalletError: If no mint URLs configured - """ - if not self.mint_urls: - raise WalletError("No mint URLs configured") - return sorted(self.mint_urls)[0] # Use sorted order for consistency + # Select the mint with the highest balance of this unit + return max(suitable_mints, key=lambda m: mint_unit_balances[m]) - def _sort_proofs_by_mint( - self, proofs: list[ProofDict] - ) -> dict[str, list[ProofDict]]: + def _sort_proofs_by_mint(self, proofs: list[Proof]) -> dict[str, list[Proof]]: return { mint_url: [proof for proof in proofs if proof["mint"] == mint_url] for mint_url in set(proof["mint"] for proof in proofs) @@ -3169,8 +2431,8 @@ async def scan_and_recover_local_proofs( # Scan all backup files backup_files = list(backup_dir.glob("proofs_*.json")) - all_backup_proofs: dict[str, ProofDict] = {} # proof_id -> proof - backup_proofs_by_mint: dict[str, list[ProofDict]] = {} + all_backup_proofs: dict[str, Proof] = {} # proof_id -> proof + backup_proofs_by_mint: dict[str, list[Proof]] = {} for backup_file in backup_files: try: @@ -3195,7 +2457,7 @@ async def scan_and_recover_local_proofs( # Find missing proofs missing_proof_ids = set(all_backup_proofs.keys()) - existing_proofs - missing_proofs: list[ProofDict] = [] + missing_proofs: list[Proof] = [] for proof_id in missing_proof_ids: missing_proofs.append(all_backup_proofs[proof_id]) @@ -3240,7 +2502,7 @@ async def scan_and_recover_local_proofs( return stats # Group valid missing proofs by mint - missing_by_mint: dict[str, list[ProofDict]] = {} + missing_by_mint: dict[str, list[Proof]] = {} for proof in valid_missing_proofs: mint_url = proof.get("mint", "") if mint_url: @@ -3253,19 +2515,30 @@ async def scan_and_recover_local_proofs( for mint_url, mint_proofs in missing_by_mint.items(): try: - event_manager = await self._ensure_event_manager() - event_id = await event_manager.publish_token_event(mint_proofs) + event_id = await self.event_manager.publish_token_event(mint_proofs) stats["recovered"] += len(mint_proofs) print(f" ✅ Published {len(mint_proofs)} proofs for {mint_url}") print(f" Event ID: {event_id}") - # Also create spending history for recovery - total_amount = sum(p["amount"] for p in mint_proofs) - await event_manager.publish_spending_history( - direction="in", - amount=total_amount, - created_token_ids=[event_id], - ) + # Also create spending history for recovery (group by unit) + # Group recovered proofs by unit + recovered_by_unit: dict[str, int] = {} + for proof in mint_proofs: + unit_str = str(proof.get("unit", "sat")) + recovered_by_unit[unit_str] = ( + recovered_by_unit.get(unit_str, 0) + proof["amount"] + ) + + # Create spending history for each unit + for recovery_unit, recovery_amount in recovered_by_unit.items(): + await self.event_manager.publish_spending_history( + direction="in", + amount=recovery_amount, + unit=recovery_unit, + created_token_ids=[event_id] + if recovery_unit == list(recovered_by_unit.keys())[0] + else None, + ) except Exception as e: print(f" ❌ Failed to publish proofs for {mint_url}: {e}") From bc84d4201ba32b75192a8bfb87450e67cb702121 Mon Sep 17 00:00:00 2001 From: Shroominic Date: Mon, 14 Jul 2025 15:52:58 -0300 Subject: [PATCH 05/17] fix examples --- examples/multi_mint_operations.py | 7 +++++-- examples/queued_relay_demo.py | 4 ++-- examples/recovery_tool.py | 17 ++++++++++++----- examples/refresh_proofs.py | 13 +++++++++---- 4 files changed, 28 insertions(+), 13 deletions(-) diff --git a/examples/multi_mint_operations.py b/examples/multi_mint_operations.py index 2d485f7..5f5e945 100644 --- a/examples/multi_mint_operations.py +++ b/examples/multi_mint_operations.py @@ -48,11 +48,14 @@ async def add_new_mint(wallet: Wallet, mint_url: str): print(f"\n➕ Adding new mint: {mint_url}") if mint_url not in wallet.mint_urls: - wallet.mint_urls.add(mint_url) + wallet.mint_urls.append(mint_url) # Update wallet event with new mint try: - await wallet.initialize_wallet(force=True) + # Need to provide wallet_privkey to update the wallet event + # For this example, we'll use a placeholder key + wallet_privkey = "placeholder_wallet_privkey" + await wallet.event_manager.update_wallet_event(wallet_privkey) print("✅ Mint added and wallet updated") except Exception as e: print(f"⚠️ Mint added locally but failed to update wallet event: {e}") diff --git a/examples/queued_relay_demo.py b/examples/queued_relay_demo.py index 0ee0695..39de789 100644 --- a/examples/queued_relay_demo.py +++ b/examples/queued_relay_demo.py @@ -57,8 +57,8 @@ async def demonstrate_queued_operations(wallet: Wallet): async def show_relay_status(wallet: Wallet): """Show current relay configuration and status.""" print("\n🌐 Relay Configuration:") - print(f" Configured relays: {len(wallet.relays)}") - for i, relay in enumerate(wallet.relays, 1): + print(f" Configured relays: {len(wallet.relay_urls)}") + for i, relay in enumerate(wallet.relay_urls, 1): print(f" {i}. {relay}") print( diff --git a/examples/recovery_tool.py b/examples/recovery_tool.py index 9ae83fb..9b9eff7 100644 --- a/examples/recovery_tool.py +++ b/examples/recovery_tool.py @@ -20,10 +20,17 @@ async def demonstrate_recovery(nsec: str): async with Wallet(nsec=nsec) as wallet: print("✅ Wallet created and connected to relays") - # Check if wallet events exist - exists, wallet_event = await wallet.check_wallet_event_exists() + # Fetch all wallet events to check if configuration exists + from sixty_nuts.relay import EventKind + from sixty_nuts.crypto import get_pubkey - if exists and wallet_event: + pubkey = get_pubkey(wallet._privkey) + all_events = await wallet.relay_manager.fetch_wallet_events(pubkey) + wallet_events = [e for e in all_events if e["kind"] == EventKind.Wallet] + + if wallet_events: + # Get the newest wallet event + wallet_event = max(wallet_events, key=lambda e: e["created_at"]) from datetime import datetime created_time = datetime.fromtimestamp(wallet_event["created_at"]) @@ -38,8 +45,8 @@ async def demonstrate_recovery(nsec: str): for i, mint_url in enumerate(wallet.mint_urls, 1): print(f" {i}. {mint_url}") - print(f" Relays: {len(wallet.relays)}") - for i, relay_url in enumerate(wallet.relays, 1): + print(f" Relays: {len(wallet.relay_urls)}") + for i, relay_url in enumerate(wallet.relay_urls, 1): print(f" {i}. {relay_url}") # Show recovered balance and proofs diff --git a/examples/refresh_proofs.py b/examples/refresh_proofs.py index 1456eec..1b91e53 100755 --- a/examples/refresh_proofs.py +++ b/examples/refresh_proofs.py @@ -20,20 +20,25 @@ async def refresh_proofs(wallet: Wallet): print(f"Found {len(state.proofs)} proofs worth {state.balance} sats") - print(f"Refreshing proofs at {len(state.proofs_by_mints)} mint(s)...") + print(f"Refreshing proofs at {len(state.proofs_by_mint)} mint(s)...") - for mint_url, mint_proofs in state.proofs_by_mints.items(): + for mint_url, mint_proofs in state.proofs_by_mint.items(): mint_balance = sum(p["amount"] for p in mint_proofs) print(f"\n📍 Processing {len(mint_proofs)} proofs at {mint_url}") print(f" Balance: {mint_balance} sats") try: + # Get currency unit from the first proof + currency = mint_proofs[0].get("unit", "sat") + # Calculate optimal denominations for consolidation - optimal_denoms = wallet._calculate_optimal_denominations(mint_balance) + optimal_denoms = await wallet._calculate_optimal_denominations( + mint_balance, mint_url, currency + ) # Swap proofs for optimal denominations new_proofs = await wallet._swap_proof_denominations( - mint_proofs, optimal_denoms, mint_url + mint_proofs, optimal_denoms, mint_url, currency ) print(f" ✅ Refreshed to {len(new_proofs)} optimized proofs") From 3e23ebf707bfd54775c502f3aa4e733d1b62746c Mon Sep 17 00:00:00 2001 From: Shroominic Date: Mon, 14 Jul 2025 15:54:21 -0300 Subject: [PATCH 06/17] fix types --- tests/unit/test_wallet.py | 34 +++++++++++++++++----------------- 1 file changed, 17 insertions(+), 17 deletions(-) diff --git a/tests/unit/test_wallet.py b/tests/unit/test_wallet.py index ae085d0..f0c2b7e 100644 --- a/tests/unit/test_wallet.py +++ b/tests/unit/test_wallet.py @@ -12,7 +12,7 @@ class TestWalletFeeCalculation: """Test fee calculation logic.""" - def test_fee_calculation_empty_proofs(self): + def test_fee_calculation_empty_proofs(self) -> None: """Test fee calculation with empty proofs.""" wallet = Wallet( nsec=generate_privkey(), @@ -23,7 +23,7 @@ def test_fee_calculation_empty_proofs(self): fees = wallet.calculate_input_fees([], {"input_fee_ppk": 1000}) assert fees == 0 - def test_fee_calculation_with_proofs(self): + def test_fee_calculation_with_proofs(self) -> None: """Test fee calculation with mock proofs.""" wallet = Wallet( nsec=generate_privkey(), @@ -60,7 +60,7 @@ def test_fee_calculation_with_proofs(self): fees_no_fee = wallet.calculate_input_fees(mock_proofs, keyset_info_no_fee) assert fees_no_fee == 0 - def test_fee_calculation_fractional(self): + def test_fee_calculation_fractional(self) -> None: """Test fee calculation with fractional fees.""" wallet = Wallet( nsec=generate_privkey(), @@ -91,7 +91,7 @@ def test_fee_calculation_fractional(self): fees = wallet.calculate_input_fees(mock_proofs, keyset_info) assert fees == 2 # Should round up to 2 - def test_estimate_transaction_fees(self): + def test_estimate_transaction_fees(self) -> None: """Test transaction fee estimation.""" wallet = Wallet( nsec=generate_privkey(), @@ -132,7 +132,7 @@ def test_estimate_transaction_fees(self): class TestWalletTokenSerialization: """Test token serialization and parsing logic.""" - def test_token_serialization_v3(self): + def test_token_serialization_v3(self) -> None: """Test V3 token serialization.""" wallet = Wallet( nsec=generate_privkey(), @@ -154,7 +154,7 @@ def test_token_serialization_v3(self): token_v3 = wallet._serialize_proofs_v3(sample_proofs, "http://test.mint", "sat") assert token_v3.startswith("cashuA"), "V3 tokens should start with cashuA" - def test_token_serialization_v4(self): + def test_token_serialization_v4(self) -> None: """Test V4 token serialization.""" wallet = Wallet( nsec=generate_privkey(), @@ -176,7 +176,7 @@ def test_token_serialization_v4(self): token_v4 = wallet._serialize_proofs_v4(sample_proofs, "http://test.mint", "sat") assert token_v4.startswith("cashuB"), "V4 tokens should start with cashuB" - def test_token_roundtrip_v3(self): + def test_token_roundtrip_v3(self) -> None: """Test V3 token serialization and parsing roundtrip.""" wallet = Wallet( nsec=generate_privkey(), @@ -206,7 +206,7 @@ def test_token_roundtrip_v3(self): assert len(parsed_proofs) == 1 assert parsed_proofs[0]["amount"] == 10 - def test_token_roundtrip_v4(self): + def test_token_roundtrip_v4(self) -> None: """Test V4 token serialization and parsing roundtrip.""" wallet = Wallet( nsec=generate_privkey(), @@ -236,7 +236,7 @@ def test_token_roundtrip_v4(self): assert len(parsed_proofs) == 1 assert parsed_proofs[0]["amount"] == 10 - def test_parse_invalid_tokens(self): + def test_parse_invalid_tokens(self) -> None: """Test parsing invalid tokens raises appropriate errors.""" wallet = Wallet( nsec=generate_privkey(), @@ -257,7 +257,7 @@ def test_parse_invalid_tokens(self): class TestWalletVersionValidation: """Test version validation logic.""" - def test_send_token_invalid_version(self): + def test_send_token_invalid_version(self) -> None: """Test error handling for invalid token versions.""" wallet = Wallet( nsec=generate_privkey(), @@ -277,7 +277,7 @@ def test_send_token_invalid_version(self): class TestWalletCurrencyValidation: """Test currency unit validation.""" - def test_valid_currency_units(self): + def test_valid_currency_units(self) -> None: """Test that valid currency units are accepted.""" from sixty_nuts.types import CurrencyUnit @@ -291,7 +291,7 @@ def test_valid_currency_units(self): ) wallet._validate_currency_unit(unit) - def test_invalid_currency_unit(self): + def test_invalid_currency_unit(self) -> None: """Test invalid currency unit handling.""" wallet = Wallet( nsec=generate_privkey(), @@ -307,7 +307,7 @@ class TestWalletOptimalDenominations: """Test optimal denomination calculation logic.""" @pytest.mark.asyncio - async def test_calculate_optimal_denominations_small(self): + async def test_calculate_optimal_denominations_small(self) -> None: wallet = Wallet( nsec=generate_privkey(), mint_urls=["http://test.mint"], @@ -339,7 +339,7 @@ async def test_calculate_optimal_denominations_small(self): assert denoms == {4: 1, 2: 1, 1: 1} @pytest.mark.asyncio - async def test_calculate_optimal_denominations_large(self): + async def test_calculate_optimal_denominations_large(self) -> None: wallet = Wallet( nsec=generate_privkey(), mint_urls=["http://test.mint"], @@ -365,7 +365,7 @@ async def test_calculate_optimal_denominations_large(self): assert total == 1000 @pytest.mark.asyncio - async def test_calculate_optimal_denominations_zero(self): + async def test_calculate_optimal_denominations_zero(self) -> None: wallet = Wallet( nsec=generate_privkey(), mint_urls=["http://test.mint"], @@ -390,7 +390,7 @@ async def test_calculate_optimal_denominations_zero(self): class TestWalletInsufficientBalanceCheck: """Test insufficient balance validation logic.""" - def test_raise_if_insufficient_balance_sufficient(self): + def test_raise_if_insufficient_balance_sufficient(self) -> None: """Test that sufficient balance doesn't raise error.""" wallet = Wallet( nsec=generate_privkey(), @@ -402,7 +402,7 @@ def test_raise_if_insufficient_balance_sufficient(self): wallet.raise_if_insufficient_balance(100, 50) wallet.raise_if_insufficient_balance(100, 100) - def test_raise_if_insufficient_balance_insufficient(self): + def test_raise_if_insufficient_balance_insufficient(self) -> None: """Test that insufficient balance raises WalletError.""" from sixty_nuts.types import WalletError From a5b2bb4387b67e8c29a9d356548aef6d31a51873 Mon Sep 17 00:00:00 2001 From: Shroominic Date: Thu, 17 Jul 2025 15:19:34 -0300 Subject: [PATCH 07/17] fix typing --- sixty_nuts/cli.py | 42 ++++++++++++++++++++++++---------------- sixty_nuts/wallet.py | 16 +++++++++++---- tests/unit/test_nut02.py | 12 ++++++++---- 3 files changed, 45 insertions(+), 25 deletions(-) diff --git a/sixty_nuts/cli.py b/sixty_nuts/cli.py index 6b2de47..9ebeb9a 100644 --- a/sixty_nuts/cli.py +++ b/sixty_nuts/cli.py @@ -1891,10 +1891,10 @@ async def _relays(): for i, relay_url in enumerate(test_relays, 1): console.print(f" {i}. Testing {relay_url}...") try: - relay = Relay(relay_url) - await relay.connect() + relay_client = Relay(relay_url) + await relay_client.connect() console.print(" [green]✅ Connected successfully[/green]") - await relay.disconnect() + await relay_client.disconnect() except Exception as e: console.print(f" [red]❌ Failed: {e}[/red]") @@ -2425,7 +2425,7 @@ def cleanup( superseded, making it work even on relays that don't support deletion events. """ - async def _cleanup(): + async def _cleanup() -> dict | None: try: nsec = get_nsec() # Use create_wallet_with_mint_selection for automatic mint discovery and selection @@ -2479,7 +2479,7 @@ async def _cleanup(): if confirm_cleanup != "yes": console.print("[yellow]❌ Cleanup cancelled[/yellow]") - return + return None # Perform cleanup print("🧹 Starting wallet state cleanup...") @@ -2514,7 +2514,7 @@ async def _cleanup(): except Exception: undecryptable_events.append(event["id"]) - stats = { + stats: dict = { "total_events": len(token_events), "valid_events": len(valid_events), "undecryptable_events": len(undecryptable_events), @@ -2652,8 +2652,9 @@ async def _cleanup(): # Show balance by currency console.print(" Balance by currency:") - for currency, balance in sorted(stats["balance_by_unit"].items()): - if currency in [ + balance_by_unit = cast(dict[str, int], stats["balance_by_unit"]) + for currency_str, balance in sorted(balance_by_unit.items()): + if currency_str in [ "usd", "eur", "gbp", @@ -2669,10 +2670,12 @@ async def _cleanup(): ]: display_balance = balance / 100 console.print( - f" {currency.upper()}: {display_balance:.2f} {currency}" + f" {currency_str.upper()}: {display_balance:.2f} {currency_str}" ) else: - console.print(f" {currency.upper()}: {balance} {currency}") + console.print( + f" {currency_str.upper()}: {balance} {currency_str}" + ) if not dry_run: console.print( @@ -2697,13 +2700,18 @@ async def _cleanup(): console.print( "\n[cyan]ℹ️ This was a dry run - no changes were made.[/cyan]" ) - if stats["undecryptable_events"] + stats["empty_events"] >= 5: + if ( + cast(int, stats["undecryptable_events"]) + + cast(int, stats["empty_events"]) + >= 5 + ): console.print( "[yellow]Run without --dry-run to perform actual cleanup.[/yellow]" ) except Exception as e: handle_wallet_error(e) + return None asyncio.run(_cleanup()) @@ -2735,7 +2743,7 @@ def backup( Use --scan to check for missing proofs and --recover to restore them. """ - async def _backup(): + async def _backup() -> None: nsec = get_nsec() async with await create_wallet_with_mint_selection( nsec, mint_urls=mint_urls @@ -2778,7 +2786,7 @@ async def _backup(): with open(bf, "r") as f: data = json.load(f) - proof_count = len(data.get("proofs", [])) + proof_count: int | str = len(data.get("proofs", [])) except Exception: proof_count = "?" @@ -3256,7 +3264,7 @@ async def _debug_nostr_relays(wallet_obj: Wallet) -> None: except Exception as e: console.print(f" {relay_conn.url}: ❌ Error: {e}") - async def _debug_balance_proofs(wallet_obj): + async def _debug_balance_proofs(wallet_obj: Wallet) -> None: """Debug balance calculation and proof validation.""" console.print("\n[yellow]💰 Balance & Proof Validation[/yellow]") @@ -3364,7 +3372,7 @@ async def _debug_balance_proofs(wallet_obj): console.print("\n Proof Breakdown by Mint and Currency:") # Group proofs by mint and currency - proofs_by_mint_currency: dict[str, dict[str, list]] = {} + proofs_by_mint_currency: dict[str, dict[CurrencyUnit, list]] = {} for proof in state_validated.proofs: mint_url = proof.get("mint", "unknown") currency = proof.get("unit", "sat") @@ -3382,11 +3390,11 @@ async def _debug_balance_proofs(wallet_obj): ) console.print(f" {mint_display}:") - for currency, proofs in sorted(currency_proofs.items()): + for currency, proofs in sorted(currency_proofs.items()): # type: ignore balance = sum(p["amount"] for p in proofs) # Group by denomination - denominations = {} + denominations: dict[int, int] = {} for proof in proofs: amount = proof["amount"] denominations[amount] = denominations.get(amount, 0) + 1 diff --git a/sixty_nuts/wallet.py b/sixty_nuts/wallet.py index 1326ccc..9648301 100644 --- a/sixty_nuts/wallet.py +++ b/sixty_nuts/wallet.py @@ -1969,7 +1969,9 @@ async def fetch_wallet_state( return WalletState(proofs=all_proofs, proof_to_event_id=proof_to_event_id) - async def get_balance(self, *, check_proofs: bool = True) -> int: + async def get_balance( + self, unit: CurrencyUnit | None = None, *, check_proofs: bool = True + ) -> int: """Get current wallet balance. Args: @@ -1985,7 +1987,11 @@ async def get_balance(self, *, check_proofs: bool = True) -> int: state = await self.fetch_wallet_state( check_proofs=check_proofs, check_local_backups=True ) - return state.balance + if unit: + if unit not in state.balance_by_unit: + raise WalletError(f"Unsupported currency unit: {unit}") + return state.balance_by_unit[unit] + return state.total_balance_sat async def get_balance_by_mint(self, mint_url: str) -> int: """Get balance for a specific mint.""" @@ -2007,7 +2013,7 @@ async def aclose(self) -> None: # ───────────────────────── Async context manager ────────────────────────── - async def __aenter__(self) -> "Wallet": + async def __aenter__(self) -> Wallet: """Enter async context and connect to relays without auto-creating wallet events.""" # Discover relays if none are set if not self.relay_urls: @@ -2290,7 +2296,9 @@ async def _select_mint_for_amount( unit_proofs = [p for p in proofs if p.get("unit") == unit] if not unit_proofs: - raise WalletError(f"No {unit.upper()} balance available") + raise WalletError( + f"Insufficient {unit.upper()} balance: No {unit.upper()} balance available" + ) # Calculate balances per mint for this unit mint_unit_balances: dict[str, int] = {} diff --git a/tests/unit/test_nut02.py b/tests/unit/test_nut02.py index 0991692..69d82d6 100644 --- a/tests/unit/test_nut02.py +++ b/tests/unit/test_nut02.py @@ -3,7 +3,7 @@ import pytest from unittest.mock import AsyncMock, Mock -from typing import cast +from typing import cast, Any from sixty_nuts.crypto import derive_keyset_id, validate_keyset_id from sixty_nuts.mint import Mint @@ -357,7 +357,7 @@ def test_validate_keysets_response_invalid(self): mint = Mint("https://test.mint") # Missing keysets field - response1 = {} + response1: dict[str, Any] = {} assert not mint.validate_keysets_response(response1) # Invalid keyset in list @@ -387,7 +387,9 @@ async def test_get_validated_keysets_success(self): } ] } - mint.get_keysets_info = AsyncMock(return_value=mock_response["keysets"]) + setattr( + mint, "get_keysets_info", AsyncMock(return_value=mock_response["keysets"]) + ) result = await mint.get_keysets_info() assert mint.validate_keysets_response(mock_response) @@ -397,7 +399,9 @@ async def test_get_validated_keysets_failure(self): mint = Mint("https://test.mint") mock_response = {"keysets": [{"id": "invalid", "unit": "sat", "active": True}]} - mint.get_keysets_info = AsyncMock(return_value=mock_response["keysets"]) + setattr( + mint, "get_keysets_info", AsyncMock(return_value=mock_response["keysets"]) + ) result = await mint.get_keysets_info() assert not mint.validate_keysets_response(mock_response) From c7b7ffdc9915ca45fe0049c5b933a78f3f8c8211 Mon Sep 17 00:00:00 2001 From: Shroominic Date: Thu, 17 Jul 2025 16:06:56 -0300 Subject: [PATCH 08/17] fix tests --- tests/integration/conftest.py | 72 +- .../test_advanced_wallet_operations.py | 158 ++-- tests/integration/test_lnurl_requests.py | 79 +- tests/integration/test_mint_methods.py | 281 +++--- .../test_multi_currency_support.py | 517 +++++++++++ tests/integration/test_relay_lookup.py | 829 ------------------ .../integration/test_wallet_complete_flow.py | 264 +++--- 7 files changed, 973 insertions(+), 1227 deletions(-) create mode 100644 tests/integration/test_multi_currency_support.py delete mode 100644 tests/integration/test_relay_lookup.py diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py index 0c17773..e015ea2 100644 --- a/tests/integration/conftest.py +++ b/tests/integration/conftest.py @@ -11,6 +11,8 @@ import pytest import shutil from pathlib import Path +from typing import AsyncGenerator, Any, Callable, Awaitable +from collections.abc import Generator from sixty_nuts.wallet import Wallet from sixty_nuts.crypto import generate_privkey @@ -40,13 +42,13 @@ def get_relay_wait_time(base_seconds: float = 2.0) -> float: @pytest.fixture -def test_nsec(): +def test_nsec() -> str: """Generate a test nostr private key.""" return generate_privkey() @pytest.fixture -def test_mint_urls(): +def test_mint_urls() -> list[str]: """Test mint URLs for integration tests. Uses local Docker mint when USE_LOCAL_SERVICES is set, @@ -62,7 +64,7 @@ def test_mint_urls(): @pytest.fixture -def test_relays(): +def test_relays() -> list[str]: """Test relay URLs for integration tests. Uses local Docker relay when USE_LOCAL_SERVICES is set, @@ -78,14 +80,14 @@ def test_relays(): @pytest.fixture(autouse=True) -def clean_proof_backups(): +def clean_proof_backups() -> Generator[None, None, None]: """Clean proof backups before each test to ensure isolated state. This prevents tests from recovering proofs from previous runs, which would cause unexpected non-zero balances. """ - backup_dir = Path.cwd() / "proof_backups" - test_backup_dir = Path.cwd() / "test_proof_backups" + backup_dir: Path = Path.cwd() / "proof_backups" + test_backup_dir: Path = Path.cwd() / "test_proof_backups" # Move existing backups to temporary location if they exist if backup_dir.exists(): @@ -103,7 +105,9 @@ def clean_proof_backups(): @pytest.fixture -async def wallet(test_nsec, test_mint_urls, test_relays): +async def wallet( + test_nsec: str, test_mint_urls: list[str], test_relays: list[str] +) -> AsyncGenerator[Wallet, None]: """Create a test wallet instance with controlled mint configuration. This fixture ensures that: @@ -112,7 +116,7 @@ async def wallet(test_nsec, test_mint_urls, test_relays): 3. Resources are cleaned up after test """ # Store original environment to restore later - original_env_mints = os.environ.get("CASHU_MINTS") + original_env_mints: str | None = os.environ.get("CASHU_MINTS") # Clear environment mints to prevent interference if "CASHU_MINTS" in os.environ: @@ -120,38 +124,36 @@ async def wallet(test_nsec, test_mint_urls, test_relays): try: # Create wallet with explicit test configuration - wallet = await Wallet.create( + wallet_instance: Wallet = await Wallet.create( nsec=test_nsec, mint_urls=test_mint_urls, - currency="sat", - relays=test_relays, + relay_urls=test_relays, auto_init=False, # Don't auto-initialize to avoid conflicts ) # Force wallet to use ONLY our test mints # This overrides any mints from environment or wallet events - wallet.mint_urls = set(test_mint_urls) - - # Re-initialize event manager with controlled mint set - await wallet._initialize_event_manager() + wallet_instance.mint_urls = list(test_mint_urls) # Override wallet methods to disable local backup checking during tests - original_fetch_wallet_state = wallet.fetch_wallet_state + original_fetch_wallet_state: Callable[..., Awaitable[Any]] = ( + wallet_instance.fetch_wallet_state + ) - async def fetch_wallet_state_no_backups(**kwargs): + async def fetch_wallet_state_no_backups(**kwargs: Any) -> Any: kwargs["check_local_backups"] = False return await original_fetch_wallet_state(**kwargs) - wallet.fetch_wallet_state = fetch_wallet_state_no_backups + setattr(wallet_instance, "fetch_wallet_state", fetch_wallet_state_no_backups) # Initialize wallet explicitly - await wallet.initialize_wallet(force=True) + # await wallet_instance.event_manager.initialize_wallet(force=True) - yield wallet + yield wallet_instance finally: # Cleanup - await wallet.aclose() + await wallet_instance.aclose() # Restore original environment if original_env_mints is not None: @@ -159,14 +161,16 @@ async def fetch_wallet_state_no_backups(**kwargs): @pytest.fixture -async def clean_wallet(test_nsec, test_mint_urls, test_relays): +async def clean_wallet( + test_nsec: str, test_mint_urls: list[str], test_relays: list[str] +) -> AsyncGenerator[Wallet, None]: """Create a clean test wallet instance without initialization. This fixture is for tests that need to control wallet initialization themselves or test initialization behavior. """ # Store original environment to restore later - original_env_mints = os.environ.get("CASHU_MINTS") + original_env_mints: str | None = os.environ.get("CASHU_MINTS") # Clear environment mints to prevent interference if "CASHU_MINTS" in os.environ: @@ -174,34 +178,32 @@ async def clean_wallet(test_nsec, test_mint_urls, test_relays): try: # Create wallet with explicit test configuration - wallet = await Wallet.create( + wallet_instance: Wallet = await Wallet.create( nsec=test_nsec, mint_urls=test_mint_urls, - currency="sat", - relays=test_relays, + relay_urls=test_relays, auto_init=False, ) # Force wallet to use ONLY our test mints - wallet.mint_urls = set(test_mint_urls) - - # Re-initialize event manager with controlled mint set - await wallet._initialize_event_manager() + wallet_instance.mint_urls = list(test_mint_urls) # Override wallet methods to disable local backup checking during tests - original_fetch_wallet_state = wallet.fetch_wallet_state + original_fetch_wallet_state: Callable[..., Awaitable[Any]] = ( + wallet_instance.fetch_wallet_state + ) - async def fetch_wallet_state_no_backups(**kwargs): + async def fetch_wallet_state_no_backups(**kwargs: Any) -> Any: kwargs["check_local_backups"] = False return await original_fetch_wallet_state(**kwargs) - wallet.fetch_wallet_state = fetch_wallet_state_no_backups + setattr(wallet_instance, "fetch_wallet_state", fetch_wallet_state_no_backups) - yield wallet + yield wallet_instance finally: # Cleanup - await wallet.aclose() + await wallet_instance.aclose() # Restore original environment if original_env_mints is not None: diff --git a/tests/integration/test_advanced_wallet_operations.py b/tests/integration/test_advanced_wallet_operations.py index 5a47b9b..35ba0ea 100644 --- a/tests/integration/test_advanced_wallet_operations.py +++ b/tests/integration/test_advanced_wallet_operations.py @@ -5,11 +5,14 @@ Only runs when RUN_INTEGRATION_TESTS environment variable is set. """ -import asyncio import os -import pytest +import asyncio +from typing import Any, cast from collections import defaultdict +import pytest + +from sixty_nuts.wallet import Wallet # Skip all integration tests unless explicitly enabled @@ -34,7 +37,9 @@ def get_relay_wait_time(base_seconds: float = 1.0) -> float: class TestAdvancedWalletOperations: """Advanced integration tests for wallet operations with comprehensive validation.""" - async def test_comprehensive_transaction_flow_with_validation(self, wallet): + async def test_comprehensive_transaction_flow_with_validation( + self, wallet: Wallet + ) -> None: """Advanced test: multiple transactions with precise tracking of balance, fees, denominations, relay events, and proof management.""" @@ -56,13 +61,13 @@ async def test_comprehensive_transaction_flow_with_validation(self, wallet): print(f"Warning: Could not get relay connections: {e}") # Validate initial empty state - initial_balance = await wallet.get_balance(check_proofs=False) + initial_balance: int = await wallet.get_balance(check_proofs=False) assert initial_balance == 0, ( f"Expected empty wallet, got {initial_balance} sats" ) try: - initial_token_events = await wallet.count_token_events() + initial_token_events: int = await wallet.event_manager.count_token_events() print(f"Initial token events: {initial_token_events}") except Exception as e: print(f"Warning: Could not count token events: {e}") @@ -73,7 +78,7 @@ async def test_comprehensive_transaction_flow_with_validation(self, wallet): assert initial_state.balance == 0, "Expected zero balance initially" # Track metrics throughout the test - metrics = { + metrics: dict[str, Any] = { "total_minted": 0, "total_sent": 0, "total_redeemed": 0, @@ -90,34 +95,43 @@ async def test_comprehensive_transaction_flow_with_validation(self, wallet): print("\n💰 Phase 2: Multiple minting operations") - mint_amounts = [100, 50, 25, 200, 1] # Diverse amounts for denomination testing + mint_amounts: list[int] = [ + 100, + 50, + 25, + 200, + 1, + ] # Diverse amounts for denomination testing for i, amount in enumerate(mint_amounts): print(f"\n Minting {amount} sats (operation {i + 1}/{len(mint_amounts)})") - balance_before = await wallet.get_balance() + balance_before: int = await wallet.get_balance() try: - events_before = await wallet.count_token_events() + events_before: int = await wallet.event_manager.count_token_events() except Exception: events_before = 0 # Create and wait for auto-payment + invoice: str + task: Any invoice, task = await wallet.mint_async(amount) print(f" Created invoice: {invoice[:50]}...") - timeout = 30.0 if os.getenv("USE_LOCAL_SERVICES") else 60.0 - paid = await asyncio.wait_for(task, timeout=timeout) + timeout: float = 30.0 if os.getenv("USE_LOCAL_SERVICES") else 60.0 + paid: bool = await asyncio.wait_for(task, timeout=timeout) assert paid is True, f"Invoice {i + 1} should be auto-paid" # Wait for events to propagate await asyncio.sleep(get_relay_wait_time(1.0)) # Validate balance increase with retry logic - max_retries = 5 + max_retries: int = 5 + for attempt in range(max_retries): balance_after = await wallet.get_balance() try: - events_after = await wallet.count_token_events() + events_after = await wallet.event_manager.count_token_events() except Exception: events_after = events_before @@ -165,7 +179,7 @@ async def test_comprehensive_transaction_flow_with_validation(self, wallet): # Validate denomination optimization state = await wallet.fetch_wallet_state(check_proofs=False) - denomination_counts = defaultdict(int) + denomination_counts: dict[int, int] = defaultdict(int) for proof in state.proofs: denomination_counts[proof["amount"]] += 1 @@ -186,12 +200,12 @@ async def test_comprehensive_transaction_flow_with_validation(self, wallet): print("\n📤 Phase 3: Complex send operations") - total_balance = await wallet.get_balance() + total_balance: int = await wallet.get_balance() print(f"Total balance before sending: {total_balance} sats") # Diverse send amounts to test proof selection and change calculation - send_amounts = [15, 75, 30, 5, 100] # Mix of small and large amounts - sent_tokens = [] + send_amounts: list[int] = [15, 75, 30, 5, 100] # Mix of small and large amounts + sent_tokens: list[tuple[int, str]] = [] for i, send_amount in enumerate(send_amounts): print( @@ -207,12 +221,12 @@ async def test_comprehensive_transaction_flow_with_validation(self, wallet): state_before = await wallet.fetch_wallet_state(check_proofs=False) try: - events_before = await wallet.count_token_events() + events_before = await wallet.event_manager.count_token_events() except Exception: events_before = 0 # Analyze denomination distribution before send - denoms_before = defaultdict(int) + denoms_before: dict[int, int] = defaultdict(int) for proof in state_before.proofs: denoms_before[proof["amount"]] += 1 @@ -221,7 +235,7 @@ async def test_comprehensive_transaction_flow_with_validation(self, wallet): # Perform send operation try: - token = await wallet.send(send_amount) + token: str = await wallet.send(send_amount) assert token.startswith("cashu"), "Should receive valid Cashu token" sent_tokens.append((send_amount, token)) @@ -232,18 +246,18 @@ async def test_comprehensive_transaction_flow_with_validation(self, wallet): balance_after = await wallet.get_balance() state_after = await wallet.fetch_wallet_state(check_proofs=False) try: - events_after = await wallet.count_token_events() + events_after = await wallet.event_manager.count_token_events() except Exception: events_after = events_before # Calculate actual fees paid - actual_fee = balance_before - balance_after - send_amount + actual_fee: int = balance_before - balance_after - send_amount assert actual_fee >= 0, ( f"Fee calculation error: negative fee {actual_fee}" ) # Analyze denomination distribution after send - denoms_after = defaultdict(int) + denoms_after: dict[int, int] = defaultdict(int) for proof in state_after.proofs: denoms_after[proof["amount"]] += 1 @@ -299,12 +313,15 @@ async def test_comprehensive_transaction_flow_with_validation(self, wallet): balance_before = await wallet.get_balance() try: - events_before = await wallet.count_token_events() + events_before = await wallet.event_manager.count_token_events() except Exception: events_before = 0 try: - redeemed_amount, unit = await wallet.redeem(token) + redeem_result = await wallet.redeem(token) + redeemed_amount: int + unit: str + redeemed_amount, unit = redeem_result print(f" Redeemed {redeemed_amount} {unit}") # Wait for events to propagate @@ -312,12 +329,12 @@ async def test_comprehensive_transaction_flow_with_validation(self, wallet): balance_after = await wallet.get_balance() try: - events_after = await wallet.count_token_events() + events_after = await wallet.event_manager.count_token_events() except Exception: events_after = events_before # Calculate redemption fee - redeem_fee = original_amount - redeemed_amount + redeem_fee: int = original_amount - redeemed_amount assert redeem_fee >= 0, f"Invalid redemption: negative fee {redeem_fee}" print( @@ -356,9 +373,9 @@ async def test_comprehensive_transaction_flow_with_validation(self, wallet): print("\n🔍 Phase 5: Comprehensive validation") # Final balance validation - final_balance = await wallet.get_balance() + final_balance: int = await wallet.get_balance() try: - final_events = await wallet.count_token_events() + final_events: int = await wallet.event_manager.count_token_events() except Exception: final_events = 0 final_state = await wallet.fetch_wallet_state( @@ -371,8 +388,8 @@ async def test_comprehensive_transaction_flow_with_validation(self, wallet): print(f" Active Proofs: {len(final_state.proofs)}") # Validate denomination distribution - final_denoms = defaultdict(int) - total_proof_value = 0 + final_denoms: dict[int, int] = defaultdict(int) + total_proof_value: int = 0 for proof in final_state.proofs: final_denoms[proof["amount"]] += 1 total_proof_value += proof["amount"] @@ -383,57 +400,21 @@ async def test_comprehensive_transaction_flow_with_validation(self, wallet): print(f" Final Denominations: {dict(final_denoms)}") - # Test wallet state cleanup (dry run) - cleanup_stats = await wallet.cleanup_wallet_state(dry_run=True) - print("\nWallet Cleanup Stats (dry run):") - for key, value in cleanup_stats.items(): - print(f" {key}: {value}") - - # Validate cleanup stats consistency - assert cleanup_stats["balance"] == final_balance, ( - "Cleanup balance should match current balance" - ) - - # Be lenient with event counting for integration tests - if cleanup_stats["total_events"] >= final_events: - print( - f"✅ Cleanup found {cleanup_stats['total_events']} events (≥ {final_events})" - ) - else: - print( - f"⚠️ Cleanup found {cleanup_stats['total_events']} events (< {final_events})" - ) - print(" This may be due to relay timing issues - not failing test") - - # ================================================================ - # Phase 6: Metrics analysis and final assertions - # ================================================================ - - print("\n📈 Phase 6: Transaction Metrics Analysis") - - print("\nTransaction Summary:") - print(f" Total Minted: {metrics['total_minted']} sats") - print(f" Total Sent: {metrics['total_sent']} sats") - print(f" Total Redeemed: {metrics['total_redeemed']} sats") - print(f" Total Fees Paid: {metrics['total_fees_paid']} sats") - print(f" Expected Balance: {metrics['expected_balance']} sats") - print(f" Actual Balance: {final_balance} sats") - # Allow for minor discrepancies due to fee calculation complexities - balance_diff = abs(final_balance - metrics["expected_balance"]) + balance_diff: int = abs(final_balance - metrics["expected_balance"]) assert balance_diff <= 5, ( f"Balance discrepancy too large: expected {metrics['expected_balance']}, " f"got {final_balance} (diff: {balance_diff})" ) # Validate event count progression (lenient for integration tests) - event_progression = [ + event_progression: list[int] = [ t.get("events_after", 0) for t in metrics["transactions"] if "events_after" in t ] if event_progression and any(e > 0 for e in event_progression): - is_non_decreasing = all( + is_non_decreasing: bool = all( event_progression[i] >= event_progression[i - 1] for i in range(1, len(event_progression)) ) @@ -450,7 +431,10 @@ async def test_comprehensive_transaction_flow_with_validation(self, wallet): # Validate denomination optimization over time print("\nDenomination Evolution:") - for i, denom_snapshot in enumerate(metrics["denomination_history"]): + denomination_history: list[dict[str, Any]] = cast( + list[dict[str, Any]], metrics["denomination_history"] + ) + for i, denom_snapshot in enumerate(denomination_history): print( f" {denom_snapshot['operation']}: {denom_snapshot['denominations']} " f"({denom_snapshot['total_proofs']} proofs, {denom_snapshot['balance']} sats)" @@ -458,8 +442,8 @@ async def test_comprehensive_transaction_flow_with_validation(self, wallet): # Test proof selection effectiveness (should have reasonable denomination spread) if final_denoms: - max_denomination = max(final_denoms.keys()) - min_denomination = min(final_denoms.keys()) + max_denomination: int = max(final_denoms.keys()) + min_denomination: int = min(final_denoms.keys()) assert max_denomination > min_denomination, ( "Should have diverse denominations" ) @@ -486,7 +470,7 @@ async def test_comprehensive_transaction_flow_with_validation(self, wallet): print("This is common in integration tests due to relay connection issues,") print("but doesn't affect the core wallet functionality validation.") - async def test_edge_cases_and_error_handling(self, wallet): + async def test_edge_cases_and_error_handling(self, wallet: Wallet) -> None: """Test edge cases: insufficient balance, invalid amounts, and error recovery.""" print("\n🧪 Testing edge cases and error handling...") @@ -514,41 +498,43 @@ async def test_edge_cases_and_error_handling(self, wallet): print("✅ Negative amount error handled correctly") # Test wallet state consistency after errors - balance = await wallet.get_balance() + balance: int = await wallet.get_balance() assert balance >= 0, "Balance should remain non-negative after errors" state = await wallet.fetch_wallet_state(check_proofs=False) - total_proof_value = sum(p["amount"] for p in state.proofs) + total_proof_value: int = sum(p["amount"] for p in state.proofs) assert total_proof_value == balance, ( "Proof values should match balance after errors" ) print("✅ Edge case testing completed") - async def test_denomination_optimization_stress(self, wallet): + async def test_denomination_optimization_stress(self, wallet: Wallet) -> None: """Stress test denomination optimization with many small transactions.""" print("\n⚡ Stress testing denomination optimization...") # Fund wallet for stress test - mint_amount = 500 + mint_amount: int = 500 + invoice: str + task: Any invoice, task = await wallet.mint_async(mint_amount) - timeout = 30.0 if os.getenv("USE_LOCAL_SERVICES") else 60.0 - paid = await asyncio.wait_for(task, timeout=timeout) + timeout: float = 30.0 if os.getenv("USE_LOCAL_SERVICES") else 60.0 + paid: bool = await asyncio.wait_for(task, timeout=timeout) assert paid is True await asyncio.sleep(get_relay_wait_time(2.0)) - initial_balance = await wallet.get_balance() + initial_balance: int = await wallet.get_balance() assert initial_balance >= mint_amount # Perform many small sends to stress denomination logic - small_amounts = [1, 2, 3, 5, 8, 13, 21] # Fibonacci-like sequence - successful_sends = 0 + small_amounts: list[int] = [1, 2, 3, 5, 8, 13, 21] # Fibonacci-like sequence + successful_sends: int = 0 for amount in small_amounts: try: - balance_before = await wallet.get_balance() + balance_before: int = await wallet.get_balance() if balance_before < amount + 10: # Leave buffer for fees break @@ -559,7 +545,7 @@ async def test_denomination_optimization_stress(self, wallet): # Check denomination distribution state = await wallet.fetch_wallet_state(check_proofs=False) - denoms = defaultdict(int) + denoms: dict[int, int] = defaultdict(int) for proof in state.proofs: denoms[proof["amount"]] += 1 @@ -569,7 +555,7 @@ async def test_denomination_optimization_stress(self, wallet): print(f" Failed to send {amount} sats: {e}") break - final_balance = await wallet.get_balance() + final_balance: int = await wallet.get_balance() print( f"Stress test completed: {successful_sends} sends, final balance: {final_balance} sats" ) diff --git a/tests/integration/test_lnurl_requests.py b/tests/integration/test_lnurl_requests.py index 01cbf29..54a4ecb 100644 --- a/tests/integration/test_lnurl_requests.py +++ b/tests/integration/test_lnurl_requests.py @@ -5,7 +5,15 @@ import os import pytest import httpx -from sixty_nuts.lnurl import decode_lnurl, get_lnurl_data, get_lnurl_invoice, LNURLError +from typing import Any + +from sixty_nuts.lnurl import ( + decode_lnurl, + get_lnurl_data, + get_lnurl_invoice, + LNURLError, + LNURLData, +) pytestmark = pytest.mark.skipif( not os.getenv("RUN_INTEGRATION_TESTS"), @@ -19,8 +27,8 @@ class TestLNURLIntegration: @pytest.mark.asyncio async def test_decode_lightning_address(self) -> None: """Test decoding Lightning Address format.""" - lnurl = "routstr@minibits.cash" - decoded_url = await decode_lnurl(lnurl) + lnurl: str = "routstr@minibits.cash" + decoded_url: str = await decode_lnurl(lnurl) assert decoded_url == "https://minibits.cash/.well-known/lnurlp/routstr" assert decoded_url.startswith("https://") @@ -28,16 +36,16 @@ async def test_decode_lightning_address(self) -> None: @pytest.mark.asyncio async def test_decode_lightning_prefix(self) -> None: """Test decoding with lightning: prefix.""" - lnurl = "lightning:routstr@minibits.cash" - decoded_url = await decode_lnurl(lnurl) + lnurl: str = "lightning:routstr@minibits.cash" + decoded_url: str = await decode_lnurl(lnurl) assert decoded_url == "https://minibits.cash/.well-known/lnurlp/routstr" @pytest.mark.asyncio async def test_decode_direct_https_url(self) -> None: """Test direct HTTPS URL passthrough.""" - direct_url = "https://minibits.cash/.well-known/lnurlp/routstr" - decoded_url = await decode_lnurl(direct_url) + direct_url: str = "https://minibits.cash/.well-known/lnurlp/routstr" + decoded_url: str = await decode_lnurl(direct_url) assert decoded_url == direct_url @@ -55,10 +63,10 @@ async def test_decode_invalid_formats(self) -> None: @pytest.mark.asyncio async def test_get_lnurl_data_real_endpoint(self) -> None: """Test fetching LNURL data from real Lightning Address.""" - lnurl = "routstr@minibits.cash" + lnurl: str = "routstr@minibits.cash" try: - lnurl_data = await get_lnurl_data(lnurl) + lnurl_data: LNURLData = await get_lnurl_data(lnurl) assert isinstance(lnurl_data["callback_url"], str) assert lnurl_data["callback_url"].startswith("https://") @@ -75,7 +83,7 @@ async def test_get_lnurl_data_real_endpoint(self) -> None: @pytest.mark.asyncio async def test_get_lnurl_data_invalid_endpoint(self) -> None: """Test error handling for invalid LNURL endpoints.""" - invalid_lnurl = "nonexistent@invalid-domain-that-does-not-exist.com" + invalid_lnurl: str = "nonexistent@invalid-domain-that-does-not-exist.com" with pytest.raises((httpx.HTTPError, LNURLError)): await get_lnurl_data(invalid_lnurl) @@ -83,21 +91,24 @@ async def test_get_lnurl_data_invalid_endpoint(self) -> None: @pytest.mark.asyncio async def test_full_lnurl_flow_with_invoice_generation(self) -> None: """Test complete LNURL flow: decode -> data -> invoice.""" - lnurl = "routstr@minibits.cash" - amount_msat = 1000 # 1 sat + lnurl: str = "routstr@minibits.cash" + amount_msat: int = 1000 # 1 sat try: # Step 1: Get LNURL data - lnurl_data = await get_lnurl_data(lnurl) + lnurl_data: LNURLData = await get_lnurl_data(lnurl) # Step 2: Validate amount is within bounds assert amount_msat >= lnurl_data["min_sendable"] assert amount_msat <= lnurl_data["max_sendable"] # Step 3: Generate invoice - invoice, response_data = await get_lnurl_invoice( + invoice_result = await get_lnurl_invoice( lnurl_data["callback_url"], amount_msat ) + invoice: str + response_data: dict[str, Any] + invoice, response_data = invoice_result # Validate invoice response assert isinstance(invoice, str) @@ -114,27 +125,32 @@ async def test_full_lnurl_flow_with_invoice_generation(self) -> None: @pytest.mark.asyncio async def test_lnurl_invoice_amount_validation(self) -> None: """Test invoice generation with different amounts.""" - lnurl = "routstr@minibits.cash" + lnurl: str = "routstr@minibits.cash" try: - lnurl_data = await get_lnurl_data(lnurl) + lnurl_data: LNURLData = await get_lnurl_data(lnurl) # Test minimum amount - min_amount = lnurl_data["min_sendable"] - invoice_min, _ = await get_lnurl_invoice( + min_amount: int = lnurl_data["min_sendable"] + invoice_min_result = await get_lnurl_invoice( lnurl_data["callback_url"], min_amount ) + invoice_min: str + _: dict[str, Any] + invoice_min, _ = invoice_min_result assert isinstance(invoice_min, str) assert len(invoice_min) > 0 # Test amount within range (if range allows) if lnurl_data["max_sendable"] > lnurl_data["min_sendable"]: - mid_amount = min( + mid_amount: int = min( lnurl_data["min_sendable"] + 1000, lnurl_data["max_sendable"] ) - invoice_mid, _ = await get_lnurl_invoice( + invoice_mid_result = await get_lnurl_invoice( lnurl_data["callback_url"], mid_amount ) + invoice_mid: str + invoice_mid, _ = invoice_mid_result assert isinstance(invoice_mid, str) assert len(invoice_mid) > 0 @@ -146,14 +162,14 @@ async def test_lnurl_invoice_amount_validation(self) -> None: @pytest.mark.asyncio async def test_lnurl_invoice_error_handling(self) -> None: """Test error handling in invoice generation.""" - lnurl = "routstr@minibits.cash" + lnurl: str = "routstr@minibits.cash" try: - lnurl_data = await get_lnurl_data(lnurl) + lnurl_data: LNURLData = await get_lnurl_data(lnurl) # Test amount too large (if applicable) if lnurl_data["max_sendable"] < 100000000000: # 100k sats - large_amount = lnurl_data["max_sendable"] + 1000 + large_amount: int = lnurl_data["max_sendable"] + 1000 # Either LNURLError or HTTPError is acceptable for invalid amounts with pytest.raises((LNURLError, httpx.HTTPError)): @@ -161,7 +177,7 @@ async def test_lnurl_invoice_error_handling(self) -> None: # Test amount too small (if applicable) if lnurl_data["min_sendable"] > 1: - small_amount = lnurl_data["min_sendable"] - 1 + small_amount: int = lnurl_data["min_sendable"] - 1 # Either LNURLError or HTTPError is acceptable for invalid amounts with pytest.raises((LNURLError, httpx.HTTPError)): @@ -175,27 +191,30 @@ async def test_lnurl_invoice_error_handling(self) -> None: @pytest.mark.asyncio async def test_multiple_lightning_addresses(self) -> None: """Test with multiple different Lightning Address providers.""" - test_addresses = [ + test_addresses: list[str] = [ "routstr@minibits.cash", # Add more test addresses if available ] - successful_tests = 0 + successful_tests: int = 0 for address in test_addresses: try: - decoded_url = await decode_lnurl(address) + decoded_url: str = await decode_lnurl(address) assert decoded_url.startswith("https://") - lnurl_data = await get_lnurl_data(address) + lnurl_data: LNURLData = await get_lnurl_data(address) assert isinstance(lnurl_data["callback_url"], str) # Test small invoice generation - amount = max(1000, lnurl_data["min_sendable"]) # 1 sat or minimum + amount: int = max(1000, lnurl_data["min_sendable"]) # 1 sat or minimum if amount <= lnurl_data["max_sendable"]: - invoice, _ = await get_lnurl_invoice( + invoice_result = await get_lnurl_invoice( lnurl_data["callback_url"], amount ) + invoice: str + _: dict[str, Any] + invoice, _ = invoice_result assert isinstance(invoice, str) assert len(invoice) > 0 diff --git a/tests/integration/test_mint_methods.py b/tests/integration/test_mint_methods.py index 55fd7c4..78bd8bc 100644 --- a/tests/integration/test_mint_methods.py +++ b/tests/integration/test_mint_methods.py @@ -15,9 +15,21 @@ import os import pytest import asyncio -from typing import Any - -from sixty_nuts.mint import Mint, MintError, BlindedMessage +from typing import Any, AsyncGenerator, cast + +from sixty_nuts.mint import ( + Mint, + MintError, + BlindedMessage, + MintInfo, + Keyset, + KeysetInfo, + PostMintQuoteResponse, + PostCheckStateResponse, + PostMeltQuoteResponse, + ProofComplete, +) +from sixty_nuts.types import Proof # Skip all integration tests unless explicitly enabled @@ -28,7 +40,7 @@ @pytest.fixture -async def mint(): +async def mint() -> AsyncGenerator[Mint, None]: """Create a mint instance for testing. Uses local Docker mint when USE_LOCAL_SERVICES is set, @@ -45,7 +57,7 @@ async def mint(): @pytest.fixture -async def mint_local(): +async def mint_local() -> AsyncGenerator[Mint, None]: """Create a mint instance specifically for local testing (if available).""" mint_instance = Mint("http://localhost:3338") yield mint_instance @@ -55,9 +67,9 @@ async def mint_local(): class TestMintBasicOperations: """Test basic mint operations that require live mint API.""" - async def test_get_mint_info(self, mint): + async def test_get_mint_info(self, mint: Mint) -> None: """Test retrieving mint information from real mint.""" - info = await mint.get_info() + info: MintInfo = await mint.get_info() # Verify expected fields are present assert isinstance(info, dict) @@ -81,14 +93,12 @@ async def test_get_mint_info(self, mint): print(f"✅ Mint info retrieved: {info}") - async def test_get_keysets(self, mint): - """Test retrieving active keysets from real mint.""" - keysets_resp = await mint.get_keysets() + async def test_get_keysets(self, mint: Mint) -> None: + """Test retrieving keyset information from real mint.""" + keysets: list[KeysetInfo] = await mint.get_keysets_info() - assert "keysets" in keysets_resp - keysets = keysets_resp["keysets"] assert isinstance(keysets, list) - assert len(keysets) > 0, "Mint should have at least one active keyset" + assert len(keysets) > 0, "Mint should have at least one keyset" # Verify keyset structure for keyset in keysets: @@ -107,61 +117,68 @@ async def test_get_keysets(self, mint): assert isinstance(keyset["active"], bool) # Test mint should have at least some active keysets for sat - sat_keysets = [ks for ks in keysets if ks["unit"] == "sat"] - active_sat_keysets = [ks for ks in sat_keysets if ks["active"]] + sat_keysets: list[KeysetInfo] = [ks for ks in keysets if ks["unit"] == "sat"] + active_sat_keysets: list[KeysetInfo] = [ + ks for ks in sat_keysets if ks["active"] + ] assert len(active_sat_keysets) > 0, "Should have at least one active sat keyset" print(f"✅ Found {len(keysets)} keysets") - async def test_get_keys_with_validation(self, mint): + async def test_get_keys_with_validation(self, mint: Mint) -> None: """Test retrieving mint public keys with NUT-01 validation.""" - # Get without specific keyset ID (should return newest) - keys_resp = await mint.get_keys() + # Get active keysets with full details + keysets: list[Keyset] = await mint.get_active_keysets() - assert "keysets" in keys_resp - keysets = keys_resp["keysets"] + assert isinstance(keysets, list) assert len(keysets) > 0 # Verify each keyset has proper structure for keyset in keysets: - assert mint._validate_keyset(keyset), f"Invalid keyset: {keyset}" + # Convert to dict for validation method + keyset_dict = dict(keyset) + assert mint._validate_keyset(keyset_dict), f"Invalid keyset: {keyset}" # Verify keys structure assert "keys" in keyset - keys = keyset["keys"] + keys: dict[str, str] = keyset["keys"] assert isinstance(keys, dict) assert len(keys) > 0, "Keyset should have public keys" # Verify each key is valid compressed secp256k1 for amount_str, pubkey in keys.items(): # Amount should be valid - amount = int(amount_str) + amount: int = int(amount_str) assert amount > 0 assert amount & (amount - 1) == 0 # Should be power of 2 # Pubkey should be valid compressed format - assert mint._is_valid_compressed_pubkey(pubkey) + assert len(pubkey) == 66 # 33 bytes = 66 hex chars + assert pubkey.startswith(("02", "03")) - # Test getting specific keyset - if keysets: - keyset_id = keysets[0]["id"] - specific_keys = await mint.get_keys(keyset_id) - assert "keysets" in specific_keys - assert len(specific_keys["keysets"]) >= 1 + # Verify it's hex + try: + bytes.fromhex(pubkey) + except ValueError: + pytest.fail(f"Invalid hex pubkey: {pubkey}") - print(f"✅ Keys validation passed for {len(keysets)} keysets") + print(f"✅ Retrieved and validated keys for {len(keysets)} keysets") - async def test_validate_keysets_response(self, mint): + async def test_validate_keysets_response(self, mint: Mint) -> None: """Test the keyset validation methods with real data.""" - keysets_resp = await mint.get_keysets() + keysets: list[KeysetInfo] = await mint.get_keysets_info() - # Test validation method - assert mint.validate_keysets_response(dict(keysets_resp)) + # Test validation method on proper response format + response: dict[str, list[KeysetInfo]] = {"keysets": keysets} + assert mint.validate_keysets_response(response) - # Test get_validated_keysets method - validated_resp = await mint.get_validated_keysets() - assert "keysets" in validated_resp - assert len(validated_resp["keysets"]) > 0 + # Test that we can get the full keyset details with keys + if keysets: + # Get full details for first keyset + keyset_full: Keyset = await mint.get_keyset(keysets[0]["id"]) + assert keyset_full + assert "keys" in keyset_full + assert isinstance(keyset_full["keys"], dict) print("✅ Keyset validation methods work correctly") @@ -169,11 +186,13 @@ async def test_validate_keysets_response(self, mint): class TestMintQuoteOperations: """Test mint quote operations against real mint.""" - async def test_create_mint_quote(self, mint): + async def test_create_mint_quote(self, mint: Mint) -> PostMintQuoteResponse | None: """Test creating mint quotes for various amounts and units.""" try: # Test basic quote creation - quote_resp = await mint.create_mint_quote(unit="sat", amount=100) + quote_resp: PostMintQuoteResponse = await mint.create_mint_quote( + unit="sat", amount=100 + ) assert "quote" in quote_resp assert "request" in quote_resp # BOLT11 invoice @@ -204,15 +223,17 @@ async def test_create_mint_quote(self, mint): else: raise - async def test_get_mint_quote_status(self, mint): + async def test_get_mint_quote_status(self, mint: Mint) -> None: """Test checking mint quote status.""" try: # Create a quote first - quote_resp = await mint.create_mint_quote(unit="sat", amount=50) - quote_id = quote_resp["quote"] + quote_resp: PostMintQuoteResponse = await mint.create_mint_quote( + unit="sat", amount=50 + ) + quote_id: str = quote_resp["quote"] # Check quote status - status = await mint.get_mint_quote(quote_id) + status: PostMintQuoteResponse = await mint.get_mint_quote(quote_id) assert "quote" in status assert "state" in status @@ -228,18 +249,20 @@ async def test_get_mint_quote_status(self, mint): else: raise - async def test_mint_quote_different_amounts(self, mint): + async def test_mint_quote_different_amounts(self, mint: Mint) -> None: """Test mint quotes for different amounts with rate limiting.""" - amounts = [1, 10, 100, 1000] + amounts: list[int] = [1, 10, 100, 1000] for amount in amounts: try: - quote_resp = await mint.create_mint_quote(unit="sat", amount=amount) + quote_resp: PostMintQuoteResponse = await mint.create_mint_quote( + unit="sat", amount=amount + ) assert quote_resp["amount"] == amount print(f"✅ Created quote for {amount} sats") await asyncio.sleep(1) # Delay to avoid rate limiting except MintError as e: - error_msg = str(e).lower() + error_msg: str = str(e).lower() # Some mints might have minimum amounts or rate limiting if "minimum" in error_msg: print(f"⚠️ Mint has minimum amount restriction for {amount} sats") @@ -249,12 +272,12 @@ async def test_mint_quote_different_amounts(self, mint): else: raise - async def test_mint_quote_with_description(self, mint): + async def test_mint_quote_with_description(self, mint: Mint) -> None: """Test mint quote with description and optional fields.""" - description = "Integration test payment" + description: str = "Integration test payment" try: - quote_resp = await mint.create_mint_quote( + quote_resp: PostMintQuoteResponse = await mint.create_mint_quote( unit="sat", amount=25, description=description ) @@ -271,27 +294,29 @@ async def test_mint_quote_with_description(self, mint): class TestMeltQuoteOperations: """Test melt quote operations (may be limited without actual Lightning).""" - async def test_create_melt_quote_invalid_invoice(self, mint): + async def test_create_melt_quote_invalid_invoice(self, mint: Mint) -> None: """Test melt quote with invalid invoice (should fail gracefully).""" - invalid_invoice = "lnbc1000n1invalid" + invalid_invoice: str = "lnbc1000n1invalid" with pytest.raises(MintError) as exc_info: await mint.create_melt_quote(unit="sat", request=invalid_invoice) # Should get a reasonable error message - error_msg = str(exc_info.value).lower() + error_msg: str = str(exc_info.value).lower() assert any( word in error_msg for word in ["invalid", "bad", "bech32", "not valid"] ) print("✅ Invalid invoice properly rejected") - async def test_melt_quote_structure(self, mint): + async def test_melt_quote_structure(self, mint: Mint) -> None: """Test melt quote response structure with a potentially valid invoice.""" # Use a well-formed but likely expired/invalid invoice - test_invoice = "lnbc100n1pjqq5jqsp5l3l6t7k6z4t5r9m8s7q2w3e4r5t6y7u8i9o0p1l2k3j4h5g6f7s8dp9q7sqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqspp5qr3n6g5g4t6t7k8h9j0k1l2m3n4p5q6r7s8t9u0v1w2x3y4z5a6b7c8d9e0f1gq9qtzqqqqqq" + test_invoice: str = "lnbc100n1pjqq5jqsp5l3l6t7k6z4t5r9m8s7q2w3e4r5t6y7u8i9o0p1l2k3j4h5g6f7s8dp9q7sqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqspp5qr3n6g5g4t6t7k8h9j0k1l2m3n4p5q6r7s8t9u0v1w2x3y4z5a6b7c8d9e0f1gq9qtzqqqqqq" try: - quote_resp = await mint.create_melt_quote(unit="sat", request=test_invoice) + quote_resp: PostMeltQuoteResponse = await mint.create_melt_quote( + unit="sat", request=test_invoice + ) # If it succeeds, verify structure assert "quote" in quote_resp @@ -309,9 +334,9 @@ async def test_melt_quote_structure(self, mint): class TestTokenManagement: """Test token management operations.""" - async def test_check_state_empty(self, mint): + async def test_check_state_empty(self, mint: Mint) -> None: """Test checking state with empty Y values.""" - state_resp = await mint.check_state(Ys=[]) + state_resp: PostCheckStateResponse = await mint.check_state(Ys=[]) assert "states" in state_resp assert isinstance(state_resp["states"], list) @@ -319,18 +344,18 @@ async def test_check_state_empty(self, mint): print("✅ Empty state check works correctly") - async def test_check_state_fake_proofs(self, mint): + async def test_check_state_fake_proofs(self, mint: Mint) -> None: """Test checking state with fake proof Y values.""" # Generate some fake Y values (valid format but non-existent proofs) - fake_y_values = [ + fake_y_values: list[str] = [ "02" + "a1b2c3d4e5f6" * 10, # 66 char hex string "03" + "f1e2d3c4b5a6" * 10, # Another fake Y value ] - state_resp = await mint.check_state(Ys=fake_y_values) + state_resp: PostCheckStateResponse = await mint.check_state(Ys=fake_y_values) assert "states" in state_resp - states = state_resp["states"] + states: list[dict[str, str]] = state_resp["states"] assert len(states) == len(fake_y_values) # States should indicate these proofs don't exist @@ -339,30 +364,32 @@ async def test_check_state_fake_proofs(self, mint): print(f"✅ Checked state for {len(fake_y_values)} fake Y values") - async def test_restore_empty(self, mint): + async def test_restore_empty(self, mint: Mint) -> None: """Test restore with empty outputs (should fail).""" with pytest.raises(MintError) as exc_info: await mint.restore(outputs=[]) # Should get an error about no outputs provided - error_msg = str(exc_info.value).lower() + error_msg: str = str(exc_info.value).lower() assert any(word in error_msg for word in ["no outputs", "empty", "required"]) print("✅ Empty restore properly rejected") - async def test_swap_validation_errors(self, mint): + async def test_swap_validation_errors(self, mint: Mint) -> None: """Test swap with invalid inputs (should fail).""" - # Create fake but properly structured inputs and outputs - fake_inputs = [ + # Create fake but properly structured inputs + fake_inputs: list[Proof] = [ { "id": "00ad268c4d1f5826", "amount": 10, "secret": "fake_secret", "C": "02a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2", + "mint": "https://testnut.cashu.space", + "unit": "sat", } ] # Create blinded outputs for the same amount - fake_outputs = [ + fake_outputs: list[BlindedMessage] = [ BlindedMessage( amount=10, id="00ad268c4d1f5826", @@ -371,10 +398,13 @@ async def test_swap_validation_errors(self, mint): ] with pytest.raises(MintError) as exc_info: - await mint.swap(inputs=fake_inputs, outputs=fake_outputs) + # Cast to ProofComplete since swap expects it (ProofComplete extends Proof with optional fields) + await mint.swap( + inputs=cast(list[ProofComplete], fake_inputs), outputs=fake_outputs + ) # Should get a reasonable error (invalid proof, unknown secret, etc.) - error_msg = str(exc_info.value).lower() + error_msg: str = str(exc_info.value).lower() assert any( word in error_msg for word in ["invalid", "unknown", "proof", "secret"] ) @@ -384,28 +414,28 @@ async def test_swap_validation_errors(self, mint): class TestMintValidation: """Test mint validation methods with real data.""" - async def test_keyset_validation_real_data(self, mint): + async def test_keyset_validation_real_data(self, mint: Mint) -> None: """Test keyset validation with real mint data.""" - keysets_resp = await mint.get_keysets() - keysets = keysets_resp["keysets"] + keysets: list[KeysetInfo] = await mint.get_keysets_info() # All real keysets should pass validation for keyset in keysets: - assert mint.validate_keyset(keyset), ( + # Convert to dict for validation method + keyset_dict = dict(keyset) + assert mint.validate_keyset(keyset_dict), ( f"Real keyset failed validation: {keyset}" ) # Test the response validation - assert mint.validate_keysets_response(dict(keysets_resp)) + assert mint.validate_keysets_response({"keysets": keysets}) print(f"✅ All {len(keysets)} real keysets passed validation") - async def test_pubkey_validation_real_keys(self, mint): + async def test_pubkey_validation_real_keys(self, mint: Mint) -> None: """Test public key validation with real mint keys.""" - keys_resp = await mint.get_keys() - keysets = keys_resp["keysets"] + keysets: list[Keyset] = await mint.get_active_keysets() - valid_count = 0 + valid_count: int = 0 for keyset in keysets: if "keys" in keyset: for amount_str, pubkey in keyset["keys"].items(): @@ -421,7 +451,7 @@ async def test_pubkey_validation_real_keys(self, mint): class TestMintErrorHandling: """Test error handling with real mint responses.""" - async def test_invalid_endpoints(self, mint): + async def test_invalid_endpoints(self, mint: Mint) -> None: """Test requests to invalid endpoints.""" with pytest.raises(MintError) as exc_info: await mint._request("GET", "/v1/nonexistent") @@ -429,22 +459,22 @@ async def test_invalid_endpoints(self, mint): assert "404" in str(exc_info.value) or "400" in str(exc_info.value) print("✅ Invalid endpoint properly rejected") - async def test_invalid_keyset_id(self, mint): + async def test_invalid_keyset_id(self, mint: Mint) -> None: """Test requesting keys with invalid keyset ID.""" - invalid_keyset_id = "invalid_id_123" + invalid_keyset_id: str = "invalid_id_123" with pytest.raises(MintError) as exc_info: - await mint.get_keys(invalid_keyset_id) + await mint.get_keyset(invalid_keyset_id) - error_msg = str(exc_info.value) + error_msg: str = str(exc_info.value) assert "400" in error_msg or "404" in error_msg print("✅ Invalid keyset ID properly rejected") - async def test_malformed_requests(self, mint): + async def test_malformed_requests(self, mint: Mint) -> None: """Test malformed request handling.""" # Try to create quote with invalid unit with pytest.raises(Exception): # Could be MintError or validation error - await mint.create_mint_quote(unit="invalid_unit", amount=100) + await mint.create_mint_quote(unit="invalid_unit", amount=100) # type: ignore print("✅ Malformed requests properly handled") @@ -452,10 +482,10 @@ async def test_malformed_requests(self, mint): class TestMintComplexOperations: """Test more complex mint operations and flows.""" - async def test_multiple_concurrent_quotes(self, mint): + async def test_multiple_concurrent_quotes(self, mint: Mint) -> None: """Test creating multiple quotes with rate limit handling.""" - async def create_quote_with_retry(amount: int) -> dict[str, Any]: + async def create_quote_with_retry(amount: int) -> PostMintQuoteResponse: for attempt in range(3): try: return await mint.create_mint_quote(unit="sat", amount=amount) @@ -468,12 +498,12 @@ async def create_quote_with_retry(amount: int) -> dict[str, Any]: raise RuntimeError("All retry attempts failed") # Create quotes with small delays to avoid rate limiting - amounts = [10, 25, 50, 100] - quotes = [] + amounts: list[int] = [10, 25, 50, 100] + quotes: list[PostMintQuoteResponse] = [] for amount in amounts: try: - quote = await create_quote_with_retry(amount) + quote: PostMintQuoteResponse = await create_quote_with_retry(amount) quotes.append(quote) await asyncio.sleep(0.5) # Small delay between requests except MintError as e: @@ -484,7 +514,7 @@ async def create_quote_with_retry(amount: int) -> dict[str, Any]: if quotes: # Verify all quotes are unique - quote_ids = [q["quote"] for q in quotes] + quote_ids: list[str] = [q["quote"] for q in quotes] assert len(set(quote_ids)) == len(quote_ids), ( "All quote IDs should be unique" ) @@ -497,17 +527,19 @@ async def create_quote_with_retry(amount: int) -> dict[str, Any]: "⚠️ All requests were rate limited - test passed (shows rate limiting works)" ) - async def test_quote_status_polling(self, mint): + async def test_quote_status_polling(self, mint: Mint) -> None: """Test polling quote status over time with rate limit handling.""" try: # Create a quote - quote_resp = await mint.create_mint_quote(unit="sat", amount=21) - quote_id = quote_resp["quote"] + quote_resp: PostMintQuoteResponse = await mint.create_mint_quote( + unit="sat", amount=21 + ) + quote_id: str = quote_resp["quote"] # Poll status a few times with delays - states = [] + states: list[str] = [] for i in range(3): - status = await mint.get_mint_quote(quote_id) + status: PostMintQuoteResponse = await mint.get_mint_quote(quote_id) states.append(status["state"]) if i < 2: # Don't wait after last check @@ -523,18 +555,18 @@ async def test_quote_status_polling(self, mint): else: raise - async def test_keys_caching_behavior(self, mint): + async def test_keys_caching_behavior(self, mint: Mint) -> None: """Test that repeated key requests work correctly.""" # Get keys multiple times - keys1 = await mint.get_keys() - keys2 = await mint.get_keys() + keys1: list[Keyset] = await mint.get_active_keysets() + keys2: list[Keyset] = await mint.get_active_keysets() # Should return consistent results assert keys1 == keys2 # Get keysets multiple times - keysets1 = await mint.get_keysets() - keysets2 = await mint.get_keysets() + keysets1: list[KeysetInfo] = await mint.get_keysets_info() + keysets2: list[KeysetInfo] = await mint.get_keysets_info() # Should return consistent results assert keysets1 == keysets2 @@ -545,42 +577,43 @@ async def test_keys_caching_behavior(self, mint): class TestMintPerformance: """Test mint performance and reliability.""" - async def test_rapid_requests(self, mint): + async def test_rapid_requests(self, mint: Mint) -> None: """Test making rapid sequential requests.""" - start_time = asyncio.get_event_loop().time() + start_time: float = asyncio.get_event_loop().time() # Make multiple rapid requests - tasks = [] + tasks: list[Any] = [] for _ in range(5): tasks.append(mint.get_info()) - results = await asyncio.gather(*tasks) + results: list[MintInfo] = await asyncio.gather(*tasks) - end_time = asyncio.get_event_loop().time() - duration = end_time - start_time + end_time: float = asyncio.get_event_loop().time() + duration: float = end_time - start_time assert len(results) == 5 assert all(isinstance(result, dict) for result in results) print(f"✅ Completed 5 concurrent requests in {duration:.2f}s") - async def test_connection_reuse(self, mint): + async def test_connection_reuse(self, mint: Mint) -> None: """Test that HTTP connections are properly reused.""" # Make multiple requests that should reuse connections - info1 = await mint.get_info() - keysets = await mint.get_keysets() - info2 = await mint.get_info() + info1: MintInfo = await mint.get_info() + keysets: list[KeysetInfo] = await mint.get_keysets_info() + info2: MintInfo = await mint.get_info() assert isinstance(info1, dict) - assert isinstance(keysets, dict) + assert isinstance(keysets, list) assert isinstance(info2, dict) # Info should be mostly consistent (excluding time-sensitive fields) - info1_copy = dict(info1) - info2_copy = dict(info2) + info1_copy: dict[str, Any] = dict(info1) + info2_copy: dict[str, Any] = dict(info2) # Remove time-sensitive fields that may differ between requests - for time_field in ["time", "timestamp", "updated_at"]: + time_fields: list[str] = ["time", "timestamp", "updated_at"] + for time_field in time_fields: info1_copy.pop(time_field, None) info2_copy.pop(time_field, None) @@ -601,20 +634,22 @@ async def test_connection_reuse(self, mint): sys.exit(1) # Run a simple smoke test - async def main(): + async def main() -> None: mint = Mint("https://testnut.cashu.space") try: print("🔄 Testing mint connection...") - info = await mint.get_info() + info: MintInfo = await mint.get_info() print(f"✅ Connected to mint: {info.get('name', 'Unknown')}") print("🔄 Testing keysets...") - keysets = await mint.get_keysets() - print(f"✅ Found {len(keysets['keysets'])} keysets") + keysets: list[Keyset] = await mint.get_active_keysets() + print(f"✅ Found {len(keysets)} keysets") print("🔄 Testing quote creation...") - quote = await mint.create_mint_quote(unit="sat", amount=100) + quote: PostMintQuoteResponse = await mint.create_mint_quote( + unit="sat", amount=100 + ) print(f"✅ Created quote: {quote['quote']}") print("✅ All basic tests passed!") diff --git a/tests/integration/test_multi_currency_support.py b/tests/integration/test_multi_currency_support.py new file mode 100644 index 0000000..8e4a005 --- /dev/null +++ b/tests/integration/test_multi_currency_support.py @@ -0,0 +1,517 @@ +"""Multi-currency support integration tests. + +Tests wallet operations across multiple currencies (sat, msat, usd, eur) using +the cashu test mint which provides different keysets for each currency. +Only runs when RUN_INTEGRATION_TESTS environment variable is set. + +This test suite is designed to validate the multi-currency implementation +and help debug any issues with currency-specific operations. +""" + +import asyncio +import os +import pytest +from typing import Any, cast + +from sixty_nuts.wallet import Wallet +from sixty_nuts.mint import Mint, MintInfo, KeysetInfo +from sixty_nuts.types import CurrencyUnit + + +# Skip all integration tests unless explicitly enabled +pytestmark = pytest.mark.skipif( + not os.getenv("RUN_INTEGRATION_TESTS"), + reason="Integration tests only run when RUN_INTEGRATION_TESTS is set", +) + + +def get_relay_wait_time(base_seconds: float = 1.0) -> float: + """Get appropriate wait time based on service type.""" + if os.getenv("USE_LOCAL_SERVICES"): + return base_seconds + else: + return base_seconds * 3.0 # 3x longer for public relays + + +class TestMultiCurrencySupport: + """Test multi-currency operations with different keysets.""" + + async def test_mint_info_currencies_available( + self, wallet: Wallet + ) -> list[CurrencyUnit]: + """Verify the test mint supports multiple currencies.""" + print("\n🌍 Testing available currencies on test mint...") + + # Get mint instance + mint: Mint = wallet._get_mint(wallet._primary_mint_url()) + + # Get mint info + info: MintInfo = await mint.get_info() + print(f"Mint info: {info}") + + # Get available currencies + currencies: list[CurrencyUnit] = await mint.get_currencies() + print(f"Available currencies: {currencies}") + + # The test mint should support at least sat, msat, usd, eur + expected_currencies: set[str] = {"sat", "msat", "usd", "eur"} + available_set: set[str] = set(currencies) + + # Check if expected currencies are available + missing: set[str] = expected_currencies - available_set + if missing: + print(f"⚠️ Warning: Missing expected currencies: {missing}") + print(f" Available: {available_set}") + # Don't fail - test what's available + + assert len(currencies) > 0, "Mint should support at least one currency" + return currencies + + async def test_keysets_per_currency(self, wallet: Wallet) -> None: + """Test that each currency has its own keyset.""" + print("\n🔑 Testing keysets for different currencies...") + + mint: Mint = wallet._get_mint(wallet._primary_mint_url()) + + # Get all keysets info + keysets_info: list[KeysetInfo] = await mint.get_keysets_info() + print(f"Found {len(keysets_info)} keysets") + + # Group keysets by currency + keysets_by_currency: dict[str, list[KeysetInfo]] = {} + for keyset in keysets_info: + unit: str = keyset.get("unit", "") + if unit not in keysets_by_currency: + keysets_by_currency[unit] = [] + keysets_by_currency[unit].append(keyset) + + print("\nKeysets by currency:") + for unit, keysets in keysets_by_currency.items(): + active_keysets: list[KeysetInfo] = [ + k for k in keysets if k.get("active", True) + ] + print(f" {unit}: {len(keysets)} total, {len(active_keysets)} active") + for keyset in active_keysets[:1]: # Show first active keyset + print( + f" - ID: {keyset['id']}, Fee: {keyset.get('input_fee_ppk', 0)} ppk" + ) + + # Verify we have at least one active keyset per currency + for unit in ["sat", "msat", "usd", "eur"]: + if unit in keysets_by_currency: + active: list[KeysetInfo] = [ + k for k in keysets_by_currency[unit] if k.get("active", True) + ] + assert len(active) > 0, ( + f"Should have at least one active keyset for {unit}" + ) + + async def test_mint_in_different_currencies(self, wallet: Wallet) -> None: + """Test minting tokens in different currencies.""" + print("\n💰 Testing minting in different currencies...") + + # Check initial state + initial_state = await wallet.fetch_wallet_state(check_proofs=False) + initial_balance_by_unit: dict[CurrencyUnit, int] = initial_state.balance_by_unit + print(f"Initial balances by unit: {initial_balance_by_unit}") + + # Get available currencies + mint: Mint = wallet._get_mint(wallet._primary_mint_url()) + available_currencies: list[CurrencyUnit] = await mint.get_currencies() + + # Test currencies that are available + test_currencies: list[tuple[str, int]] = [] + test_amounts: dict[str, int] = {"sat": 100, "msat": 10000, "usd": 1, "eur": 1} + + for currency in ["sat", "msat", "usd", "eur"]: + if currency in available_currencies: + test_currencies.append((currency, test_amounts[currency])) + else: + print(f"⚠️ Skipping {currency} - not available on mint") + + if not test_currencies: + pytest.skip("No expected currencies available on test mint") + + # Track minted amounts + minted_by_currency: dict[str, int] = {} + + for currency, amount in test_currencies: + print(f"\n Minting {amount} {currency}...") + + try: + # Create mint quote for specific currency + invoice: str + task: Any + invoice, task = await wallet.mint_async( + amount=amount, currency=cast(CurrencyUnit, currency), timeout=60 + ) + print(f" Invoice created: {invoice[:50]}...") + + # Wait for auto-payment + timeout: float = 30.0 if os.getenv("USE_LOCAL_SERVICES") else 90.0 + paid: bool = await asyncio.wait_for(task, timeout=timeout) + + if paid: + print(f" ✓ Successfully minted {amount} {currency}") + minted_by_currency[currency] = amount + else: + print(f" ✗ Failed to mint {currency} - invoice not paid") + + # Wait for events to propagate + await asyncio.sleep(get_relay_wait_time(2.0)) + + # Add extra delay between currencies to avoid relay rate limiting + if currency != test_currencies[-1][0]: # Not the last currency + await asyncio.sleep(get_relay_wait_time(5.0)) + + except Exception as e: + print(f" ✗ Error minting {currency}: {e}") + # Continue with other currencies + + # Verify balances increased for each currency + if minted_by_currency: + print("\n📊 Verifying currency-specific balances...") + + # Fetch updated state with retries + max_retries: int = 5 + for attempt in range(max_retries): + state = await wallet.fetch_wallet_state(check_proofs=True) + balance_by_unit: dict[str, int] = { + str(k): v for k, v in state.balance_by_unit.items() + } + + # Check if all minted currencies show up + all_present: bool = all( + currency in balance_by_unit and balance_by_unit[currency] >= amount + for currency, amount in minted_by_currency.items() + ) + + if all_present: + break + + if attempt < max_retries - 1: + print( + f" Retry {attempt + 1}: Waiting for all balances to update..." + ) + await asyncio.sleep(get_relay_wait_time(3.0)) + + print(f" Final balances by unit: {balance_by_unit}") + + # Verify each minted currency + for currency, expected_amount in minted_by_currency.items(): + actual_balance: int = balance_by_unit.get(currency, 0) + initial_balance: int = { + str(k): v for k, v in initial_balance_by_unit.items() + }.get(currency, 0) + + assert actual_balance >= initial_balance + expected_amount, ( + f"Balance for {currency} should increase by at least {expected_amount}, " + f"got {actual_balance - initial_balance}" + ) + print(f" ✓ {currency}: {actual_balance} (minted {expected_amount})") + + async def test_send_tokens_different_currencies(self, wallet: Wallet) -> None: + """Test sending tokens in different currencies.""" + print("\n📤 Testing sending tokens in different currencies...") + + # Add delay to reset any rate limits from previous tests + await asyncio.sleep(get_relay_wait_time(10.0)) + + # First ensure we have some balance in different currencies + state = await wallet.fetch_wallet_state(check_proofs=True) + balance_by_unit: dict[str, int] = { + str(k): v for k, v in state.balance_by_unit.items() + } + + if not balance_by_unit: + print(" No balances found, minting first...") + # Mint some tokens first + await self.test_mint_in_different_currencies(wallet) + state = await wallet.fetch_wallet_state(check_proofs=True) + balance_by_unit = {str(k): v for k, v in state.balance_by_unit.items()} + + print(f" Current balances: {balance_by_unit}") + + # Test sending for each available currency + sent_tokens: dict[str, tuple[str, int]] = {} + + for currency, balance in balance_by_unit.items(): + if balance < 1: + print(f" ⚠️ Skipping {currency} - insufficient balance ({balance})") + continue + + # Determine amount to send (small amount, but at least 1) + if currency == "msat": + send_amount: int = ( + min(1000, balance // 2) if balance >= 2000 else balance + ) + else: + send_amount = 1 # Always send 1 unit for other currencies + + print(f"\n Sending {send_amount} {currency}...") + + try: + # Create token for specific currency + token: str = await wallet.send( + amount=send_amount, + unit=currency, # type: ignore + token_version=4, # Use V4 format + ) + + # Verify token was created + assert token.startswith("cashuB"), "Should create V4 token" + print(f" ✓ Created token: {token[:50]}...") + + # Parse token to verify currency + mint_url: str + token_unit: str + proofs: list[Any] + mint_url, token_unit, proofs = wallet._parse_cashu_token(token) + assert token_unit == currency, ( + f"Token should be in {currency}, got {token_unit}" + ) + assert sum(p["amount"] for p in proofs) == send_amount + + sent_tokens[currency] = (token, send_amount) + + # Wait for state update + await asyncio.sleep(get_relay_wait_time(1.0)) + + except Exception as e: + print(f" ✗ Error sending {currency}: {e}") + + # Verify balances decreased + if sent_tokens: + print("\n📊 Verifying balance changes after sending...") + + # Fetch updated state + updated_state = await wallet.fetch_wallet_state(check_proofs=True) + updated_balance_by_unit: dict[str, int] = { + str(k): v for k, v in updated_state.balance_by_unit.items() + } + + for currency, (token, amount) in sent_tokens.items(): + initial: int = balance_by_unit.get(currency, 0) + current: int = updated_balance_by_unit.get(currency, 0) + + # Balance should decrease by at least the sent amount (might be more due to fees) + assert current <= initial - amount, ( + f"Balance for {currency} should decrease by at least {amount}, " + f"was {initial}, now {current}" + ) + + actual_decrease: int = initial - current + fees: int = actual_decrease - amount + + if fees > 0: + print( + f" ✓ {currency}: {initial} → {current} (-{amount} sent, -{fees} fees)" + ) + else: + print(f" ✓ {currency}: {initial} → {current} (-{amount})") + + async def test_redeem_multi_currency_tokens(self, wallet: Wallet) -> None: + """Test redeeming tokens of different currencies.""" + print("\n📥 Testing redeeming multi-currency tokens...") + + # First create some tokens to redeem + print(" Creating tokens to redeem...") + + # Get current state + initial_state = await wallet.fetch_wallet_state(check_proofs=True) + initial_balances: dict[CurrencyUnit, int] = initial_state.balance_by_unit.copy() + + # Create tokens in available currencies + tokens_to_redeem: dict[str, tuple[str, int]] = {} + + for currency, balance in initial_balances.items(): + if balance < 1: + continue + + amount: int = 1 if currency != "msat" else 1000 + if balance < amount: + continue + + try: + token: str = await wallet.send( + amount=amount, unit=currency, token_version=3 + ) + tokens_to_redeem[currency] = (token, amount) + print(f" Created {currency} token for {amount}") + except Exception as e: + print(f" Skipped {currency}: {e}") + + if not tokens_to_redeem: + pytest.skip("No tokens could be created for redemption test") + + # Wait for state to update + await asyncio.sleep(get_relay_wait_time(2.0)) + + # Now redeem each token + print("\n Redeeming tokens...") + + for currency_str, (token, amount) in tokens_to_redeem.items(): + print(f"\n Redeeming {amount} {currency_str} token...") + + try: + # Get balance before redemption + state_before = await wallet.fetch_wallet_state(check_proofs=True) + balance_before: int = { + str(k): v for k, v in state_before.balance_by_unit.items() + }.get(currency_str, 0) + + # Redeem token + redeem_result = await wallet.redeem(token) + redeemed_amount: int + unit: str + redeemed_amount, unit = redeem_result + + assert unit == currency_str, ( + f"Redeemed unit should be {currency_str}, got {unit}" + ) + # Amount might be less due to fees + assert redeemed_amount <= amount, ( + "Redeemed amount should not exceed sent amount" + ) + + print( + f" ✓ Redeemed {redeemed_amount} {unit} (fees: {amount - redeemed_amount})" + ) + + # Wait for state update + await asyncio.sleep(get_relay_wait_time(2.0)) + + # Verify balance increased + state_after = await wallet.fetch_wallet_state(check_proofs=True) + balance_after: int = { + str(k): v for k, v in state_after.balance_by_unit.items() + }.get(currency_str, 0) + + assert balance_after == balance_before + redeemed_amount, ( + f"Balance should increase by {redeemed_amount}, " + f"was {balance_before}, now {balance_after}" + ) + + except Exception as e: + print(f" ✗ Error redeeming {currency_str}: {e}") + + async def test_keyset_specific_operations(self, wallet: Wallet) -> None: + """Test operations with specific keysets.""" + print("\n🔐 Testing keyset-specific operations...") + + mint: Mint = wallet._get_mint(wallet._primary_mint_url()) + + # Get all active keysets + keysets: list[KeysetInfo] = await mint.get_keysets_info() + active_keysets: list[KeysetInfo] = [k for k in keysets if k.get("active", True)] + + print(f" Found {len(active_keysets)} active keysets") + + # Get current proofs grouped by keyset + state = await wallet.fetch_wallet_state(check_proofs=True) + proofs_by_keyset: dict[str, list[Any]] = state.proofs_by_keyset + + print("\n Current proofs by keyset:") + for keyset_id, proofs in proofs_by_keyset.items(): + keyset_info: KeysetInfo | None = next( + (k for k in keysets if k["id"] == keyset_id), None + ) + if keyset_info: + total: int = sum(p["amount"] for p in proofs) + print( + f" {keyset_id}: {len(proofs)} proofs, {total} {keyset_info.get('unit', '?')}" + ) + else: + print(f" {keyset_id}: {len(proofs)} proofs (keyset info not found)") + + # Test denomination optimization per keyset + if proofs_by_keyset: + print("\n Testing denomination optimization...") + + for keyset_id, proofs in list(proofs_by_keyset.items())[ + :1 + ]: # Test first keyset + if len(proofs) < 2: + print(f" Skipping {keyset_id} - not enough proofs") + continue + + keyset_info = next((k for k in keysets if k["id"] == keyset_id), None) + if not keyset_info: + continue + + unit: str = keyset_info.get("unit", "sat") + total_amount: int = sum(p["amount"] for p in proofs) + + print( + f"\n Optimizing {len(proofs)} proofs totaling {total_amount} {unit}..." + ) + + # Get optimal denominations + available_denoms: list[int] = await mint.get_denominations_for_currency( + cast(CurrencyUnit, unit) + ) + optimal_denoms: dict[int, int] = mint.calculate_optimal_split( + total_amount, available_denoms + ) + + print(f" Current denominations: {[p['amount'] for p in proofs]}") + print(f" Optimal denominations: {optimal_denoms}") + + # The wallet should handle consolidation automatically + # Just verify the calculation works + optimal_count: int = sum(optimal_denoms.values()) + current_count: int = len(proofs) + + if optimal_count < current_count: + print( + f" → Could reduce from {current_count} to {optimal_count} proofs" + ) + else: + print(" → Already optimal or close to optimal") + + async def test_multi_mint_multi_currency(self, wallet: Wallet) -> None: + """Test multi-currency operations across multiple mints if available.""" + print("\n🏪 Testing multi-mint multi-currency support...") + + # This test would require multiple test mints + # For now, just verify the wallet can handle the concept + + # Check wallet's mint URLs + print(f" Wallet mint URLs: {wallet.mint_urls}") + + if len(wallet.mint_urls) < 2: + print(" ⚠️ Only one mint configured, skipping multi-mint test") + pytest.skip("Multi-mint test requires multiple mints") + + # Get info from each mint + mint_info: dict[str, dict[str, Any]] = {} + for mint_url in sorted(wallet.mint_urls)[:2]: # Test first 2 mints + try: + mint: Mint = wallet._get_mint(mint_url) + info: MintInfo = await mint.get_info() + currencies: list[CurrencyUnit] = await mint.get_currencies() + mint_info[mint_url] = { + "name": info.get("name", "Unknown"), + "currencies": currencies, + } + print(f"\n Mint: {mint_url}") + print(f" Name: {info.get('name', 'Unknown')}") + print(f" Currencies: {currencies}") + except Exception as e: + print(f" ✗ Error getting info from {mint_url}: {e}") + + # If we have multiple mints with different currencies, we could test + # cross-mint operations here + + print("\n Multi-mint multi-currency infrastructure is in place") + + +async def main() -> None: + """Run tests directly.""" + import sys + + sys.exit(pytest.main([__file__, "-v"])) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/tests/integration/test_relay_lookup.py b/tests/integration/test_relay_lookup.py deleted file mode 100644 index d1967af..0000000 --- a/tests/integration/test_relay_lookup.py +++ /dev/null @@ -1,829 +0,0 @@ -"""Relay integration tests. - -Tests relay connectivity, event publishing/fetching, and event management -against real public Nostr relays. Uses generated nsec keys for each test. -Only runs when RUN_INTEGRATION_TESTS environment variable is set. -""" - -import asyncio -import json -import os -import time -from uuid import uuid4 - -import pytest -from coincurve import PrivateKey - -from sixty_nuts.crypto import ( - generate_privkey, - get_pubkey, - sign_event, - nip44_encrypt, - nip44_decrypt, -) -from sixty_nuts.relay import ( - NostrRelay, - QueuedNostrRelay, - RelayPool, - RelayManager, - EventKind, - create_event, - NostrEvent, - NostrFilter, -) -from sixty_nuts.events import EventManager -from sixty_nuts.types import ProofDict - - -# Skip all integration tests unless explicitly enabled -pytestmark = pytest.mark.skipif( - not os.getenv("RUN_INTEGRATION_TESTS"), - reason="Integration tests only run when RUN_INTEGRATION_TESTS is set", -) - -TEST_RELAYS = [ - "wss://relay.damus.io", - "wss://relay.nostr.band", - "wss://relay.snort.social", -] - - -@pytest.fixture -def test_privkey(): - """Generate a test nostr private key.""" - hex_key = generate_privkey() - return PrivateKey(bytes.fromhex(hex_key)) - - -@pytest.fixture -def test_pubkey(test_privkey): - """Get the public key for test private key.""" - return get_pubkey(test_privkey) - - -@pytest.fixture -def test_mint_urls(): - """Test mint URLs for event manager tests. - - Uses local Docker mint when USE_LOCAL_SERVICES is set, - otherwise uses public test mint. - """ - if os.getenv("USE_LOCAL_SERVICES"): - return ["http://localhost:3338"] - else: - return ["https://testnut.cashu.space"] - - -@pytest.fixture -async def test_relay(): - """Create a test relay connection.""" - relay = NostrRelay(TEST_RELAYS[0]) - try: - await relay.connect() - yield relay - finally: - await relay.disconnect() - - -@pytest.fixture -async def test_queued_relay(): - """Create a test queued relay connection.""" - relay = QueuedNostrRelay(TEST_RELAYS[1], batch_size=5, batch_interval=0.5) - try: - await relay.connect() - await relay.start_queue_processor() - yield relay - finally: - await relay.disconnect() - - -@pytest.fixture -async def test_relay_manager(test_privkey): - """Create a test relay manager.""" - manager = RelayManager( - relay_urls=TEST_RELAYS[:2], - privkey=test_privkey, - use_queued_relays=True, - min_relay_interval=0.1, - ) - yield manager - await manager.disconnect_all() - - -@pytest.fixture -async def test_event_manager(test_relay_manager, test_privkey, test_mint_urls): - """Create a test event manager.""" - manager = EventManager(test_relay_manager, test_privkey, test_mint_urls) - yield manager - - -class TestBasicRelayOperations: - """Test basic relay connectivity and operations.""" - - async def test_relay_connection(self, test_relay) -> None: - """Test basic relay connection.""" - # Relay should be connected from fixture - assert test_relay.ws is not None - assert test_relay.ws.close_code is None - - async def test_relay_reconnection(self) -> None: - """Test relay reconnection after disconnect.""" - relay = NostrRelay(TEST_RELAYS[0]) - - # Connect - await relay.connect() - assert relay.ws is not None - - # Disconnect - await relay.disconnect() - - # Reconnect - await relay.connect() - assert relay.ws is not None - assert relay.ws.close_code is None - - await relay.disconnect() - - async def test_relay_timeout_handling(self) -> None: - """Test relay timeout handling.""" - # Use a non-existent relay to test timeout - relay = NostrRelay("wss://nonexistent.relay.example.com") - - with pytest.raises(Exception): # Should raise RelayError or timeout - await relay.connect() - - async def test_fetch_events_basic(self, test_relay) -> None: - """Test fetching events with basic filters.""" - # Fetch recent events from relay - filters: list[NostrFilter] = [ - { - "kinds": [1], # Text notes - "limit": 5, - } - ] - - events = await test_relay.fetch_events(filters, timeout=10.0) - - # Should get some events (public relay should have activity) - assert isinstance(events, list) - # Note: Can't assert len > 0 as relay might be empty or filtered - - async def test_fetch_events_with_timeout(self, test_relay) -> None: - """Test event fetching with short timeout.""" - filters: list[NostrFilter] = [ - { - "kinds": [99999], # Unlikely kind - "limit": 100, - } - ] - - start_time = time.time() - events = await test_relay.fetch_events(filters, timeout=2.0) - elapsed = time.time() - start_time - - # Should complete within timeout - assert elapsed < 3.0 - assert isinstance(events, list) - - -class TestEventPublishing: - """Test event publishing and retrieval.""" - - async def test_publish_and_fetch_text_note( - self, test_relay, test_privkey, test_pubkey - ) -> None: - """Test publishing a text note and fetching it back.""" - # Create a unique text note - unique_content = f"Test note {uuid4().hex[:8]} at {int(time.time())}" - - # Create unsigned event - unsigned_event = create_event( - kind=1, # Text note - content=unique_content, - tags=[], - ) - - # Sign the event - signed_event = sign_event(unsigned_event, test_privkey) - - # Publish event - event_dict = NostrEvent(**signed_event) # type: ignore - published = await test_relay.publish_event(event_dict) - - if not published: - pytest.skip("Relay rejected the event (possible rate limiting)") - - # Wait a moment for propagation - await asyncio.sleep(2) - - # Try to fetch it back - filters: list[NostrFilter] = [ - { - "authors": [test_pubkey], - "kinds": [1], - "limit": 10, - } - ] - - events = await test_relay.fetch_events(filters, timeout=5.0) - - # Look for our event - found_event = None - for event in events: - if event["content"] == unique_content: - found_event = event - break - - # Should find our published event - assert found_event is not None, ( - f"Could not find published event with content: {unique_content}" - ) - assert found_event["pubkey"] == test_pubkey - assert found_event["kind"] == 1 - - async def test_publish_wallet_metadata_event( - self, test_relay, test_privkey, test_pubkey - ) -> None: - """Test publishing a wallet metadata event (kind 17375).""" - # Create wallet metadata content - content_data = [ - ["privkey", "test_wallet_privkey"], - ["mint", "https://mint.example.com"], - ] - - # Encrypt content - content_json = json.dumps(content_data) - encrypted_content = nip44_encrypt(content_json, test_privkey) - - # Create unsigned event - unsigned_event = create_event( - kind=EventKind.Wallet, - content=encrypted_content, - tags=[["mint", "https://mint.example.com"]], - ) - - # Sign and publish - signed_event = sign_event(unsigned_event, test_privkey) - event_dict = NostrEvent(**signed_event) # type: ignore - published = await test_relay.publish_event(event_dict) - - if not published: - pytest.skip("Relay rejected the wallet event") - - # Wait for propagation - await asyncio.sleep(2) - - # Fetch it back - filters: list[NostrFilter] = [ - { - "authors": [test_pubkey], - "kinds": [EventKind.Wallet], - "limit": 5, - } - ] - - events = await test_relay.fetch_events(filters, timeout=5.0) - - # Find our event - found_event = None - for event in events: - if event["id"] == signed_event["id"]: - found_event = event - break - - assert found_event is not None, "Could not find published wallet event" - - # Verify we can decrypt the content - decrypted = nip44_decrypt(found_event["content"], test_privkey) - decrypted_data = json.loads(decrypted) - - assert decrypted_data == content_data - assert found_event["kind"] == EventKind.Wallet - - async def test_publish_delete_event( - self, test_relay, test_privkey, test_pubkey - ) -> None: - """Test publishing and then deleting an event.""" - # First, publish a test event - test_content = f"Test event to delete {uuid4().hex[:8]}" - - unsigned_event = create_event( - kind=1, - content=test_content, - tags=[], - ) - - signed_event = sign_event(unsigned_event, test_privkey) - event_dict = NostrEvent(**signed_event) # type: ignore - published = await test_relay.publish_event(event_dict) - - if not published: - pytest.skip("Relay rejected the initial event") - - target_event_id = signed_event["id"] - - # Wait for propagation - await asyncio.sleep(1) - - # Now publish a delete event - delete_event = create_event( - kind=EventKind.Delete, - content="", - tags=[ - ["e", target_event_id], - ["k", "1"], - ], - ) - - signed_delete = sign_event(delete_event, test_privkey) - delete_dict = NostrEvent(**signed_delete) # type: ignore - delete_published = await test_relay.publish_event(delete_dict) - - # Delete event should be published successfully - if delete_published: - # Wait for delete to propagate - await asyncio.sleep(2) - - # Try to fetch the original event - it might still be there - # (depends on relay implementation of NIP-09) - filters: list[NostrFilter] = [ - { - "authors": [test_pubkey], - "kinds": [1], - "limit": 10, - } - ] - - await test_relay.fetch_events(filters, timeout=5.0) - - # Check if original event is still present - # Note: Not all relays implement deletion, so we just verify the delete was accepted - print(f"Published delete event for {target_event_id}") - - -class TestQueuedRelayOperations: - """Test queued relay operations.""" - - async def test_queued_event_publishing( - self, test_queued_relay, test_privkey - ) -> None: - """Test publishing events through the queue system.""" - # Create multiple test events - events_to_publish = [] - - for i in range(3): - content = f"Queued test event {i} - {uuid4().hex[:8]}" - unsigned_event = create_event( - kind=1, - content=content, - tags=[], - ) - signed_event = sign_event(unsigned_event, test_privkey) - events_to_publish.append((content, signed_event)) - - # Publish events with different priorities - publish_results = [] - for i, (content, signed_event) in enumerate(events_to_publish): - event_dict = NostrEvent(**signed_event) # type: ignore - - # Use callback to track completion - completion_event = asyncio.Event() - publish_result: dict[str, bool | str | None] = { - "success": False, - "error": None, - } - - def callback(success: bool, error: str | None) -> None: - publish_result["success"] = success - publish_result["error"] = error - completion_event.set() - - # Queue the event with priority (higher number = higher priority) - await test_queued_relay.publish_event( - event_dict, - priority=10 - i, # First event gets highest priority - callback=callback, - ) - - publish_results.append((completion_event, publish_result, content)) - - # Wait for all events to be processed - timeout_duration = 10.0 - for completion_event, publish_result, content in publish_results: - try: - await asyncio.wait_for( - completion_event.wait(), timeout=timeout_duration - ) - print( - f"Event published: {content} (success: {publish_result['success']})" - ) - except asyncio.TimeoutError: - print(f"Timeout waiting for event: {content}") - - # Check that at least some events were successful - successful_events = [ - result for _, result, _ in publish_results if result["success"] - ] - - # At least one event should succeed (unless relay is heavily rate-limited) - if len(successful_events) == 0: - pytest.skip("All events were rejected by relay (possible rate limiting)") - - async def test_queue_processor_lifecycle(self) -> None: - """Test starting and stopping the queue processor.""" - relay = QueuedNostrRelay(TEST_RELAYS[0], batch_size=2, batch_interval=0.5) - - try: - await relay.connect() - - # Start processor - await relay.start_queue_processor() - assert relay._processor_task is not None - assert not relay._processor_task.done() - - # Stop processor - await relay.stop_queue_processor() - assert relay._processor_task.done() - - finally: - await relay.disconnect() - - async def test_pending_proofs_tracking( - self, test_queued_relay, test_privkey - ) -> None: - """Test tracking of pending token events for proof data.""" - # Create a token event with proof data - proof_data = { - "mint": "https://mint.example.com", - "proofs": [ - { - "id": "test", - "amount": 10, - "secret": "dGVzdA==", - "C": "02abc123", - "mint": "https://mint.example.com", - } - ], - } - - content_json = json.dumps(proof_data) - encrypted_content = nip44_encrypt(content_json, test_privkey) - - unsigned_event = create_event( - kind=EventKind.Token, - content=encrypted_content, - tags=[], - ) - - signed_event = sign_event(unsigned_event, test_privkey) - event_dict = NostrEvent(**signed_event) # type: ignore - - # Publish with token data - await test_queued_relay.publish_event( - event_dict, - token_data=proof_data, - ) - - # Check pending proofs - pending_proofs = test_queued_relay.get_pending_proofs() - - # Should have our proof in pending - assert len(pending_proofs) > 0 - found_proof = False - for proof in pending_proofs: - if proof.get("amount") == 10 and proof.get("secret") == "dGVzdA==": - found_proof = True - break - - assert found_proof, "Should find our proof in pending list" - - -class TestRelayPoolOperations: - """Test relay pool functionality.""" - - async def test_relay_pool_creation_and_connection(self, test_privkey) -> None: - """Test creating and connecting a relay pool.""" - pool = RelayPool( - urls=TEST_RELAYS[:2], - batch_size=5, - batch_interval=0.5, - ) - - try: - # Connect all relays - await pool.connect_all() - - # Should have relay instances - assert len(pool.relays) == 2 - assert pool.shared_queue is not None - - # Test publishing through pool - unsigned_event = create_event( - kind=1, - content=f"Pool test {uuid4().hex[:8]}", - tags=[], - ) - - signed_event = sign_event(unsigned_event, test_privkey) - event_dict = NostrEvent(**signed_event) # type: ignore - - success = await pool.publish_event(event_dict) - assert success # Should queue successfully - - finally: - await pool.disconnect_all() - - -class TestRelayManagerOperations: - """Test relay manager functionality.""" - - async def test_relay_manager_initialization(self, test_relay_manager) -> None: - """Test relay manager initialization and connection.""" - # Get relay connections (should trigger discovery/connection) - relays = await test_relay_manager.get_relay_connections() - - assert len(relays) > 0, "Should connect to at least one relay" - assert test_relay_manager.relay_pool is not None - - async def test_publish_to_relays(self, test_relay_manager, test_privkey) -> None: - """Test publishing events through relay manager.""" - # Create test event - unsigned_event = create_event( - kind=1, - content=f"RelayManager test {uuid4().hex[:8]}", - tags=[], - ) - - # Publish through manager (handles signing internally) - event_id = await test_relay_manager.publish_to_relays(unsigned_event) - - assert len(event_id) > 0, "Should return valid event ID" - - async def test_fetch_wallet_events(self, test_relay_manager, test_pubkey) -> None: - """Test fetching wallet events through relay manager.""" - events = await test_relay_manager.fetch_wallet_events(test_pubkey) - - assert isinstance(events, list) - # Events list might be empty for fresh pubkey - - async def test_relay_discovery(self, test_privkey) -> None: - """Test relay discovery from kind:10019 events.""" - manager = RelayManager( - relay_urls=TEST_RELAYS[:1], - privkey=test_privkey, - use_queued_relays=False, - ) - - try: - # Try to discover relays - discovered = await manager.discover_relays() - - # Should return a list (might be empty if no recommendations found) - assert isinstance(discovered, list) - - finally: - await manager.disconnect_all() - - -class TestEventManagerOperations: - """Test event manager functionality.""" - - async def test_wallet_event_creation(self, test_event_manager) -> None: - """Test creating wallet events through event manager.""" - wallet_privkey_hex = generate_privkey() - - # Create wallet event - this might fail due to rate limiting - try: - event_id = await test_event_manager.create_wallet_event( - wallet_privkey_hex, force=True - ) - - assert len(event_id) > 0, "Should return valid event ID" - - # Wait longer for propagation on public relays - await asyncio.sleep(5) - - # Check if event exists - but skip if it was rate limited - exists, event = await test_event_manager.check_wallet_event_exists() - - # If the event doesn't exist, it might have been rate limited - # This is acceptable behavior with public relays - if not exists: - pytest.skip( - "Wallet event not found - likely rate limited by public relay" - ) - - assert event is not None - - except Exception as e: - error_msg = str(e).lower() - if "rate" in error_msg or "limit" in error_msg or "too much" in error_msg: - pytest.skip(f"Event creation rate limited by relay: {e}") - else: - # Re-raise if it's not a rate limiting error - raise - - async def test_token_event_publishing(self, test_event_manager) -> None: - """Test publishing token events through event manager.""" - # Create test proofs - test_proofs: list[ProofDict] = [ - { - "id": "test", - "amount": 10, - "secret": "746573742d736563726574", # hex secret - "C": "02abc123def456", - "mint": "https://mint.example.com", - }, - { - "id": "test", - "amount": 5, - "secret": "616e6f746865722d736563726574", # hex secret - "C": "02def456abc123", - "mint": "https://mint.example.com", - }, - ] - - # Publish token event - try: - event_id = await test_event_manager.publish_token_event(test_proofs) - assert len(event_id) > 0, "Should return valid event ID" - except Exception as e: - error_msg = str(e).lower() - if "rate" in error_msg or "limit" in error_msg or "too much" in error_msg: - pytest.skip(f"Token event publishing rate limited: {e}") - else: - raise - - async def test_spending_history_publishing(self, test_event_manager) -> None: - """Test publishing spending history events.""" - # Publish history event - try: - event_id = await test_event_manager.publish_spending_history( - direction="out", - amount=25, - created_token_ids=["event1", "event2"], - destroyed_token_ids=["event3"], - ) - - assert len(event_id) > 0, "Should return valid event ID" - - # Wait for propagation - await asyncio.sleep(3) - - # Fetch spending history - history = await test_event_manager.fetch_spending_history() - - # Should find our history entry (but might be empty due to rate limiting) - found_entry = None - for entry in history: - if entry.get("direction") == "out" and entry.get("amount") == "25": - found_entry = entry - break - - # Don't assert if not found - could be rate limited - if found_entry is None: - print( - "Warning: Published history entry not found in fetch - possible rate limiting" - ) - - except Exception as e: - error_msg = str(e).lower() - if "rate" in error_msg or "limit" in error_msg or "too much" in error_msg: - pytest.skip(f"History event publishing rate limited: {e}") - else: - raise - - async def test_nip60_proof_conversion(self, test_event_manager) -> None: - """Test NIP-60 proof format conversion.""" - # Test proof with hex secret - hex_proof: ProofDict = { - "id": "test", - "amount": 10, - "secret": "746573742d736563726574", # hex - "C": "02abc123", - "mint": "https://mint.example.com", - } - - # Convert to NIP-60 format (base64) - nip60_proof = test_event_manager._convert_proof_to_nip60(hex_proof) - - # Secret should be base64 now - assert nip60_proof["secret"] != hex_proof["secret"] - - # Convert back to internal format - internal_proof = test_event_manager._convert_proof_from_nip60(nip60_proof) - - # Should match original - assert internal_proof["secret"] == hex_proof["secret"] - - async def test_event_count_operations(self, test_event_manager) -> None: - """Test counting various event types.""" - # Count token events - token_count = await test_event_manager.count_token_events() - assert token_count >= 0 - - # Try to publish a token event to increment count - test_proofs: list[ProofDict] = [ - { - "id": "test", - "amount": 1, - "secret": "746573742d736563726574", - "C": "02abc123", - "mint": "https://mint.example.com", - } - ] - - try: - await test_event_manager.publish_token_event(test_proofs) - - # Wait for propagation - await asyncio.sleep(3) - - # Count should increase (but might not due to rate limiting) - new_token_count = await test_event_manager.count_token_events() - - # Don't assert strict increase due to possible rate limiting - assert new_token_count >= token_count - - except Exception as e: - error_msg = str(e).lower() - if "rate" in error_msg or "limit" in error_msg or "too much" in error_msg: - pytest.skip(f"Event count test rate limited: {e}") - else: - raise - - -class TestRelayErrorHandling: - """Test error handling in relay operations.""" - - async def test_connection_error_handling(self) -> None: - """Test handling of connection errors.""" - # Try to connect to non-existent relay - relay = NostrRelay("wss://nonexistent.relay.nowhere") - - with pytest.raises(Exception): - await relay.connect() - - async def test_invalid_event_publishing(self, test_relay) -> None: - """Test publishing invalid events.""" - # Create invalid event (missing required fields) - invalid_event = { - "kind": 1, - "content": "test", - # Missing required fields like id, pubkey, sig - } - - # This should return False (rejected) or timeout, not raise an exception - # The relay will reject it but publish_event handles this gracefully - result = await test_relay.publish_event(invalid_event) # type: ignore - assert result is False, "Invalid event should be rejected by relay" - - async def test_timeout_handling(self, test_relay) -> None: - """Test timeout handling in fetch operations.""" - # Use very short timeout - filters: list[NostrFilter] = [ - { - "kinds": [99999], # Unlikely kind - "limit": 1000, - } - ] - - start_time = time.time() - events = await test_relay.fetch_events(filters, timeout=0.5) - elapsed = time.time() - start_time - - # Should respect timeout - assert elapsed < 1.0 - assert isinstance(events, list) - - -if __name__ == "__main__": - # Allow running this file directly for debugging - import sys - - if not os.getenv("RUN_INTEGRATION_TESTS"): - print("Set RUN_INTEGRATION_TESTS=1 to run relay integration tests") - sys.exit(1) - - # Run a simple test - async def main() -> None: - print("Running basic relay integration test...") - - # Test basic connection - relay = NostrRelay(TEST_RELAYS[0]) - try: - await relay.connect() - print(f"✅ Connected to {TEST_RELAYS[0]}") - - # Test basic fetch - filters: list[NostrFilter] = [{"kinds": [1], "limit": 1}] - events = await relay.fetch_events(filters, timeout=5.0) - print(f"✅ Fetched {len(events)} events") - - except Exception as e: - print(f"❌ Error: {e}") - finally: - await relay.disconnect() - print("✅ Disconnected") - - asyncio.run(main()) diff --git a/tests/integration/test_wallet_complete_flow.py b/tests/integration/test_wallet_complete_flow.py index db4b350..11e2012 100644 --- a/tests/integration/test_wallet_complete_flow.py +++ b/tests/integration/test_wallet_complete_flow.py @@ -13,10 +13,17 @@ import asyncio import os import pytest +from typing import Any from sixty_nuts.wallet import Wallet from sixty_nuts.crypto import generate_privkey -from sixty_nuts.types import ProofDict +from sixty_nuts.types import Proof +from sixty_nuts.mint import ( + Mint, + KeysetInfo, + PostMintQuoteResponse, + PostCheckStateResponse, +) pytestmark = pytest.mark.skipif( @@ -36,40 +43,58 @@ def get_relay_wait_time(base_seconds: float = 2.0) -> float: class TestWalletBasicOperations: """Test basic wallet operations that require live services.""" - async def test_wallet_creation_and_initialization(self, clean_wallet): + async def test_wallet_creation_and_initialization( + self, clean_wallet: Wallet + ) -> None: """Test wallet creation and initialization with live relay connections.""" - wallet = clean_wallet + wallet: Wallet = clean_wallet # Check initial state - balance = await wallet.get_balance(check_proofs=False) + balance: int = await wallet.get_balance(check_proofs=False) assert balance == 0 # Initialize wallet (requires relay connection) - initialized = await wallet.initialize_wallet(force=True) + # Generate a wallet private key if not set + if wallet.wallet_privkey is None: + import secrets + + wallet.wallet_privkey = secrets.token_hex(32) + + # Initialize through event manager + initialized: bool = await wallet.event_manager.initialize_wallet( + wallet.wallet_privkey, force=True + ) assert initialized is True # Give some time for the wallet event to propagate await asyncio.sleep(get_relay_wait_time(2.0)) # Check wallet event exists (requires relay connection) - exists, event = await wallet.check_wallet_event_exists() + exists: bool + event: Any + exists, event = await wallet.event_manager.check_wallet_event_exists() if not exists: # Try one more time in case of relay timing issues await asyncio.sleep(get_relay_wait_time(3.0)) - exists, event = await wallet.check_wallet_event_exists() + exists, event = await wallet.event_manager.check_wallet_event_exists() assert exists is True, "Wallet event should exist after initialization" assert event is not None - async def test_balance_check_empty_wallet(self, wallet): + async def test_balance_check_empty_wallet(self, wallet: Wallet) -> None: """Test balance checking on empty wallet.""" - balance = await wallet.get_balance() + balance: int = await wallet.get_balance() assert balance == 0 async def test_mint_quote_creation(self, wallet: Wallet) -> None: """Test creating mint quotes (requires mint API).""" - mint_url = wallet._primary_mint_url() - invoice, quote_id = await wallet.create_quote(50, mint_url) + mint_url: str = wallet._primary_mint_url() + mint: Mint = wallet._get_mint(mint_url) + response: PostMintQuoteResponse = await mint.create_mint_quote( + amount=50, unit="sat" + ) + invoice: str = response["request"] + quote_id: str = response["quote"] assert invoice.startswith("lnbc") # BOLT11 invoice assert len(quote_id) > 0 @@ -82,7 +107,7 @@ async def test_mint_quote_creation(self, wallet: Wallet) -> None: class TestWalletMinting: """Test wallet minting operations that require mint API.""" - async def test_mint_async_flow(self, wallet): + async def test_mint_async_flow(self, wallet: Wallet) -> None: """Test asynchronous minting flow with auto-paying test mint.""" # Add delay between test classes for public relays if not os.getenv("USE_LOCAL_SERVICES"): @@ -90,31 +115,35 @@ async def test_mint_async_flow(self, wallet): await asyncio.sleep(15.0) # 15 second delay for public relays # Create invoice - test mint should auto-pay + invoice: str + task: Any invoice, task = await wallet.mint_async(25) print(f"Created invoice: {invoice}") # Wait for the auto-payment to complete try: # Give reasonable time for auto-payment (longer for public relays) - timeout = ( + timeout: float = ( 30.0 if os.getenv("USE_LOCAL_SERVICES") else 90.0 ) # Increased from 60s - paid = await asyncio.wait_for(task, timeout=timeout) + paid: bool = await asyncio.wait_for(task, timeout=timeout) assert paid is True, "Invoice should be auto-paid by test mint" # Give time for token events to propagate to relay await asyncio.sleep(get_relay_wait_time(2.0)) # Verify balance increased with retry for rate limiting - max_balance_retries = 8 # More retries for heavily rate-limited tests - base_delay = get_relay_wait_time(2.0) + max_balance_retries: int = 8 # More retries for heavily rate-limited tests + base_delay: float = get_relay_wait_time(2.0) + balance: int = 0 + for attempt in range(max_balance_retries): balance = await wallet.get_balance() if balance >= 25: break if attempt < max_balance_retries - 1: # Exponential backoff for heavy rate limiting - delay = base_delay * (1.5**attempt) + delay: float = base_delay * (1.5**attempt) print( f"Balance check attempt {attempt + 1}: {balance} sats, retrying in {delay:.1f}s..." ) @@ -139,7 +168,7 @@ async def test_mint_async_flow(self, wallet): class TestWalletTransactions: """Test wallet transaction operations that require mint validation.""" - async def test_send_insufficient_balance(self, wallet): + async def test_send_insufficient_balance(self, wallet: Wallet) -> None: # Add delay between test classes for public relays if not os.getenv("USE_LOCAL_SERVICES"): print("Adding delay between test classes to avoid rate limiting...") @@ -151,33 +180,37 @@ async def test_send_insufficient_balance(self, wallet): assert "insufficient" in str(exc_info.value).lower() - async def test_complete_mint_send_redeem_flow(self, wallet): + async def test_complete_mint_send_redeem_flow(self, wallet: Wallet) -> None: """Test complete end-to-end flow: mint → send → redeem. This test would have caught the balance calculation bug since it exercises the full proof swapping logic with actual mint validation. """ # 1. Start with empty wallet - initial_balance = await wallet.get_balance() + initial_balance: int = await wallet.get_balance() assert initial_balance == 0 # 2. Mint some tokens (fund the wallet) - mint_amount = 100 + mint_amount: int = 100 + invoice: str + task: Any invoice, task = await wallet.mint_async(mint_amount) print(f"Created invoice for {mint_amount} sats: {invoice}") # Wait for auto-payment (longer timeout for public relays) - timeout = ( + timeout: float = ( 30.0 if os.getenv("USE_LOCAL_SERVICES") else 90.0 ) # Increased from 60s - paid = await asyncio.wait_for(task, timeout=timeout) + paid: bool = await asyncio.wait_for(task, timeout=timeout) assert paid is True, "Invoice should be auto-paid by test mint" # Give time for token events to propagate to relay await asyncio.sleep(get_relay_wait_time(2.0)) # Verify wallet is funded with retry for rate limiting - max_funded_retries = 5 # More retries for the main test + max_funded_retries: int = 5 + funded_balance: int = 0 + for attempt in range(max_funded_retries): funded_balance = await wallet.get_balance() if funded_balance >= mint_amount: @@ -204,8 +237,8 @@ async def test_complete_mint_send_redeem_flow(self, wallet): print(f" - {p['amount']} sats") # 3. Send some tokens - send_amount = 25 - token = await wallet.send(send_amount) + send_amount: int = 25 + token: str = await wallet.send(send_amount) assert token.startswith("cashu"), "Should receive valid Cashu token" print(f"\nCreated token for {send_amount} sats") @@ -214,7 +247,7 @@ async def test_complete_mint_send_redeem_flow(self, wallet): get_relay_wait_time(2.0) ) # Give time for events to propagate state = await wallet.fetch_wallet_state() - balance_after_send = state.balance + balance_after_send: int = state.balance print( f"\nDEBUG after send: {len(state.proofs)} proofs, total {balance_after_send} sats" ) @@ -226,7 +259,10 @@ async def test_complete_mint_send_redeem_flow(self, wallet): # 4. Redeem the token (simulating receiving it) print("\nRedeeming the sent token...") - redeemed_amount, unit = await wallet.redeem(token) + redeem_result = await wallet.redeem(token) + redeemed_amount: int + unit: str + redeemed_amount, unit = redeem_result print( f"Redeemed {redeemed_amount} {unit} (fees deducted from original {send_amount})" ) @@ -236,14 +272,14 @@ async def test_complete_mint_send_redeem_flow(self, wallet): # 5. Verify final balance (accounting for fees) state = await wallet.fetch_wallet_state() - final_balance = state.balance + final_balance: int = state.balance print( f"\nDEBUG after redeem: {len(state.proofs)} proofs, total {final_balance} sats" ) for p in state.proofs: print(f" - {p['amount']} sats") - fees_paid = funded_balance - final_balance + fees_paid: int = funded_balance - final_balance print(f"Total lost to fees: {fees_paid} sats") # Basic sanity checks @@ -264,7 +300,7 @@ async def test_complete_mint_send_redeem_flow(self, wallet): print("✅ Complete mint → send → redeem flow successful!") - async def test_multiple_send_operations(self, wallet): + async def test_multiple_send_operations(self, wallet: Wallet) -> None: """Test multiple send operations to verify fee handling.""" # Add delay for public relays to avoid consecutive test rate limiting if not os.getenv("USE_LOCAL_SERVICES"): @@ -272,25 +308,29 @@ async def test_multiple_send_operations(self, wallet): await asyncio.sleep(10.0) # 10 second delay for public relays # Fund wallet - mint_amount = 200 + mint_amount: int = 200 + invoice: str + task: Any invoice, task = await wallet.mint_async(mint_amount) - timeout = 30.0 if os.getenv("USE_LOCAL_SERVICES") else 60.0 - paid = await asyncio.wait_for(task, timeout=timeout) + timeout: float = 30.0 if os.getenv("USE_LOCAL_SERVICES") else 60.0 + paid: bool = await asyncio.wait_for(task, timeout=timeout) assert paid is True # Give time for token events to propagate to relay await asyncio.sleep(get_relay_wait_time(2.0)) # Check initial balance with retry for rate limiting (more aggressive for consecutive tests) - max_initial_retries = 8 # More retries for rate-limited consecutive tests - base_delay = get_relay_wait_time(3.0) + max_initial_retries: int = 8 # More retries for rate-limited consecutive tests + base_delay: float = get_relay_wait_time(3.0) + initial_balance: int = 0 + for attempt in range(max_initial_retries): initial_balance = await wallet.get_balance() if initial_balance >= mint_amount: break if attempt < max_initial_retries - 1: # Exponential backoff for heavy rate limiting - delay = base_delay * (1.5**attempt) + delay: float = base_delay * (1.5**attempt) print( f"Initial balance check attempt {attempt + 1}: {initial_balance} sats, retrying in {delay:.1f}s..." ) @@ -301,20 +341,20 @@ async def test_multiple_send_operations(self, wallet): ) # Perform a few small sends - send_amounts = [10, 5, 20, 1] - tokens = [] + send_amounts: list[int] = [10, 5, 20, 1] + tokens: list[tuple[int, str]] = [] for amount in send_amounts: try: print(f"\nSending {amount} sats...") - balance_before = await wallet.get_balance() - token = await wallet.send(amount) + balance_before: int = await wallet.get_balance() + token: str = await wallet.send(amount) tokens.append((amount, token)) # Give time for events to propagate await asyncio.sleep(get_relay_wait_time(1.0)) - balance_after = await wallet.get_balance() + balance_after: int = await wallet.get_balance() print(f"Balance: {balance_before} → {balance_after} (sent {amount})") # Balance should decrease by at least the sent amount @@ -326,17 +366,20 @@ async def test_multiple_send_operations(self, wallet): # Continue with other amounts # Redeem all tokens that were successfully sent - total_redeemed = 0 + total_redeemed: int = 0 for expected_amount, token in tokens: try: - redeemed_amount, unit = await wallet.redeem(token) + redeem_result = await wallet.redeem(token) + redeemed_amount: int + unit: str + redeemed_amount, unit = redeem_result total_redeemed += redeemed_amount print(f"Redeemed {redeemed_amount} {unit}") except Exception as e: print(f"Failed to redeem token: {e}") # Final checks - final_balance = await wallet.get_balance() + final_balance: int = await wallet.get_balance() print( f"\nInitial: {initial_balance}, Final: {final_balance}, Redeemed: {total_redeemed}" ) @@ -354,121 +397,101 @@ async def test_multiple_send_operations(self, wallet): class TestWalletRelayOperations: """Test wallet operations that require relay connections.""" - async def test_relay_connections(self, wallet): + async def test_relay_connections(self, wallet: Wallet) -> None: """Test relay connection establishment.""" # Wallet should have relay connections from initialization - assert len(wallet.relays) > 0 + assert len(getattr(wallet, "relays", [])) > 0 assert wallet.relay_manager is not None # Test that we can actually connect - relays = await wallet.relay_manager.get_relay_connections() + relays: list[Any] = await wallet.relay_manager.get_relay_connections() assert len(relays) > 0, "Should connect to at least one relay" - async def test_fetch_spending_history(self, wallet): + async def test_fetch_spending_history(self, wallet: Wallet) -> None: """Test fetching spending history from relays.""" - history = await wallet.fetch_spending_history() - assert isinstance(history, list) - # Fresh wallet should have minimal history + if hasattr(wallet, "fetch_spending_history"): + history: list[Any] = await wallet.fetch_spending_history() # type: ignore + assert isinstance(history, list) + # Fresh wallet should have minimal history + else: + # Method doesn't exist, skip test + pytest.skip("fetch_spending_history method not available") - async def test_count_token_events(self, wallet): + async def test_count_token_events(self, wallet: Wallet) -> None: """Test counting token events from relays.""" - count = await wallet.count_token_events() + count: int = await wallet.event_manager.count_token_events() assert count >= 0 # Should be 0 for fresh wallet - async def test_cleanup_wallet_state_dry_run(self, wallet): - """Test wallet state cleanup (requires relay connection to fetch events).""" - stats = await wallet.cleanup_wallet_state(dry_run=True) - - assert "total_events" in stats - assert "valid_events" in stats - assert "undecryptable_events" in stats - assert "empty_events" in stats - assert "balance" in stats - - # Should not have made any changes in dry run - assert stats["events_consolidated"] == 0 - assert stats["events_marked_superseded"] == 0 - class TestWalletMintIntegration: """Test operations that require actual mint API validation.""" - async def test_get_keysets_from_mint(self, wallet): + async def test_get_keysets_from_mint(self, wallet: Wallet) -> None: """Test getting keysets from real mint.""" - mint = wallet._get_mint(wallet._primary_mint_url()) - keysets_resp = await mint.get_keysets() + mint: Mint = wallet._get_mint(wallet._primary_mint_url()) + keysets_info: list[KeysetInfo] = await mint.get_keysets_info() - assert "keysets" in keysets_resp - keysets = keysets_resp["keysets"] + assert isinstance(keysets_info, list) + keysets: list[KeysetInfo] = keysets_info assert len(keysets) > 0, "Mint should have at least one keyset" - # Find keysets for our wallet's currency - wallet_currency_keysets = [ - ks - for ks in keysets - if ks.get("unit") == wallet.currency and ks.get("active", True) + # Find keysets for sat (default currency) + sat_keysets: list[KeysetInfo] = [ + ks for ks in keysets if ks.get("unit") == "sat" and ks.get("active", True) ] - assert len(wallet_currency_keysets) > 0, ( - f"Mint should have active keysets for {wallet.currency}" - ) + assert len(sat_keysets) > 0, "Mint should have active keysets for sat" # Verify keyset structure for our currency - for keyset in wallet_currency_keysets: + for keyset in sat_keysets: assert "id" in keyset assert "unit" in keyset - assert keyset["unit"] == wallet.currency # Should match wallet's currency - async def test_get_keys_from_mint(self, wallet): + async def test_get_keys_from_mint(self, wallet: Wallet) -> None: """Test getting public keys from real mint.""" - mint = wallet._get_mint(wallet._primary_mint_url()) + mint: Mint = wallet._get_mint(wallet._primary_mint_url()) # Get keysets first - keysets_resp = await mint.get_keysets() - keysets = keysets_resp["keysets"] + keysets: list[KeysetInfo] = await mint.get_keysets_info() if keysets: - keyset_id = keysets[0]["id"] - keys_resp = await mint.get_keys(keyset_id) - - assert "keysets" in keys_resp - mint_keysets = keys_resp["keysets"] - - # Find our keyset - for ks in mint_keysets: - if ks["id"] == keyset_id: - assert "keys" in ks - keys = ks["keys"] - assert isinstance(keys, dict) - assert len(keys) > 0, "Keyset should have public keys" - break + keyset_id: str = keysets[0]["id"] + # Get full keyset details with keys + keyset_full: Any = await mint.get_keyset(keyset_id) + + assert keyset_full is not None + assert "keys" in keyset_full + keys: dict[str, str] = keyset_full["keys"] + assert isinstance(keys, dict) + assert len(keys) > 0, "Keyset should have public keys" class TestWalletProofValidation: """Test proof validation against real mint.""" - async def test_proof_state_checking_empty(self, wallet): + async def test_proof_state_checking_empty(self, wallet: Wallet) -> None: """Test proof state checking with empty proofs list.""" - mint = wallet._get_mint(wallet._primary_mint_url()) + mint: Mint = wallet._get_mint(wallet._primary_mint_url()) # Empty Y values should return empty states - state_response = await mint.check_state(Ys=[]) + state_response: PostCheckStateResponse = await mint.check_state(Ys=[]) assert "states" in state_response assert len(state_response["states"]) == 0 - async def test_compute_proof_y_values(self, wallet): + async def test_compute_proof_y_values(self, wallet: Wallet) -> None: """Test Y value computation for proof validation.""" - mock_proofs = [ - ProofDict( + mock_proofs: list[Proof] = [ + Proof( id="test1", amount=10, secret="dGVzdA==", # base64 "test" C="02a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2", mint="test", + unit="sat", ), ] - y_values = wallet._compute_proof_y_values(mock_proofs) + y_values: list[str] = wallet._compute_proof_y_values(mock_proofs) assert len(y_values) == 1 assert len(y_values[0]) == 66 # 33 bytes * 2 hex chars = 66 chars assert all(c in "0123456789abcdefABCDEF" for c in y_values[0]), ( @@ -479,9 +502,9 @@ async def test_compute_proof_y_values(self, wallet): class TestWalletErrorHandling: """Test wallet error handling with live services.""" - async def test_insufficient_balance_error(self, wallet): + async def test_insufficient_balance_error(self, wallet: Wallet) -> None: """Test insufficient balance error handling.""" - balance = await wallet.get_balance() + balance: int = await wallet.get_balance() assert balance == 0 # Try to send more than balance @@ -500,13 +523,13 @@ async def test_insufficient_balance_error(self, wallet): sys.exit(1) # Run a simple test - async def main(): - nsec = generate_privkey() + async def main() -> None: + nsec: str = generate_privkey() # Use same logic as fixtures if os.getenv("USE_LOCAL_SERVICES"): - mint_urls = ["http://localhost:3338"] - relays = ["ws://localhost:8080"] + mint_urls: list[str] = ["http://localhost:3338"] + relays: list[str] = ["ws://localhost:8080"] else: mint_urls = ["https://testnut.cashu.space"] relays = [ @@ -514,20 +537,13 @@ async def main(): "wss://relay.nostr.band", ] - wallet = await Wallet.create( - nsec=nsec, - mint_urls=mint_urls, - currency="sat", - relays=relays, - auto_init=False, + wallet: Wallet = await Wallet.create( + nsec=nsec, mint_urls=mint_urls, relay_urls=relays, auto_init=False ) print("✅ Wallet created successfully") - await wallet.initialize_wallet(force=True) - print("✅ Wallet initialized") - - balance = await wallet.get_balance() + balance: int = await wallet.get_balance() print(f"✅ Balance: {balance} sats") await wallet.aclose() From 56e0d3cce345e10596e7f3345f1489a2e7ad5d5a Mon Sep 17 00:00:00 2001 From: Shroominic Date: Thu, 17 Jul 2025 16:23:38 -0300 Subject: [PATCH 09/17] fix tests --- tests/integration/test_wallet_complete_flow.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/integration/test_wallet_complete_flow.py b/tests/integration/test_wallet_complete_flow.py index 11e2012..3c86183 100644 --- a/tests/integration/test_wallet_complete_flow.py +++ b/tests/integration/test_wallet_complete_flow.py @@ -400,7 +400,7 @@ class TestWalletRelayOperations: async def test_relay_connections(self, wallet: Wallet) -> None: """Test relay connection establishment.""" # Wallet should have relay connections from initialization - assert len(getattr(wallet, "relays", [])) > 0 + assert len(getattr(wallet, "relay_urls", [])) > 0 assert wallet.relay_manager is not None # Test that we can actually connect From 62dbdfa1925e5f5f53af8b0d166057802b7d5bff Mon Sep 17 00:00:00 2001 From: Shroominic Date: Fri, 18 Jul 2025 20:15:39 -0300 Subject: [PATCH 10/17] fix tests --- .../test_advanced_wallet_operations.py | 8 +-- .../test_multi_currency_support.py | 2 +- .../integration/test_wallet_complete_flow.py | 5 +- tests/unit/test_nut02.py | 52 +++++++++---------- 4 files changed, 34 insertions(+), 33 deletions(-) diff --git a/tests/integration/test_advanced_wallet_operations.py b/tests/integration/test_advanced_wallet_operations.py index 35ba0ea..c66debd 100644 --- a/tests/integration/test_advanced_wallet_operations.py +++ b/tests/integration/test_advanced_wallet_operations.py @@ -75,7 +75,9 @@ async def test_comprehensive_transaction_flow_with_validation( initial_state = await wallet.fetch_wallet_state(check_proofs=False) assert len(initial_state.proofs) == 0, "Expected no proofs initially" - assert initial_state.balance == 0, "Expected zero balance initially" + assert await initial_state.total_balance_sat() == 0, ( + "Expected zero balance initially" + ) # Track metrics throughout the test metrics: dict[str, Any] = { @@ -188,7 +190,7 @@ async def test_comprehensive_transaction_flow_with_validation( "operation": f"mint_{amount}", "denominations": dict(denomination_counts), "total_proofs": len(state.proofs), - "balance": state.balance, + "balance": await state.total_balance_sat(), } ) @@ -290,7 +292,7 @@ async def test_comprehensive_transaction_flow_with_validation( "operation": f"send_{send_amount}", "denominations": dict(denoms_after), "total_proofs": len(state_after.proofs), - "balance": state_after.balance, + "balance": await state_after.total_balance_sat(), } ) diff --git a/tests/integration/test_multi_currency_support.py b/tests/integration/test_multi_currency_support.py index 8e4a005..b0ea62c 100644 --- a/tests/integration/test_multi_currency_support.py +++ b/tests/integration/test_multi_currency_support.py @@ -143,7 +143,7 @@ async def test_mint_in_different_currencies(self, wallet: Wallet) -> None: invoice: str task: Any invoice, task = await wallet.mint_async( - amount=amount, currency=cast(CurrencyUnit, currency), timeout=60 + amount=amount, unit=cast(CurrencyUnit, currency), timeout=60 ) print(f" Invoice created: {invoice[:50]}...") diff --git a/tests/integration/test_wallet_complete_flow.py b/tests/integration/test_wallet_complete_flow.py index 3c86183..76324a1 100644 --- a/tests/integration/test_wallet_complete_flow.py +++ b/tests/integration/test_wallet_complete_flow.py @@ -247,7 +247,7 @@ async def test_complete_mint_send_redeem_flow(self, wallet: Wallet) -> None: get_relay_wait_time(2.0) ) # Give time for events to propagate state = await wallet.fetch_wallet_state() - balance_after_send: int = state.balance + balance_after_send: int = await wallet.get_balance() print( f"\nDEBUG after send: {len(state.proofs)} proofs, total {balance_after_send} sats" ) @@ -271,8 +271,7 @@ async def test_complete_mint_send_redeem_flow(self, wallet: Wallet) -> None: await asyncio.sleep(get_relay_wait_time(2.0)) # 5. Verify final balance (accounting for fees) - state = await wallet.fetch_wallet_state() - final_balance: int = state.balance + final_balance: int = await wallet.get_balance() print( f"\nDEBUG after redeem: {len(state.proofs)} proofs, total {final_balance} sats" ) diff --git a/tests/unit/test_nut02.py b/tests/unit/test_nut02.py index 69d82d6..8d9760e 100644 --- a/tests/unit/test_nut02.py +++ b/tests/unit/test_nut02.py @@ -14,7 +14,7 @@ class TestKeysetIDDerivation: """Test keyset ID derivation according to NUT-02.""" - def test_derive_keyset_id_basic(self): + def test_derive_keyset_id_basic(self) -> None: """Test basic keyset ID derivation.""" keys = {"1": "02abc123", "2": "02def456", "4": "02ghi789"} @@ -27,7 +27,7 @@ def test_derive_keyset_id_basic(self): # Version byte should be 00 assert keyset_id.startswith("00") - def test_derive_keyset_id_deterministic(self): + def test_derive_keyset_id_deterministic(self) -> None: """Test that keyset ID derivation is deterministic.""" keys = {"1": "02abc123", "2": "02def456"} @@ -36,7 +36,7 @@ def test_derive_keyset_id_deterministic(self): assert id1 == id2 - def test_derive_keyset_id_order_independent(self): + def test_derive_keyset_id_order_independent(self) -> None: """Test that key order doesn't affect derivation.""" keys1 = {"1": "02abc123", "2": "02def456", "4": "02ghi789"} keys2 = {"4": "02ghi789", "1": "02abc123", "2": "02def456"} @@ -46,7 +46,7 @@ def test_derive_keyset_id_order_independent(self): assert id1 == id2 - def test_derive_keyset_id_different_versions(self): + def test_derive_keyset_id_different_versions(self) -> None: """Test keyset ID derivation with different versions.""" keys = {"1": "02abc123", "2": "02def456"} @@ -57,21 +57,21 @@ def test_derive_keyset_id_different_versions(self): assert id_v0.startswith("00") assert id_v1.startswith("01") - def test_validate_keyset_id_valid(self): + def test_validate_keyset_id_valid(self) -> None: """Test keyset ID validation with valid ID.""" keys = {"1": "02abc123", "2": "02def456"} keyset_id = derive_keyset_id(keys) assert validate_keyset_id(keyset_id, keys) - def test_validate_keyset_id_invalid(self): + def test_validate_keyset_id_invalid(self) -> None: """Test keyset ID validation with invalid ID.""" keys = {"1": "02abc123", "2": "02def456"} assert not validate_keyset_id("invalid_id", keys) assert not validate_keyset_id("00112233445566", {"1": "different_key"}) - def test_validate_keyset_id_case_insensitive(self): + def test_validate_keyset_id_case_insensitive(self) -> None: """Test that keyset ID validation is case insensitive.""" keys = {"1": "02abc123", "2": "02def456"} keyset_id = derive_keyset_id(keys) @@ -83,7 +83,7 @@ def test_validate_keyset_id_case_insensitive(self): class TestFeeCalculation: """Test input fee calculation functionality.""" - def test_calculate_input_fees_zero_fee(self): + def test_calculate_input_fees_zero_fee(self) -> None: """Test fee calculation with zero fee rate.""" wallet = TempWallet() proofs = cast( @@ -110,7 +110,7 @@ def test_calculate_input_fees_zero_fee(self): fee = wallet.calculate_input_fees(proofs, keyset_info) assert fee == 0 - def test_calculate_input_fees_positive_fee(self): + def test_calculate_input_fees_positive_fee(self) -> None: """Test fee calculation with positive fee rate.""" wallet = TempWallet() proofs = cast( @@ -146,7 +146,7 @@ def test_calculate_input_fees_positive_fee(self): # 3 proofs * 1000 ppk / 1000 = 3 sats assert fee == 3 - def test_calculate_input_fees_fractional(self): + def test_calculate_input_fees_fractional(self) -> None: """Test fee calculation with fractional fees.""" wallet = TempWallet() proofs = cast( @@ -175,7 +175,7 @@ def test_calculate_input_fees_fractional(self): # 2 proofs * 500 ppk / 1000 = 1 sat (integer division) assert fee == 1 - def test_calculate_input_fees_string_conversion(self): + def test_calculate_input_fees_string_conversion(self) -> None: """Test fee calculation with string fee value.""" wallet = TempWallet() proofs = cast( @@ -197,7 +197,7 @@ def test_calculate_input_fees_string_conversion(self): # 1 proof * 2000 ppk / 1000 = 2 sats assert fee == 2 - def test_calculate_input_fees_invalid_fee(self): + def test_calculate_input_fees_invalid_fee(self) -> None: """Test fee calculation with invalid fee value.""" wallet = TempWallet() proofs = cast( @@ -218,7 +218,7 @@ def test_calculate_input_fees_invalid_fee(self): # Should fallback to 0 for invalid fee assert fee == 0 - def test_estimate_transaction_fees(self): + def test_estimate_transaction_fees(self) -> None: """Test total transaction fee estimation.""" wallet = TempWallet() proofs = cast( @@ -254,14 +254,14 @@ def test_estimate_transaction_fees(self): class TestKeysetValidation: """Test keyset structure validation.""" - def test_validate_keyset_valid_minimal(self): + def test_validate_keyset_valid_minimal(self) -> None: """Test validation of minimal valid keyset.""" mint = Mint("https://test.mint") keyset = {"id": "00a1b2c3d4e5f6a7", "unit": "sat", "active": True} assert mint.validate_keyset(keyset) - def test_validate_keyset_valid_with_fees(self): + def test_validate_keyset_valid_with_fees(self) -> None: """Test validation of keyset with fee information.""" mint = Mint("https://test.mint") keyset = { @@ -273,7 +273,7 @@ def test_validate_keyset_valid_with_fees(self): assert mint.validate_keyset(keyset) - def test_validate_keyset_valid_with_keys(self): + def test_validate_keyset_valid_with_keys(self) -> None: """Test validation of keyset with public keys.""" mint = Mint("https://test.mint") keyset = { @@ -288,7 +288,7 @@ def test_validate_keyset_valid_with_keys(self): assert mint.validate_keyset(keyset) - def test_validate_keyset_missing_required_field(self): + def test_validate_keyset_missing_required_field(self) -> None: """Test validation fails for missing required field.""" mint = Mint("https://test.mint") keyset = { @@ -299,7 +299,7 @@ def test_validate_keyset_missing_required_field(self): assert not mint.validate_keyset(keyset) - def test_validate_keyset_invalid_id_format(self): + def test_validate_keyset_invalid_id_format(self) -> None: """Test validation fails for invalid keyset ID.""" mint = Mint("https://test.mint") @@ -311,14 +311,14 @@ def test_validate_keyset_invalid_id_format(self): keyset2 = {"id": "gggggggggggggggg", "unit": "sat", "active": True} assert not mint.validate_keyset(keyset2) - def test_validate_keyset_invalid_unit(self): + def test_validate_keyset_invalid_unit(self) -> None: """Test validation fails for invalid unit.""" mint = Mint("https://test.mint") keyset = {"id": "00a1b2c3d4e5f6a7", "unit": "invalid_unit", "active": True} assert not mint.validate_keyset(keyset) - def test_validate_keyset_invalid_fee(self): + def test_validate_keyset_invalid_fee(self) -> None: """Test validation fails for invalid fee.""" mint = Mint("https://test.mint") @@ -340,7 +340,7 @@ def test_validate_keyset_invalid_fee(self): } assert not mint.validate_keyset(keyset2) - def test_validate_keysets_response_valid(self): + def test_validate_keysets_response_valid(self) -> None: """Test validation of valid keysets response.""" mint = Mint("https://test.mint") response = { @@ -352,7 +352,7 @@ def test_validate_keysets_response_valid(self): assert mint.validate_keysets_response(response) - def test_validate_keysets_response_invalid(self): + def test_validate_keysets_response_invalid(self) -> None: """Test validation fails for invalid keysets response.""" mint = Mint("https://test.mint") @@ -374,7 +374,7 @@ def test_validate_keysets_response_invalid(self): class TestKeysetIntegration: """Test integration of keyset and fee functionality.""" - async def test_get_validated_keysets_success(self): + async def test_get_validated_keysets_success(self) -> None: mint = Mint("https://test.mint") mock_response = { @@ -395,7 +395,7 @@ async def test_get_validated_keysets_success(self): assert mint.validate_keysets_response(mock_response) assert result == mock_response["keysets"] - async def test_get_validated_keysets_failure(self): + async def test_get_validated_keysets_failure(self) -> None: mint = Mint("https://test.mint") mock_response = {"keysets": [{"id": "invalid", "unit": "sat", "active": True}]} @@ -407,7 +407,7 @@ async def test_get_validated_keysets_failure(self): assert not mint.validate_keysets_response(mock_response) assert result == mock_response["keysets"] - async def test_calculate_total_input_fees_success(self): + async def test_calculate_total_input_fees_success(self) -> None: wallet = TempWallet() mint = Mock() @@ -448,7 +448,7 @@ async def test_calculate_total_input_fees_success(self): total_fee = await wallet.calculate_total_input_fees(mint, proofs) assert total_fee == 4 - async def test_calculate_total_input_fees_failure(self): + async def test_calculate_total_input_fees_failure(self) -> None: """Test total input fee calculation with mint failure.""" wallet = TempWallet() From d8c1233d82123abcd7a0d07403d7ca0bd3b67861 Mon Sep 17 00:00:00 2001 From: Shroominic Date: Fri, 18 Jul 2025 20:15:47 -0300 Subject: [PATCH 11/17] fix examples --- examples/refresh_proofs.py | 6 ++++-- examples/split_tokens.py | 2 +- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/examples/refresh_proofs.py b/examples/refresh_proofs.py index 1b91e53..7d34458 100755 --- a/examples/refresh_proofs.py +++ b/examples/refresh_proofs.py @@ -18,7 +18,9 @@ async def refresh_proofs(wallet: Wallet): print("❌ No proofs found in wallet!") return - print(f"Found {len(state.proofs)} proofs worth {state.balance} sats") + print( + f"Found {len(state.proofs)} proofs worth {await state.total_balance_sat()} sats" + ) print(f"Refreshing proofs at {len(state.proofs_by_mint)} mint(s)...") @@ -52,7 +54,7 @@ async def refresh_proofs(wallet: Wallet): # Verify final state print("\nVerifying final state...") final_state = await wallet.fetch_wallet_state(check_proofs=True) - print(f"Final balance: {final_state.balance} sats") + print(f"Final balance: {await final_state.total_balance_sat()} sats") print(f"Final proof count: {len(final_state.proofs)}") diff --git a/examples/split_tokens.py b/examples/split_tokens.py index 4f639e7..5c49d88 100644 --- a/examples/split_tokens.py +++ b/examples/split_tokens.py @@ -28,7 +28,7 @@ async def split_tokens(wallet: Wallet, target_amounts: list[int]): # Get current wallet state state = await wallet.fetch_wallet_state(check_proofs=False) - print(f"💳 Current balance: {state.balance} sats") + print(f"💳 Current balance: {await state.total_balance_sat()} sats") # For each target amount, try to create exact tokens created_tokens = [] From e426efa538a6ecb7d3bc6caea79cad753d424301 Mon Sep 17 00:00:00 2001 From: Shroominic Date: Fri, 18 Jul 2025 20:16:12 -0300 Subject: [PATCH 12/17] cross mint transfers --- sixty_nuts/cli.py | 2 +- sixty_nuts/mint.py | 34 +++++ sixty_nuts/types.py | 46 +++--- sixty_nuts/wallet.py | 329 ++++++++++++++----------------------------- 4 files changed, 166 insertions(+), 245 deletions(-) diff --git a/sixty_nuts/cli.py b/sixty_nuts/cli.py index 9ebeb9a..93bd3d6 100644 --- a/sixty_nuts/cli.py +++ b/sixty_nuts/cli.py @@ -1557,7 +1557,7 @@ async def _mint() -> None: invoice, task = await wallet.mint_async( amount, mint_url=target_mint_url, - currency=mint_unit, + unit=mint_unit, timeout=timeout, ) diff --git a/sixty_nuts/mint.py b/sixty_nuts/mint.py index 91837da..d1ba82f 100644 --- a/sixty_nuts/mint.py +++ b/sixty_nuts/mint.py @@ -15,6 +15,7 @@ CurrencyUnit, MintError, ) +from .lnurl import parse_lightning_invoice_amount # ────────────────────────────────────────────────────────────────────────────── @@ -332,6 +333,39 @@ async def get_currencies(self) -> list[CurrencyUnit]: keyset["unit"] for keyset in (await self.get_active_keysets()) ] + async def mint_exchange_rate(self, unit: CurrencyUnit) -> float: + """Get exchange rate for a given currency unit in msats per unit.""" + # TODO: include mint fee + if unit == "sat": + return 1 + elif unit == "msat": + return 1000 + elif unit in await self.get_currencies(): + # TODO: test this + quote = await self.create_mint_quote(amount=1000, unit=unit) + invoice_amount_sats = parse_lightning_invoice_amount( + quote["request"], "sat" + ) + sat_per_base_unit = invoice_amount_sats / 1000 + return sat_per_base_unit + raise NotImplementedError(f"Exchange rate for {unit} not implemented") + + async def melt_exchange_rate(self, unit: CurrencyUnit) -> float: + """Get exchange rate for a given currency unit in msats per unit.""" + # TODO: include mint fee + PRECISION_FACTOR = 100_000 + if unit == "sat": + return 1000 + elif unit == "msat": + return 1 + elif unit in await self.get_currencies(): + # TODO: test this + quote = await self.create_mint_quote(amount=PRECISION_FACTOR, unit="sat") + melt_quote = await self.create_melt_quote(quote["request"], unit=unit) + sat_per_base_unit = 1 / (melt_quote["amount"] / PRECISION_FACTOR) + return sat_per_base_unit + raise NotImplementedError(f"Exchange rate for {unit} not implemented") + # ───────────────────────── Minting (receive) ───────────────────────────────── async def create_mint_quote( diff --git a/sixty_nuts/types.py b/sixty_nuts/types.py index fa07c7a..d5210d0 100644 --- a/sixty_nuts/types.py +++ b/sixty_nuts/types.py @@ -140,25 +140,21 @@ def balance_by_unit(self) -> dict[CurrencyUnit, int]: balances[unit] = balances.get(unit, 0) + proof["amount"] return balances - @property - def total_balance_sat(self) -> int: + async def total_balance_sat(self) -> int: """Get total balance in satoshis (only BTC-based currencies).""" - total = 0 - - for unit, balance in self.balance_by_unit.items(): - if unit == "sat": - total += balance - elif unit == "msat": - total += balance // 1000 - elif unit == "btc": - total += balance * 100_000_000 - - return total - - @property - def balance(self) -> int: - """Get total balance in satoshis for backward compatibility.""" - return self.total_balance_sat + total_sats = 0 + for proof in self.proofs: + if proof["unit"] == "sat": + total_sats += proof["amount"] + elif proof["unit"] == "msat": + total_sats += proof["amount"] // 1000 + else: + from .mint import Mint + + mint = Mint(proof["mint"]) + exchange_rate = await mint.melt_exchange_rate(proof["unit"]) + total_sats += int(proof["amount"] * exchange_rate * 0.99) + return total_sats @property def proofs_by_keyset(self) -> dict[str, list[Proof]]: @@ -184,10 +180,20 @@ def proofs_by_mint(self) -> dict[str, list[Proof]]: @property def mint_balances(self) -> dict[str, int]: - """Get balances for all mints (backward compatibility).""" + """Get balances for all mints in sats.""" balances: dict[str, int] = {} for mint_url, proofs in self.proofs_by_mint.items(): - balances[mint_url] = sum(p["amount"] for p in proofs) + for proof in proofs: + if proof["unit"] == "sat": + balances[mint_url] = balances.get(mint_url, 0) + proof["amount"] + elif proof["unit"] == "msat": + balances[mint_url] = ( + balances.get(mint_url, 0) + proof["amount"] // 1000 + ) + else: + raise NotImplementedError( + f"Balance for {proof['unit']} not supported" + ) return balances diff --git a/sixty_nuts/wallet.py b/sixty_nuts/wallet.py index 9648301..ef81b1d 100644 --- a/sixty_nuts/wallet.py +++ b/sixty_nuts/wallet.py @@ -174,7 +174,7 @@ async def redeem(self, token: str, *, auto_swap: bool = True) -> tuple[int, str] # Check if this is a trusted mint if auto_swap and self.mint_urls and mint_url not in self.mint_urls: # Token is from untrusted mint - swap to our primary mint - proofs = await self.transfer_proofs(proofs, self._primary_mint_url()) + return await self.remote_redeem_proofs(proofs, self._primary_mint_url()) # Proceed with normal redemption for trusted mints # Calculate total amount @@ -215,7 +215,7 @@ async def mint_async( amount: int, *, mint_url: str | None = None, - currency: CurrencyUnit | None = None, + unit: CurrencyUnit | None = None, timeout: int = 300, ) -> tuple[str, asyncio.Task[bool]]: """Create a Lightning invoice and return a task that completes when paid. @@ -240,8 +240,8 @@ async def mint_async( """ mint = self._get_mint(mint_url or self._primary_mint_url()) # Convert amount to base unit for the currency (e.g., dollars to cents) - base_amount = self._convert_to_base_unit(amount, currency or "sat") - quote_resp = await mint.create_mint_quote(amount=base_amount, unit=currency) + base_amount = self._convert_to_base_unit(amount, unit or "sat") + quote_resp = await mint.create_mint_quote(amount=base_amount, unit=unit) quote_id, invoice = quote_resp["quote"], quote_resp["request"] async def poll_payment() -> bool: @@ -328,13 +328,16 @@ async def melt( state = await self.fetch_wallet_state(check_proofs=True) total_needed = int(invoice_amount_sat * 1.01) - self.raise_if_insufficient_balance(state.total_balance_sat, total_needed) + total_balance = await state.total_balance_sat() + self.raise_if_insufficient_balance(total_balance, total_needed) if target_mint is None: mint_balances = state.mint_balances target_mint = max(mint_balances, key=lambda k: mint_balances[k]) if mint_balances[target_mint] < total_needed: - await self.transfer_balance_to_mint(total_needed, target_mint) + await self.transfer_balance_to_mint( + total_needed, from_mint=None, to_mint=target_mint + ) return await self.melt(invoice, target_mint=target_mint) # Create melt quote to get fees @@ -342,7 +345,9 @@ async def melt( melt_quote = await mint.create_melt_quote(request=invoice) fee_reserve = melt_quote.get("fee_reserve", 0) total_needed = invoice_amount_sat + fee_reserve - self.raise_if_insufficient_balance(state.total_balance_sat, total_needed) + self.raise_if_insufficient_balance( + await state.total_balance_sat(), total_needed + ) # Select proofs for the total amount needed (invoice + fees) selected_proofs, consumed_proofs = await self._select_proofs( @@ -489,12 +494,9 @@ async def send_to_lnurl( # Send USD to Lightning Address paid = await wallet.send_to_lnurl("user@getalby.com", 50, unit="usd") """ - state = await self.fetch_wallet_state(check_proofs=True) - total_needed_estimated = int(amount * 1.01) - self.raise_if_insufficient_balance( - state.total_balance_sat, total_needed_estimated - ) + total_balance = await self.get_balance() + self.raise_if_insufficient_balance(total_balance, total_needed_estimated) lnurl_data = await get_lnurl_data(lnurl) @@ -1172,239 +1174,104 @@ async def _retry_store_proofs( ) print(f" Manual recovery may be needed from: {backup_file}") - async def transfer_proofs( - self, proofs: list[Proof], target_mint: str - ) -> list[Proof]: - raise NotImplementedError("Not implemented") # TODO: Implement - - async def _create_proofs_at_mint( + async def remote_redeem_proofs( self, - mint_url: str, - amount: int, - denominations: dict[int, int], - currency: CurrencyUnit = "sat", - ) -> list[Proof]: - """Create new proofs at a specific mint with given denominations. - - This is a simplified version that creates proofs without requiring - Lightning infrastructure by using the mint's swap functionality. - """ - from .crypto import ( - create_blinded_message_with_secret, - ) - - mint = self._get_mint(mint_url) - - active_keysets = await mint.get_active_keysets() - keyset = next((ks for ks in active_keysets if ks.get("unit") == currency), None) - if not keyset: - raise WalletError( - f"No keyset found for currency {currency} on mint {mint.url}" - ) - keyset_id = str(keyset["id"]) + proofs: list[Proof], + target_mint: str, + target_unit: CurrencyUnit = "sat", + ) -> tuple[int, CurrencyUnit]: + if not proofs: + return 0, "sat" - # Create blinded messages for target denominations - outputs: list[BlindedMessage] = [] - secrets: list[str] = [] - blinding_factors: list[str] = [] + # Assume all proofs from same mint + source_mint_url = proofs[0]["mint"] + if not all(p["mint"] == source_mint_url for p in proofs): + raise WalletError("Proofs must be from the same mint for transfer") - for denomination, count in sorted(denominations.items()): - for _ in range(count): - secret, r_hex, blinded_msg = create_blinded_message_with_secret( - denomination, keyset_id - ) - outputs.append(blinded_msg) - secrets.append(secret) - blinding_factors.append(r_hex) + source_mint = Mint(source_mint_url) - # For this simplified implementation, we'll create a mint quote - # and immediately mark it as paid (this requires mint cooperation) - # In a full implementation, this would involve Lightning payments + total_amount_sats = await sats_value_of_proofs(proofs) + if total_amount_sats == 0: + return 0, "sat" - # Create mint quote - quote_resp = await mint.create_mint_quote( - unit=currency, - amount=amount, - ) - quote_id = quote_resp["quote"] - print(f"Quote ID: {quote_id}") - # TODO: Implement mint quote payment - # Attempt to mint using the quote - # Note: This will fail unless the invoice is actually paid - # For now, we'll raise an error indicating Lightning payment is needed - raise WalletError( - f"Cross-mint transfers require Lightning payment infrastructure. " - f"Please pay invoice: {quote_resp.get('request', 'No invoice available')} " - f"to complete transfer of {amount} sats to {mint_url}" + invoice, mint_task = await self.mint_async( + total_amount_sats, mint_url=target_mint ) - async def transfer_balance_to_mint(self, amount: int, target_mint: str) -> None: - """Transfer balance to a specific mint using optimal selection. - - Args: - amount: Amount to transfer in sats - target_mint: Target mint URL - - Raises: - WalletError: If insufficient balance or transfer fails - """ - # Get current wallet state - state = await self.fetch_wallet_state(check_proofs=True) - - # Get all proofs not from target mint - source_proofs = [p for p in state.proofs if p.get("mint") != target_mint] - - if not source_proofs: - raise WalletError("No proofs available from other mints for transfer") - - # Group by mint and calculate available balance per mint - mint_balances: dict[str, tuple[int, list[Proof]]] = {} - for proof in source_proofs: - mint_url = proof.get("mint") or "" - if not mint_url: - continue # Skip proofs without mint URL - if mint_url not in mint_balances: - mint_balances[mint_url] = (0, []) - balance, proofs_list = mint_balances[mint_url] - mint_balances[mint_url] = (balance + proof["amount"], proofs_list + [proof]) - - # Calculate transfer costs and net transferable amounts - transfer_options: list[tuple[str, int, int, list[Proof]]] = [] - - for mint_url, (balance, mint_proofs) in mint_balances.items(): - try: - # Estimate transfer fees - mint = self._get_mint(mint_url) - estimated_fees = await self.calculate_total_input_fees( - mint, mint_proofs - ) - net_transferable = balance - estimated_fees - - if net_transferable > 0: - transfer_options.append( - (mint_url, balance, net_transferable, mint_proofs) - ) - except Exception as e: - print(f"Warning: Could not calculate fees for {mint_url}: {e}") - # Assume 10% fee as fallback - estimated_fees = balance // 10 - net_transferable = balance - estimated_fees - if net_transferable > 0: - transfer_options.append( - (mint_url, balance, net_transferable, mint_proofs) - ) + melt_quote = await source_mint.create_melt_quote( + request=invoice, + ) + fee_reserve = melt_quote.get("fee_reserve", 0) + total_needed = total_amount_sats + fee_reserve + self.raise_if_insufficient_balance(await self.get_balance(), total_needed) - # Sort by net transferable amount (descending) - transfer_options.sort(key=lambda x: x[2], reverse=True) + melt_resp = await source_mint.melt( + quote=melt_quote["quote"], inputs=cast(list[ProofComplete], proofs) + ) - total_available = sum(option[2] for option in transfer_options) - if total_available < amount: + # Check if payment was successful + if not melt_resp.get("paid", False): raise WalletError( - f"Insufficient transferable balance: need {amount}, " - f"have {total_available} (after estimated fees)" + f"Lightning payment failed. State: {melt_resp.get('state', 'unknown')}" ) - # Select proofs to transfer, starting with largest balances - remaining_needed = amount - proofs_to_transfer: list[Proof] = [] - - for mint_url, gross_balance, net_transferable, mint_proofs in transfer_options: - if remaining_needed <= 0: - break - - # Take the amount we need from this mint (up to what's available) - take_amount = min(remaining_needed, net_transferable) - - # Select proofs greedily to meet take_amount - selected_proofs = [] - selected_total = 0 + await mint_task - # Sort proofs by amount (descending) for greedy selection - sorted_proofs = sorted(mint_proofs, key=lambda p: p["amount"], reverse=True) + return total_amount_sats, target_unit - for proof in sorted_proofs: - if selected_total >= take_amount: - break - selected_proofs.append(proof) - selected_total += proof["amount"] - - if selected_total < take_amount: - # Need to split proofs to get exact amount - # For simplicity, take all proofs from this mint if we need them - selected_proofs = mint_proofs - selected_total = gross_balance - - proofs_to_transfer.extend(selected_proofs) - remaining_needed -= min(take_amount, selected_total) + async def transfer_balance_to_mint( + self, + amount_sats: int, + *, + to_mint: str, + from_mint: str | None = None, + target_unit: CurrencyUnit | None = None, + exclude_mints: list[str] = [], + ) -> tuple[int, CurrencyUnit]: + state = await self.fetch_wallet_state(check_proofs=True) - if remaining_needed > 0: + if not from_mint: + mint_sats_balances: dict[str, int] = {} + for mint in set(p["mint"] for p in state.proofs if "mint" in p): + if mint in exclude_mints or mint == to_mint: + continue + mint_proofs = [p for p in state.proofs if p["mint"] == mint] + mint_sats = await sats_value_of_proofs(mint_proofs) + if mint_sats > 0: + mint_sats_balances[mint] = mint_sats + if not mint_sats_balances: + raise WalletError("No source mints with balance available") + from_mint = max(mint_sats_balances, key=lambda k: mint_sats_balances[k]) + + proofs_from_mint = [p for p in state.proofs if p.get("mint") == from_mint] + total_sats_from_mint = await sats_value_of_proofs(proofs_from_mint) + transfer_sats = min(amount_sats, total_sats_from_mint) + if transfer_sats <= 0: raise WalletError( - f"Could not select enough proofs: {remaining_needed} sats short" + f"Insufficient balance. Need at least {amount_sats}, but have 0 in available mints" ) + if not target_unit: + target_unit = "sat" - # Perform the transfer - try: - await self.transfer_proofs(proofs_to_transfer, target_mint) - except WalletError as e: - if "Lightning payment infrastructure" in str(e): - # For now, raise a more user-friendly error - raise WalletError( - "Multi-mint transfers require Lightning infrastructure which is not yet implemented. " - "Please consolidate your funds to a single mint manually, or wait for this feature to be completed." - ) from e - raise - - async def summon_mint_with_balance( - self, amount: int, unit: CurrencyUnit = "sat" - ) -> str: - """Summon a mint with at least the given amount of balance in the specified unit. - - Args: - amount: Amount needed in the specified unit - unit: Currency unit (defaults to "sat") - - Returns: - Mint URL with sufficient balance - - Raises: - WalletError: If insufficient balance or transfer fails - """ - try: - # Try to find a mint with sufficient balance - return await self._select_mint_for_amount(amount, unit) - except WalletError: - # No single mint has enough balance, try to consolidate - state = await self.fetch_wallet_state(check_proofs=True) - - # Filter proofs by unit - unit_proofs = [p for p in state.proofs if p.get("unit") == unit] - total_unit_balance = sum(p["amount"] for p in unit_proofs) - - # Check if we have enough total balance - if total_unit_balance < amount: - raise WalletError( - f"Insufficient {unit.upper()} balance. Need at least {amount}, " - f"but have {total_unit_balance}" - ) - - # Select a target mint (prefer one with the most balance already) - mint_balances: dict[str, int] = {} - for proof in unit_proofs: - mint_url = proof.get("mint", "") - if mint_url: - mint_balances[mint_url] = ( - mint_balances.get(mint_url, 0) + proof["amount"] - ) + invoice, async_task = await self.mint_async( + transfer_sats, mint_url=to_mint, unit=target_unit + ) - if not mint_balances: - raise WalletError(f"No mints found with {unit.upper()} balance") + await self.melt(invoice, target_mint=from_mint) - target_mint = max(mint_balances, key=lambda k: mint_balances[k]) + await async_task - # Transfer balance from other mints to the target mint - await self.transfer_balance_to_mint(amount, target_mint) + remaining = amount_sats - transfer_sats + if remaining > 0: + await self.transfer_balance_to_mint( + remaining, + to_mint=to_mint, + from_mint=None, + target_unit=target_unit, + exclude_mints=cast(list[str], exclude_mints + [from_mint]), + ) - return target_mint + return amount_sats, target_unit # ───────────────────────── Helper Methods ───────────────────────────────── @@ -1991,7 +1858,7 @@ async def get_balance( if unit not in state.balance_by_unit: raise WalletError(f"Unsupported currency unit: {unit}") return state.balance_by_unit[unit] - return state.total_balance_sat + return await state.total_balance_sat() async def get_balance_by_mint(self, mint_url: str) -> int: """Get balance for a specific mint.""" @@ -2600,3 +2467,17 @@ async def scan_and_recover_local_proofs( print(f"\n✨ Recovery complete! Recovered {stats['recovered']} proofs") return stats + + +async def sats_value_of_proofs(proofs: list[Proof]) -> int: + """Get the total value of proofs in sats.""" + total_sats = 0 + for proof in proofs: + if proof["unit"] == "sat": + total_sats += proof["amount"] + elif proof["unit"] == "msat": + total_sats += proof["amount"] // 1000 + else: + exchange_rate = await Mint(proof["mint"]).mint_exchange_rate(proof["unit"]) + total_sats += int(proof["amount"] * exchange_rate) + return total_sats From 571351a019366b32f7779c1e7bf8cfe773c200e2 Mon Sep 17 00:00:00 2001 From: Shroominic Date: Sat, 19 Jul 2025 14:17:24 -0300 Subject: [PATCH 13/17] mint balances --- sixty_nuts/types.py | 31 ++++++++++++++++++++++++------- 1 file changed, 24 insertions(+), 7 deletions(-) diff --git a/sixty_nuts/types.py b/sixty_nuts/types.py index d5210d0..9cccc30 100644 --- a/sixty_nuts/types.py +++ b/sixty_nuts/types.py @@ -140,7 +140,7 @@ def balance_by_unit(self) -> dict[CurrencyUnit, int]: balances[unit] = balances.get(unit, 0) + proof["amount"] return balances - async def total_balance_sat(self) -> int: + async def total_balance_sat(self, include_shitnuts: bool = False) -> int: """Get total balance in satoshis (only BTC-based currencies).""" total_sats = 0 for proof in self.proofs: @@ -148,11 +148,12 @@ async def total_balance_sat(self) -> int: total_sats += proof["amount"] elif proof["unit"] == "msat": total_sats += proof["amount"] // 1000 - else: + elif include_shitnuts: from .mint import Mint mint = Mint(proof["mint"]) exchange_rate = await mint.melt_exchange_rate(proof["unit"]) + # Apply 0.99 multiplier as rough estimate for fees (1% buffer) total_sats += int(proof["amount"] * exchange_rate * 0.99) return total_sats @@ -180,7 +181,10 @@ def proofs_by_mint(self) -> dict[str, list[Proof]]: @property def mint_balances(self) -> dict[str, int]: - """Get balances for all mints in sats.""" + """Get balances for all mints in sats + (BTC-based currencies only). + TODO: add support for non-BTC currencies. + """ balances: dict[str, int] = {} for mint_url, proofs in self.proofs_by_mint.items(): for proof in proofs: @@ -190,10 +194,23 @@ def mint_balances(self) -> dict[str, int]: balances[mint_url] = ( balances.get(mint_url, 0) + proof["amount"] // 1000 ) - else: - raise NotImplementedError( - f"Balance for {proof['unit']} not supported" - ) + # Skip non-BTC currencies instead of throwing error + if mint_url not in balances: + balances[mint_url] = 0 + return balances + + @property + def mint_balances_by_unit(self) -> dict[str, dict[str, int]]: + """Get balances for all mints organized by currency unit.""" + balances: dict[str, dict[str, int]] = {} + for mint_url, proofs in self.proofs_by_mint.items(): + if mint_url not in balances: + balances[mint_url] = {} + for proof in proofs: + unit = proof.get("unit", "sat") + balances[mint_url][unit] = ( + balances[mint_url].get(unit, 0) + proof["amount"] + ) return balances From afed00d41ea247f3a9ce679d592d696c87fb8d33 Mon Sep 17 00:00:00 2001 From: Shroominic Date: Sat, 19 Jul 2025 14:19:24 -0300 Subject: [PATCH 14/17] fix mint urls and xc rate --- sixty_nuts/events.py | 5 ++-- sixty_nuts/mint.py | 62 +++++++++++++++++++++++++++++++++++++++----- 2 files changed, 59 insertions(+), 8 deletions(-) diff --git a/sixty_nuts/events.py b/sixty_nuts/events.py index 7466554..42392e9 100644 --- a/sixty_nuts/events.py +++ b/sixty_nuts/events.py @@ -145,8 +145,9 @@ async def create_wallet_event( content_data = [ ["privkey", wallet_privkey], ] - # Deduplicate mint URLs before storing - unique_mint_urls = list(dict.fromkeys(self.mint_urls)) # Preserves order + # Normalize and deduplicate mint URLs before storing + normalized_mint_urls = [url.rstrip("/") for url in self.mint_urls] + unique_mint_urls = list(dict.fromkeys(normalized_mint_urls)) # Preserves order for mint_url in unique_mint_urls: content_data.append(["mint", mint_url]) diff --git a/sixty_nuts/mint.py b/sixty_nuts/mint.py index d1ba82f..8297c1e 100644 --- a/sixty_nuts/mint.py +++ b/sixty_nuts/mint.py @@ -4,6 +4,7 @@ from __future__ import annotations import os +import time from typing import TypedDict, cast, Any import httpx @@ -29,11 +30,14 @@ class InvalidKeysetError(MintError): class Mint: def __init__(self, url: str) -> None: - self.url = url + # Normalize URL by removing trailing slashes + self.url = url.rstrip("/") self.client = httpx.AsyncClient() self._active_keysets: list[Keyset] = [] self._currencies: list[CurrencyUnit] = [] - # self.exchange_rates: dict[CurrencyUnit, dict[CurrencyUnit, float]] = {} # TODO implement + # Exchange rate cache: {cache_key: (rate, timestamp)} + self._exchange_rate_cache: dict[str, tuple[float, float]] = {} + self._exchange_rate_cache_ttl = 300 # 5 minutes cache TTL async def aclose(self) -> None: """Close the HTTP client if we created it.""" @@ -49,6 +53,8 @@ async def _request( params: dict[str, Any] | None = None, ) -> dict[str, Any]: """Make HTTP request to mint.""" + if os.environ.get("MINT_DEBUG", "false").lower() == "true": + print(f"MINT_DEBUG {method} request to {self.url}{path}") response = await self.client.request( method, f"{self.url}{path}", @@ -334,35 +340,79 @@ async def get_currencies(self) -> list[CurrencyUnit]: ] async def mint_exchange_rate(self, unit: CurrencyUnit) -> float: - """Get exchange rate for a given currency unit in msats per unit.""" - # TODO: include mint fee + """Get exchange rate for converting a currency unit to satoshis. + + Returns how many satoshis equal one unit of the given currency. + For example: USD returns ~3000 (meaning 1 USD = 3000 sats) + + NOTE: This does not include mint fees. The actual cost to mint will be higher + due to both Lightning network fees and mint fees. Consider adding a buffer + (e.g., 1-2%) when using these rates for calculations. + """ + # TODO: include mint fee in exchange rate calculation if unit == "sat": return 1 elif unit == "msat": return 1000 elif unit in await self.get_currencies(): + # Use same cache as melt_exchange_rate (rates should be similar) + current_time = time.time() + cache_key = f"mint_{unit}" # Different cache key for mint rates + if cache_key in self._exchange_rate_cache: + rate, timestamp = self._exchange_rate_cache[cache_key] + if current_time - timestamp < self._exchange_rate_cache_ttl: + return rate + + # Cache miss or expired - fetch new rate # TODO: test this quote = await self.create_mint_quote(amount=1000, unit=unit) invoice_amount_sats = parse_lightning_invoice_amount( quote["request"], "sat" ) sat_per_base_unit = invoice_amount_sats / 1000 + + # Update cache + self._exchange_rate_cache[cache_key] = (sat_per_base_unit, current_time) + return sat_per_base_unit raise NotImplementedError(f"Exchange rate for {unit} not implemented") async def melt_exchange_rate(self, unit: CurrencyUnit) -> float: - """Get exchange rate for a given currency unit in msats per unit.""" - # TODO: include mint fee + """Get exchange rate for converting a currency unit to satoshis. + + NOTE: The return values for BTC units seem inverted - this may be a bug: + - Returns 1000 for "sat" (should be 1?) + - Returns 1 for "msat" (should be 0.001?) + + TODO: Verify and fix the return values for BTC-based units. + + NOTE: This does not include mint fees. The actual proceeds from melting will be lower + due to both Lightning network fees and mint fees. Consider adding a buffer + (e.g., 1-2%) when using these rates for calculations. + """ + # TODO: include mint fee in exchange rate calculation PRECISION_FACTOR = 100_000 if unit == "sat": return 1000 elif unit == "msat": return 1 elif unit in await self.get_currencies(): + # Check cache first + current_time = time.time() + if unit in self._exchange_rate_cache: + rate, timestamp = self._exchange_rate_cache[unit] + if current_time - timestamp < self._exchange_rate_cache_ttl: + return rate + + # Cache miss or expired - fetch new rate # TODO: test this quote = await self.create_mint_quote(amount=PRECISION_FACTOR, unit="sat") melt_quote = await self.create_melt_quote(quote["request"], unit=unit) sat_per_base_unit = 1 / (melt_quote["amount"] / PRECISION_FACTOR) + + # Update cache + self._exchange_rate_cache[unit] = (sat_per_base_unit, current_time) + return sat_per_base_unit raise NotImplementedError(f"Exchange rate for {unit} not implemented") From 10702a88c0a0b683895f9440069f52a790fb9e7a Mon Sep 17 00:00:00 2001 From: Shroominic Date: Sat, 19 Jul 2025 14:20:46 -0300 Subject: [PATCH 15/17] speedup balance checking, fix cross mint transfer, fix mint url format --- sixty_nuts/wallet.py | 399 ++++++++++++++++++++++++++++++++++++------- 1 file changed, 338 insertions(+), 61 deletions(-) diff --git a/sixty_nuts/wallet.py b/sixty_nuts/wallet.py index ef81b1d..7c7a5f6 100644 --- a/sixty_nuts/wallet.py +++ b/sixty_nuts/wallet.py @@ -63,7 +63,9 @@ def __init__( self._privkey = decode_nsec(nsec) self.pubkey = get_pubkey(self._privkey) - self.mint_urls: list[str] = mint_urls or get_mints_from_env() + # Normalize mint URLs to ensure consistent comparison + raw_mint_urls = mint_urls or get_mints_from_env() + self.mint_urls: list[str] = [normalize_mint_url(url) for url in raw_mint_urls] self.relay_urls: list[str] = relay_urls or get_relays_from_env() self.mints: dict[str, Mint] = {} @@ -170,15 +172,21 @@ async def redeem(self, token: str, *, auto_swap: bool = True) -> tuple[int, str] """ # Parse token mint_url, unit, proofs = self._parse_cashu_token(token) + print("DEBUG", mint_url, unit) + + # Normalize mint URL for comparison + mint_url = normalize_mint_url(mint_url) # Check if this is a trusted mint if auto_swap and self.mint_urls and mint_url not in self.mint_urls: # Token is from untrusted mint - swap to our primary mint + print("DEBUG SWAP", self._primary_mint_url()) return await self.remote_redeem_proofs(proofs, self._primary_mint_url()) # Proceed with normal redemption for trusted mints # Calculate total amount total_amount = sum(p["amount"] for p in proofs) + print("DEBUG TOTAL AMOUNT", total_amount) # Get mint instance to calculate fees mint = self._get_mint(mint_url) @@ -259,7 +267,6 @@ async def poll_payment() -> bool: # Convert dict proofs to Proof proof_dicts: list[Proof] = [] for proof in new_proofs: - print("DEBUG PROOFS", proof, proof["unit"], mint.url) proof_dicts.append( Proof( id=proof["id"], @@ -328,17 +335,55 @@ async def melt( state = await self.fetch_wallet_state(check_proofs=True) total_needed = int(invoice_amount_sat * 1.01) - total_balance = await state.total_balance_sat() + total_balance = await state.total_balance_sat(include_shitnuts=True) self.raise_if_insufficient_balance(total_balance, total_needed) if target_mint is None: - mint_balances = state.mint_balances - target_mint = max(mint_balances, key=lambda k: mint_balances[k]) - if mint_balances[target_mint] < total_needed: - await self.transfer_balance_to_mint( - total_needed, from_mint=None, to_mint=target_mint + # Only consider trusted mints + mint_balances = {} + for mint_url, balance in state.mint_balances.items(): + if self.mint_urls and mint_url in self.mint_urls: + mint_balances[mint_url] = balance + + if not mint_balances: + raise WalletError( + "No trusted mints found with balance. " + "Specify a mint explicitly with target_mint parameter." + ) + + # Sort mints by balance and try each until one works + sorted_mints = sorted( + mint_balances.items(), key=lambda x: x[1], reverse=True + ) + + for candidate_mint, balance in sorted_mints: + if balance < total_needed: + # This mint doesn't have enough balance, try transferring + try: + await self.transfer_balance_to_mint( + total_needed, from_mint=None, to_mint=candidate_mint + ) + return await self.melt(invoice, target_mint=candidate_mint) + except Exception as e: + print(f"⚠️ Failed to transfer balance to {candidate_mint}: {e}") + continue + + # Try to use this mint + try: + # Quick connectivity check + test_mint = self._get_mint(candidate_mint) + await asyncio.wait_for(test_mint.get_info(), timeout=5.0) + target_mint = candidate_mint + break + except Exception as e: + print(f"⚠️ Mint {candidate_mint} is not reachable: {e}") + continue + + if target_mint is None: + raise WalletError( + "No reachable trusted mint found with sufficient balance. " + f"Tried mints: {', '.join(m for m, _ in sorted_mints)}" ) - return await self.melt(invoice, target_mint=target_mint) # Create melt quote to get fees mint = self._get_mint(target_mint) @@ -435,13 +480,50 @@ async def send( # If no mint URL specified, find a mint with sufficient balance of the requested unit if mint_url is None: - mint_url = await self._select_mint_for_amount(amount, unit, state.proofs) + try: + mint_url = await self._select_mint_for_amount( + amount, unit, state.proofs, trusted_only=True + ) + except WalletError as e: + # Check if we have balance in untrusted mints + unit_proofs = [p for p in state.proofs if p.get("unit") == unit] + untrusted_balances: dict[str, int] = {} + + for proof in unit_proofs: + proof_mint = proof.get("mint", "") + if ( + proof_mint + and self.mint_urls + and proof_mint not in self.mint_urls + ): + untrusted_balances[proof_mint] = ( + untrusted_balances.get(proof_mint, 0) + proof["amount"] + ) + + if untrusted_balances: + suitable_untrusted = [ + (m, b) for m, b in untrusted_balances.items() if b >= amount + ] + if suitable_untrusted: + untrusted_details = ", ".join( + f"{m}: {b} {unit}" for m, b in suitable_untrusted + ) + raise WalletError( + f"{e}\n\n" + f"💡 Found sufficient balance in untrusted mints:\n{untrusted_details}\n\n" + f"To use an untrusted mint, specify it explicitly:\n" + f' await wallet.send({amount}, mint_url="")' + ) from e + raise mint = self._get_mint(mint_url) # Filter proofs by the requested currency unit AND mint + # Note: mint.url is already normalized by _get_mint unit_proofs = [p for p in state.proofs if p.get("unit") == unit] - mint_unit_proofs = [p for p in unit_proofs if p.get("mint") == mint.url] + mint_unit_proofs = [ + p for p in unit_proofs if normalize_mint_url(p.get("mint", "")) == mint.url + ] mint_unit_balance = sum(p["amount"] for p in mint_unit_proofs) if mint_unit_balance < amount: @@ -656,7 +738,13 @@ async def _select_proofs( ) # check if enough balance in proofs from target mint - target_mint_proofs = [p for p in valid_proofs if p.get("mint") == target_mint] + # Normalize both the target mint and proof mints for comparison + normalized_target = normalize_mint_url(target_mint) + target_mint_proofs = [ + p + for p in valid_proofs + if normalize_mint_url(p.get("mint", "")) == normalized_target + ] target_mint_balance = sum(p["amount"] for p in target_mint_proofs) if target_mint_balance < amount: raise WalletError( @@ -1184,26 +1272,106 @@ async def remote_redeem_proofs( return 0, "sat" # Assume all proofs from same mint - source_mint_url = proofs[0]["mint"] - if not all(p["mint"] == source_mint_url for p in proofs): + source_mint_url = normalize_mint_url(proofs[0]["mint"]) + if not all( + normalize_mint_url(p.get("mint", "")) == source_mint_url for p in proofs + ): raise WalletError("Proofs must be from the same mint for transfer") source_mint = Mint(source_mint_url) + # Get the total amount in the source mint's native unit + total_amount_source_unit = sum(p["amount"] for p in proofs) + print("DEBUG PROOF", proofs[0]) + source_unit = proofs[0].get("unit", "sat") + + # Convert to sats for estimation total_amount_sats = await sats_value_of_proofs(proofs) if total_amount_sats == 0: return 0, "sat" + # Estimate Lightning fees: typically 1% or minimum 2 sats + estimated_fee_sats = max(int(total_amount_sats * 0.01), 2) + + # Calculate initial invoice amount after estimated fees + estimated_invoice_sats = total_amount_sats - estimated_fee_sats + + # Ensure we're trying to mint at least 1 sat + if estimated_invoice_sats < 1: + raise WalletError( + f"Amount too small to swap after fees. Have {total_amount_sats} sats, " + f"estimated fees {estimated_fee_sats} sats" + ) + + # Create invoice for the amount minus estimated fees invoice, mint_task = await self.mint_async( - total_amount_sats, mint_url=target_mint + estimated_invoice_sats, mint_url=target_mint, unit=target_unit ) + # Get the actual melt quote to see if we have enough melt_quote = await source_mint.create_melt_quote( request=invoice, ) fee_reserve = melt_quote.get("fee_reserve", 0) - total_needed = total_amount_sats + fee_reserve - self.raise_if_insufficient_balance(await self.get_balance(), total_needed) + + # Calculate total needed in the source mint's unit + # The invoice amount is in msat if going through Lightning + invoice_amount_msat = estimated_invoice_sats * 1000 + + # For msat mint, fees are in msat + if source_unit == "msat": + total_needed_source_unit = invoice_amount_msat + fee_reserve + elif source_unit == "sat": + # For sat mint, convert invoice to sats and add fees + total_needed_source_unit = (invoice_amount_msat // 1000) + fee_reserve + else: + # For other units, we'd need to handle conversion + raise WalletError( + f"Unsupported source unit for remote redemption: {source_unit}" + ) + + # Check if we have enough in the source unit + if total_amount_source_unit < total_needed_source_unit: + # Try with progressively smaller amounts + max_attempts = min( + estimated_invoice_sats - 1, 10 + ) # Try up to 10 times or until we reach 1 sat + for attempt in range(max_attempts): + # Reduce by 1 sat each attempt + new_invoice_sats = estimated_invoice_sats - (attempt + 1) + if new_invoice_sats < 1: + raise WalletError( + f"Insufficient balance after fees. Have {total_amount_source_unit} {source_unit}, " + f"but even 1 sat invoice requires {total_needed_source_unit} {source_unit} including fees" + ) + + # Cancel the previous mint task + mint_task.cancel() + + # Try with reduced amount + invoice, mint_task = await self.mint_async( + new_invoice_sats, mint_url=target_mint, unit=target_unit + ) + + # Get new melt quote + melt_quote = await source_mint.create_melt_quote( + request=invoice, + ) + fee_reserve = melt_quote.get("fee_reserve", 0) + + # Recalculate total needed + invoice_amount_msat = new_invoice_sats * 1000 + if source_unit == "msat": + total_needed_source_unit = invoice_amount_msat + fee_reserve + elif source_unit == "sat": + total_needed_source_unit = ( + invoice_amount_msat // 1000 + ) + fee_reserve + + if total_amount_source_unit >= total_needed_source_unit: + # Update the final amount for return + estimated_invoice_sats = new_invoice_sats + break melt_resp = await source_mint.melt( quote=melt_quote["quote"], inputs=cast(list[ProofComplete], proofs) @@ -1217,7 +1385,7 @@ async def remote_redeem_proofs( await mint_task - return total_amount_sats, target_unit + return estimated_invoice_sats, target_unit async def transfer_balance_to_mint( self, @@ -1232,10 +1400,21 @@ async def transfer_balance_to_mint( if not from_mint: mint_sats_balances: dict[str, int] = {} - for mint in set(p["mint"] for p in state.proofs if "mint" in p): - if mint in exclude_mints or mint == to_mint: + normalized_to_mint = normalize_mint_url(to_mint) + normalized_excludes = [normalize_mint_url(m) for m in exclude_mints] + + for mint in set( + normalize_mint_url(p.get("mint", "")) + for p in state.proofs + if p.get("mint") + ): + if mint in normalized_excludes or mint == normalized_to_mint: continue - mint_proofs = [p for p in state.proofs if p["mint"] == mint] + mint_proofs = [ + p + for p in state.proofs + if normalize_mint_url(p.get("mint", "")) == mint + ] mint_sats = await sats_value_of_proofs(mint_proofs) if mint_sats > 0: mint_sats_balances[mint] = mint_sats @@ -1243,7 +1422,11 @@ async def transfer_balance_to_mint( raise WalletError("No source mints with balance available") from_mint = max(mint_sats_balances, key=lambda k: mint_sats_balances[k]) - proofs_from_mint = [p for p in state.proofs if p.get("mint") == from_mint] + proofs_from_mint = [ + p + for p in state.proofs + if normalize_mint_url(p.get("mint", "")) == normalize_mint_url(from_mint) + ] total_sats_from_mint = await sats_value_of_proofs(proofs_from_mint) transfer_sats = min(amount_sats, total_sats_from_mint) if transfer_sats <= 0: @@ -1277,9 +1460,11 @@ async def transfer_balance_to_mint( def _get_mint(self, mint_url: str) -> Mint: """Get or create mint instance for URL.""" - if mint_url not in self.mints: - self.mints[mint_url] = Mint(mint_url) - return self.mints[mint_url] + # Normalize URL to handle trailing slashes + normalized_url = normalize_mint_url(mint_url) + if normalized_url not in self.mints: + self.mints[normalized_url] = Mint(normalized_url) + return self.mints[normalized_url] def _serialize_proofs_for_token( self, @@ -1396,6 +1581,9 @@ def _parse_cashu_token(self, token: str) -> tuple[str, CurrencyUnit, list[Proof] token_unit: CurrencyUnit = cast(CurrencyUnit, unit_str) token_proofs = mint_info["proofs"] + # Normalize mint URL + normalized_mint = normalize_mint_url(mint_info["mint"]) + # Return proofs with hex secrets (standard Cashu format) parsed_proofs: list[Proof] = [] for proof in token_proofs: @@ -1405,12 +1593,12 @@ def _parse_cashu_token(self, token: str) -> tuple[str, CurrencyUnit, list[Proof] amount=proof["amount"], secret=proof["secret"], # Already hex in Cashu tokens C=proof["C"], - mint=mint_info["mint"], + mint=normalized_mint, unit=token_unit, ) ) - return mint_info["mint"], token_unit, parsed_proofs + return normalized_mint, token_unit, parsed_proofs elif token.startswith("cashuB"): # Version 4 - CBOR format @@ -1426,7 +1614,7 @@ def _parse_cashu_token(self, token: str) -> tuple[str, CurrencyUnit, list[Proof] # Extract from CBOR format - different structure # 'm' = mint URL, 'u' = unit, 't' = tokens array - mint_url = token_data["m"] + mint_url = normalize_mint_url(token_data["m"]) unit_str = token_data["u"] # Cast to CurrencyUnit cbor_unit: CurrencyUnit = cast(CurrencyUnit, unit_str) @@ -1529,34 +1717,37 @@ async def _validate_proofs_with_cache(self, proofs: list[Proof]) -> list[Proof]: proofs_to_check.append(proof) if proofs_to_check: - for mint_url, mint_proofs in self._sort_proofs_by_mint( - proofs_to_check - ).items(): + + async def validate_mint_proofs( + mint_url: str, mint_proofs: list[Proof] + ) -> list[Proof]: try: mint = self._get_mint(mint_url) y_values = self._compute_proof_y_values(mint_proofs) state_response = await mint.check_state(Ys=y_values) - + result: list[Proof] = [] for i, proof in enumerate(mint_proofs): proof_id = f"{proof['secret']}:{proof['C']}" if i < len(state_response["states"]): state_info = state_response["states"][i] state = state_info.get("state", "UNKNOWN") - - # Cache the result self._cache_proof_state(proof_id, state) - - # Only include unspent proofs if state == "UNSPENT": - valid_proofs.append(proof) - + result.append(proof) else: - # No state info - assume valid but don't cache - valid_proofs.append(proof) - + result.append(proof) + return result except Exception: - # If validation fails, include proofs but don't cache - valid_proofs.extend(mint_proofs) + return mint_proofs + + mint_proof_map = self._sort_proofs_by_mint(proofs_to_check) + tasks = [ + validate_mint_proofs(mint_url, mint_proofs) + for mint_url, mint_proofs in mint_proof_map.items() + ] + results = await asyncio.gather(*tasks) + for valid in results: + valid_proofs.extend(valid) return valid_proofs @@ -1603,8 +1794,11 @@ async def fetch_wallet_state( # If wallet event contains mint URLs, use those as the source of truth # This replaces any mint URLs from constructor or environment if event_mint_urls: - # Deduplicate mint URLs from wallet event (in case they were stored with duplicates) - self.mint_urls = list(dict.fromkeys(event_mint_urls)) + # Normalize and deduplicate mint URLs from wallet event + normalized_urls = [ + normalize_mint_url(url) for url in event_mint_urls + ] + self.mint_urls = list(dict.fromkeys(normalized_urls)) # Also update the event manager with the correct mint URLs self.event_manager.mint_urls = self.mint_urls except Exception as e: @@ -1660,6 +1854,9 @@ async def fetch_wallet_state( if not mint_url: raise WalletError("No mint URL found in token event") + # Normalize mint URL + mint_url = normalize_mint_url(mint_url) + for proof in proofs: # Convert from NIP-60 format (base64) to internal format (hex) # NIP-60 stores secrets as base64, but internally we use hex @@ -1701,6 +1898,9 @@ async def fetch_wallet_state( if not mint_url or not isinstance(mint_url, str): raise WalletError("No mint URL found in pending token event") + # Normalize mint URL + mint_url = normalize_mint_url(mint_url) + proofs = token_data.get("proofs", []) if not isinstance(proofs, list): continue @@ -1837,7 +2037,11 @@ async def fetch_wallet_state( return WalletState(proofs=all_proofs, proof_to_event_id=proof_to_event_id) async def get_balance( - self, unit: CurrencyUnit | None = None, *, check_proofs: bool = True + self, + unit: CurrencyUnit | None = None, + *, + check_proofs: bool = True, + include_shitnuts: bool = False, ) -> int: """Get current wallet balance. @@ -1858,12 +2062,18 @@ async def get_balance( if unit not in state.balance_by_unit: raise WalletError(f"Unsupported currency unit: {unit}") return state.balance_by_unit[unit] - return await state.total_balance_sat() + return await state.total_balance_sat(include_shitnuts=include_shitnuts) async def get_balance_by_mint(self, mint_url: str) -> int: """Get balance for a specific mint.""" state = await self.fetch_wallet_state(check_proofs=True) - return sum(p["amount"] for p in state.proofs if p["mint"] == mint_url) + # Normalize mint URL for comparison + normalized_mint = normalize_mint_url(mint_url) + return sum( + p["amount"] + for p in state.proofs + if normalize_mint_url(p.get("mint", "")) == normalized_mint + ) # ─────────────────────────────── Cleanup ────────────────────────────────── @@ -2139,7 +2349,12 @@ def _primary_mint_url(self) -> str: return sorted(self.mint_urls)[0] # Use sorted order for consistency async def _select_mint_for_amount( - self, amount: int, unit: CurrencyUnit, proofs: list[Proof] | None = None + self, + amount: int, + unit: CurrencyUnit, + proofs: list[Proof] | None = None, + *, + trusted_only: bool = True, ) -> str: """Select the best mint that has sufficient balance for the given amount and unit. @@ -2147,6 +2362,7 @@ async def _select_mint_for_amount( amount: Amount needed in the specified unit unit: Currency unit proofs: Optional list of proofs to use (if None, fetches wallet state) + trusted_only: If True, only consider mints in self.mint_urls Returns: Mint URL with sufficient balance @@ -2172,8 +2388,16 @@ async def _select_mint_for_amount( for proof in unit_proofs: proof_mint = proof.get("mint", "") if proof_mint: - mint_unit_balances[proof_mint] = ( - mint_unit_balances.get(proof_mint, 0) + proof["amount"] + normalized_proof_mint = normalize_mint_url(proof_mint) + # Skip untrusted mints if trusted_only is True + if ( + trusted_only + and self.mint_urls + and normalized_proof_mint not in self.mint_urls + ): + continue + mint_unit_balances[normalized_proof_mint] = ( + mint_unit_balances.get(normalized_proof_mint, 0) + proof["amount"] ) # Find mints with sufficient balance @@ -2182,23 +2406,59 @@ async def _select_mint_for_amount( ] if not suitable_mints: + # Calculate total balance including untrusted mints for better error message + total_trusted_balance = sum(mint_unit_balances.values()) total_unit_balance = sum(p["amount"] for p in unit_proofs) + mint_details = ", ".join( f"{m}: {b}" for m, b in sorted(mint_unit_balances.items()) ) - raise WalletError( - f"Insufficient {unit.upper()} balance: need {amount}, have {total_unit_balance} " - f"(distributed across mints: {mint_details})" - ) - # Select the mint with the highest balance of this unit - return max(suitable_mints, key=lambda m: mint_unit_balances[m]) + if trusted_only and total_unit_balance > total_trusted_balance: + untrusted_balance = total_unit_balance - total_trusted_balance + raise WalletError( + f"Insufficient {unit.upper()} balance in trusted mints: need {amount}, " + f"have {total_trusted_balance} in trusted mints " + f"(plus {untrusted_balance} in untrusted mints). " + f"Trusted mint balances: {mint_details}" + ) + else: + raise WalletError( + f"Insufficient {unit.upper()} balance: need {amount}, have {total_unit_balance} " + f"(distributed across mints: {mint_details})" + ) + + # Sort suitable mints by balance (highest first) + suitable_mints.sort(key=lambda m: mint_unit_balances[m], reverse=True) + + # Try to check connectivity for each mint and return the first reachable one + for mint_candidate in suitable_mints: + try: + # Quick connectivity check - just try to get mint info + test_mint = self._get_mint(mint_candidate) + # Try a lightweight request to check if mint is reachable + await asyncio.wait_for(test_mint.get_info(), timeout=5.0) + return mint_candidate + except Exception as e: + # Log the error but continue to next mint + print(f"⚠️ Mint {mint_candidate} is not reachable: {e}") + continue + + # If no reachable mint found, raise error + raise WalletError( + f"No reachable mint found with sufficient {unit.upper()} balance. " + f"Tried mints: {', '.join(suitable_mints)}" + ) def _sort_proofs_by_mint(self, proofs: list[Proof]) -> dict[str, list[Proof]]: - return { - mint_url: [proof for proof in proofs if proof["mint"] == mint_url] - for mint_url in set(proof["mint"] for proof in proofs) - } + # Normalize mint URLs when grouping proofs + normalized_mints: dict[str, list[Proof]] = {} + for proof in proofs: + normalized_mint = normalize_mint_url(proof.get("mint", "")) + if normalized_mint not in normalized_mints: + normalized_mints[normalized_mint] = [] + normalized_mints[normalized_mint].append(proof) + return normalized_mints async def _cleanup_spent_proof_backups(self) -> int: """Clean up backup files that only contain spent/invalid proofs. @@ -2481,3 +2741,20 @@ async def sats_value_of_proofs(proofs: list[Proof]) -> int: exchange_rate = await Mint(proof["mint"]).mint_exchange_rate(proof["unit"]) total_sats += int(proof["amount"] * exchange_rate) return total_sats + + +# ────────────────────────────────────────────────────────────────────────────── +# URL Normalization +# ────────────────────────────────────────────────────────────────────────────── + + +def normalize_mint_url(url: str) -> str: + """Normalize mint URL by removing trailing slashes. + + Args: + url: Mint URL to normalize + + Returns: + Normalized URL without trailing slashes + """ + return url.rstrip("/") From 5165d6f49efeabb9c6ba4a0bdaab346f940f60be Mon Sep 17 00:00:00 2001 From: Shroominic Date: Sat, 19 Jul 2025 14:24:54 -0300 Subject: [PATCH 16/17] rm plan --- keyset_currency_refactor_plan.md | 310 ------------------------------- 1 file changed, 310 deletions(-) delete mode 100644 keyset_currency_refactor_plan.md diff --git a/keyset_currency_refactor_plan.md b/keyset_currency_refactor_plan.md deleted file mode 100644 index ff8eab6..0000000 --- a/keyset_currency_refactor_plan.md +++ /dev/null @@ -1,310 +0,0 @@ -# Multi-Keyset and Multi-Currency Refactor Plan - -## Overview - -This document outlines a comprehensive plan to implement proper multi-keyset and multi-currency support in the sixty_nuts Cashu wallet implementation. Currently, the wallet has partial support for multiple keysets and currencies, but it's not fully implemented. This refactor will enable: - -- Support for multiple keysets per mint (different currencies) -- Dynamic denomination fetching from mints -- Easy switching between keysets/currencies -- Universal keyset swap function -- Granular keyset specification for operations -- Proper balance display per keyset/currency - -## Current State Analysis - -### What's Currently Implemented - -1. **Partial Multi-Mint Support**: The wallet can handle multiple mints but treats them mostly independently -2. **Single Currency Per Wallet**: Currency is set at wallet initialization and applies globally -3. **Hardcoded Denominations**: Powers of 2 (1, 2, 4, 8, 16, ...) are hardcoded in `_calculate_optimal_denominations` -4. **Basic Keyset Handling**: Keysets are fetched but only filtered by currency and active status -5. **Token Serialization**: Supports multiple token formats (V3/V4) with currency information - -### What's Missing - -1. **Keyset Management**: No proper tracking of keysets across mints and currencies -2. **Dynamic Denominations**: Denominations should be fetched from mint keysets -3. **Currency Switching**: No way to work with multiple currencies in the same wallet -4. **Keyset Selection**: Operations don't allow specifying which keyset to use -5. **Multi-Currency Balance**: Balance display doesn't break down by currency - -## Architecture Changes - -### 1. Data Model Updates - -#### Enhanced Keyset Storage - -```python -@dataclass -class KeysetInfo: - """Complete keyset information.""" - id: str - mint_url: str - unit: CurrencyUnit - active: bool - input_fee_ppk: int = 0 - keys: dict[str, str] = field(default_factory=dict) # amount -> pubkey - denominations: list[int] = field(default_factory=list) # available denominations - -@dataclass -class WalletState: - """Enhanced wallet state with keyset tracking.""" - balance_by_keyset: dict[str, int] # keyset_id -> balance - proofs: list[ProofDict] - keysets: dict[str, KeysetInfo] # keyset_id -> KeysetInfo - proof_to_event_id: dict[str, str] | None = None -``` - -#### Enhanced ProofDict - -```python -class ProofDict(TypedDict): - """Enhanced proof with keyset information.""" - id: str # keyset_id - amount: int - secret: str - C: str - mint: str - unit: NotRequired[CurrencyUnit] # currency unit for this proof -``` - -### 2. Core Component Updates - -#### Wallet Class Enhancements - -```python -class Wallet: - def __init__(self, ...): - # Remove single currency restriction - self.default_unit: CurrencyUnit = "sat" # default unit for operations - self.keysets: dict[str, KeysetInfo] = {} # keyset_id -> KeysetInfo - self.mint_keysets: dict[str, list[str]] = {} # mint_url -> [keyset_ids] -``` - -## Implementation Tasks - -### Phase 1: Foundation (Core Infrastructure) - -#### Task 1.1: Update Data Models - -- [ ] Create `KeysetInfo` dataclass with full keyset information -- [ ] Update `WalletState` to include keyset tracking and multi-currency balances -- [ ] Add `unit` field to `ProofDict` for currency tracking -- [ ] Update type hints throughout the codebase - -#### Task 1.2: Keyset Management System - -- [ ] Create `KeysetManager` class for centralized keyset operations -- [ ] Implement keyset caching with expiration -- [ ] Add methods for keyset validation and comparison -- [ ] Implement keyset rotation detection - -#### Task 1.3: Dynamic Denomination System - -- [ ] Remove hardcoded denomination lists -- [ ] Implement denomination fetching from keyset keys -- [ ] Create denomination optimization algorithm that respects available denominations -- [ ] Add denomination validation against keyset - -### Phase 2: Multi-Currency Support - -#### Task 2.1: Currency-Aware Operations - -- [ ] Update `_calculate_optimal_denominations` to use keyset-specific denominations -- [ ] Modify proof selection to be currency-aware -- [ ] Implement currency conversion detection and prevention -- [ ] Add currency validation to all operations - -#### Task 2.2: Balance Management - -- [ ] Implement `get_balance_by_currency()` method -- [ ] Update `fetch_wallet_state()` to calculate per-currency balances -- [ ] Add `get_balance_by_keyset()` for fine-grained balance tracking -- [ ] Update CLI balance display to show breakdown by currency - -#### Task 2.3: Keyset Selection Logic - -- [ ] Implement intelligent keyset selection based on: - - Available balance - - Fees - - Currency requirements - - Active status -- [ ] Add fallback logic for inactive keysets -- [ ] Implement keyset preference system - -### Phase 3: Keyset Operations - -#### Task 3.1: Universal Keyset Swap Function - -```python -async def swap_keyset( - self, - proofs: list[ProofDict], - target_keyset_id: str, - *, - amount: int | None = None, -) -> list[ProofDict]: - """Universal function to swap proofs to a different keyset.""" -``` - -- [ ] Implement cross-currency swaps (via Lightning) -- [ ] Implement same-currency keyset swaps (direct swap) -- [ ] Handle fees for both swap types -- [ ] Add atomic swap guarantees - -#### Task 3.2: Granular Operation Control - -- [ ] Add `keyset_id` parameter to: - - `mint()` - mint to specific keyset - - `melt()` - melt from specific keyset - - `send()` - send from specific keyset - - `swap()` - swap within specific keyset -- [ ] Implement parameter validation -- [ ] Add keyset availability checks - -#### Task 3.3: Cross-Mint Currency Exchange - -- [ ] Implement `exchange_currency()` method -- [ ] Support for cross-mint transfers with currency conversion -- [ ] Calculate exchange rates based on Lightning quotes -- [ ] Implement fee optimization for exchanges - -### Phase 4: API Updates - -#### Task 4.1: Mint API Integration - -- [ ] Update `get_keysets()` to fetch all keysets (not just active) -- [ ] Implement keyset filtering by currency and active status -- [ ] Add keyset metadata caching -- [ ] Implement keyset update notifications - -#### Task 4.2: Token Format Updates - -- [ ] Ensure V4 tokens properly encode currency information -- [ ] Update token parsing to extract currency data -- [ ] Implement backward compatibility for older tokens -- [ ] Add currency validation on token redemption - -### Phase 5: CLI Enhancements - -#### Task 5.1: Balance Display - -``` -$ cashu balance -Total Balance: 1500 sat, 10.50 usd, 9.20 eur - -By Mint: -├── https://mint1.example.com -│ ├── sat: 1000 (keyset: 00a1b2...) -│ └── usd: 10.50 (keyset: 00c3d4...) -└── https://mint2.example.com - ├── sat: 500 (keyset: 00e5f6...) - └── eur: 9.20 (keyset: 00g7h8...) -``` - -#### Task 5.2: Currency Operations - -- [ ] Add `--unit` flag to all relevant commands -- [ ] Implement `cashu exchange` command for currency swaps -- [ ] Add `cashu keysets` command to list available keysets -- [ ] Update help text with currency examples - -#### Task 5.3: Keyset Management Commands - -- [ ] `cashu keysets list` - show all keysets -- [ ] `cashu keysets info ` - detailed keyset information -- [ ] `cashu keysets rotate` - handle keyset rotation -- [ ] `cashu keysets set-default ` - set default keyset - -### Phase 6: Testing & Migration - -#### Task 6.1: Test Suite Updates - -- [ ] Add multi-currency unit tests -- [ ] Add keyset swap integration tests -- [ ] Test cross-mint currency exchanges -- [ ] Add denomination edge case tests - -#### Task 6.2: Migration Strategy - -- [ ] Create migration script for existing wallets -- [ ] Handle legacy single-currency proofs -- [ ] Implement gradual migration path -- [ ] Add rollback capability - -#### Task 6.3: Documentation - -- [ ] Update API documentation -- [ ] Create currency exchange examples -- [ ] Document keyset management -- [ ] Add troubleshooting guide - -## Implementation Order - -1. **Week 1-2**: Phase 1 - Foundation - - Update data models - - Implement keyset management - - Dynamic denomination system - -2. **Week 3-4**: Phase 2 - Multi-Currency Support - - Currency-aware operations - - Balance management - - Keyset selection logic - -3. **Week 5-6**: Phase 3 - Keyset Operations - - Universal swap function - - Granular operation control - - Cross-mint exchanges - -4. **Week 7**: Phase 4 - API Updates - - Mint API integration - - Token format updates - -5. **Week 8**: Phase 5 - CLI Enhancements - - Balance display - - Currency operations - - Keyset management commands - -6. **Week 9-10**: Phase 6 - Testing & Migration - - Test suite updates - - Migration strategy - - Documentation - -## Technical Considerations - -### Performance - -- Keyset information should be cached to avoid frequent API calls -- Balance calculations should be optimized for wallets with many proofs -- Denomination selection algorithm must be efficient - -### Security - -- Keyset validation must be thorough to prevent attacks -- Currency swap operations must be atomic -- Private keys must never be exposed during swaps - -### Compatibility - -- Maintain backward compatibility with existing tokens -- Support gradual adoption of new features -- Ensure compatibility with other Cashu implementations - -### Error Handling - -- Clear error messages for currency mismatches -- Graceful handling of unavailable keysets -- Rollback mechanisms for failed swaps - -## Future Enhancements - -1. **Automatic Currency Arbitrage**: Detect and execute profitable currency swaps -2. **Keyset Reputation System**: Track keyset reliability and performance -3. **Multi-Signature Keysets**: Support for keysets requiring multiple signatures -4. **Currency Hedging**: Automatic balancing across currencies -5. **Keyset Discovery**: Automatic discovery of new keysets from mints - -## Conclusion - -This refactor will transform the sixty_nuts wallet from a single-currency system to a fully-featured multi-currency wallet with proper keyset management. The phased approach ensures that each component is properly tested before moving to the next phase, minimizing risk and ensuring a smooth transition for users. From 3108320d78617aa524fd59f5820566824c6c2cfb Mon Sep 17 00:00:00 2001 From: Shroominic Date: Sat, 19 Jul 2025 14:26:13 -0300 Subject: [PATCH 17/17] rm debug prints --- sixty_nuts/mint.py | 1 - sixty_nuts/wallet.py | 5 ----- 2 files changed, 6 deletions(-) diff --git a/sixty_nuts/mint.py b/sixty_nuts/mint.py index 8297c1e..93e680f 100644 --- a/sixty_nuts/mint.py +++ b/sixty_nuts/mint.py @@ -364,7 +364,6 @@ async def mint_exchange_rate(self, unit: CurrencyUnit) -> float: return rate # Cache miss or expired - fetch new rate - # TODO: test this quote = await self.create_mint_quote(amount=1000, unit=unit) invoice_amount_sats = parse_lightning_invoice_amount( quote["request"], "sat" diff --git a/sixty_nuts/wallet.py b/sixty_nuts/wallet.py index 7c7a5f6..1ed6b8b 100644 --- a/sixty_nuts/wallet.py +++ b/sixty_nuts/wallet.py @@ -172,7 +172,6 @@ async def redeem(self, token: str, *, auto_swap: bool = True) -> tuple[int, str] """ # Parse token mint_url, unit, proofs = self._parse_cashu_token(token) - print("DEBUG", mint_url, unit) # Normalize mint URL for comparison mint_url = normalize_mint_url(mint_url) @@ -180,13 +179,11 @@ async def redeem(self, token: str, *, auto_swap: bool = True) -> tuple[int, str] # Check if this is a trusted mint if auto_swap and self.mint_urls and mint_url not in self.mint_urls: # Token is from untrusted mint - swap to our primary mint - print("DEBUG SWAP", self._primary_mint_url()) return await self.remote_redeem_proofs(proofs, self._primary_mint_url()) # Proceed with normal redemption for trusted mints # Calculate total amount total_amount = sum(p["amount"] for p in proofs) - print("DEBUG TOTAL AMOUNT", total_amount) # Get mint instance to calculate fees mint = self._get_mint(mint_url) @@ -645,7 +642,6 @@ async def _consolidate_proofs( if not proofs: state = await self.fetch_wallet_state(check_proofs=True) proofs = state.proofs - # TODO: these proofs are not used anywhere following logic needs to be updated # Get currency unit from the first proof unit = proofs[0].get("unit") or "sat" @@ -1282,7 +1278,6 @@ async def remote_redeem_proofs( # Get the total amount in the source mint's native unit total_amount_source_unit = sum(p["amount"] for p in proofs) - print("DEBUG PROOF", proofs[0]) source_unit = proofs[0].get("unit", "sat") # Convert to sats for estimation