@@ -243,6 +243,22 @@ class WalletState:
243243 None # proof_id -> event_id mapping (TODO)
244244 )
245245
246+ @property
247+ def proofs_by_mints (self ) -> dict [str , list [ProofDict ]]:
248+ """Group proofs by mint."""
249+ return {
250+ mint_url : [proof for proof in self .proofs if proof ["mint" ] == mint_url ]
251+ for mint_url in self .mint_keysets .keys ()
252+ }
253+
254+ @property
255+ def mint_balances (self ) -> dict [str , int ]:
256+ """Get balances for all mints."""
257+ return {
258+ mint_url : sum (p ["amount" ] for p in self .proofs_by_mints [mint_url ])
259+ for mint_url in self .mint_keysets .keys ()
260+ }
261+
246262
247263# ──────────────────────────────────────────────────────────────────────────────
248264# Wallet implementation skeleton
@@ -463,7 +479,7 @@ async def redeem(self, token: str, *, auto_swap: bool = True) -> tuple[int, str]
463479 # Check if this is a trusted mint
464480 if auto_swap and self .mint_urls and mint_url not in self .mint_urls :
465481 # Token is from untrusted mint - swap to our primary mint
466- proofs = await self .transfer_proofs (proofs , self ._get_primary_mint_url ())
482+ proofs = await self .transfer_proofs (proofs , self ._primary_mint_url ())
467483
468484 # Proceed with normal redemption for trusted mints
469485 # Calculate total amount
@@ -519,8 +535,9 @@ async def mint_async(
519535 # Do other things...
520536 paid = await task # Wait for payment
521537 """
522- invoice , quote_id = await self .create_quote (amount )
523- mint = self ._get_mint (self ._get_primary_mint_url ())
538+ mint_url = self ._primary_mint_url ()
539+ invoice , quote_id = await self .create_quote (amount , mint_url )
540+ mint = self ._get_mint (mint_url )
524541
525542 async def poll_payment () -> bool :
526543 start_time = time .time ()
@@ -532,7 +549,7 @@ async def poll_payment() -> bool:
532549 quote_id ,
533550 amount ,
534551 minted_quotes = self ._minted_quotes ,
535- mint_url = self . _get_primary_mint_url () ,
552+ mint_url = mint_url ,
536553 )
537554
538555 # If new proofs were minted, publish wallet events
@@ -591,24 +608,31 @@ async def melt(self, invoice: str, *, target_mint: str | None = None) -> None:
591608 Example:
592609 await wallet.melt("lnbc100n1...")
593610 """
594- if target_mint is None :
595- target_mint = self ._get_primary_mint_url ()
596-
597611 try :
598612 invoice_amount = parse_lightning_invoice_amount (invoice , self .currency )
599613 except LNURLError as e :
600614 raise WalletError (f"Invalid Lightning invoice: { e } " ) from e
601615
602616 # Get current state and check balance
603617 state = await self .fetch_wallet_state (check_proofs = True )
618+ total_needed = int (invoice_amount * 1.01 )
619+ self .raise_if_insufficient_balance (state .balance , total_needed )
620+
621+ if target_mint is None :
622+ mint_balances = state .mint_balances
623+ if not mint_balances :
624+ raise WalletError ("No mints available" )
625+ target_mint = max (mint_balances , key = lambda k : mint_balances [k ])
626+ if mint_balances [target_mint ] < total_needed :
627+ await self .rebalance_until_target (target_mint , total_needed )
628+ return await self .melt (invoice , target_mint = target_mint )
604629
605630 # Create melt quote to get fees
606631 mint = self ._get_mint (target_mint )
607632 melt_quote = await mint .create_melt_quote (unit = self .currency , request = invoice )
608633 fee_reserve = melt_quote .get ("fee_reserve" , 0 )
609634 total_needed = invoice_amount + fee_reserve
610-
611- self .raise_if_insufficient_balance (state .balance , total_needed )
635+ # TODO: check if we have enough balance in the target mint with real fee caclulation
612636
613637 # Select proofs for the total amount needed (invoice + fees)
614638 selected_proofs , consumed_proofs = await self ._select_proofs (
@@ -630,6 +654,7 @@ async def melt(self, invoice: str, *, target_mint: str | None = None) -> None:
630654 # Handle any change returned from the mint
631655 change_proofs : list [ProofDict ] = []
632656 if "change" in melt_resp and melt_resp ["change" ]:
657+ # TODO: handle change
633658 # Convert BlindedSignatures to ProofDict format
634659 # This would require unblinding logic, but for now we'll skip change handling
635660 # In practice, most melts shouldn't have change if amounts are selected properly
@@ -686,13 +711,14 @@ async def send(
686711 raise ValueError (f"Unsupported token version: { token_version } . Use 3 or 4." )
687712
688713 if target_mint is None :
689- target_mint = self ._get_primary_mint_url ( )
714+ target_mint = await self .summon_mint_with_balance ( amount )
690715
691716 state = await self .fetch_wallet_state (check_proofs = True )
692- if state .balance < amount :
717+ balance = await self .get_balance_by_mint (target_mint )
718+ if balance < amount :
693719 raise WalletError (
694- f"Insufficient balance. Need at least { amount } { self .currency } "
695- f"(amount: { amount } ), but have { state . balance } "
720+ f"Insufficient balance at { target_mint } . Need at least { amount } { self .currency } "
721+ f"(amount: { amount } ), but have { balance } "
696722 )
697723
698724 selected_proofs , consumed_proofs = await self ._select_proofs (
@@ -801,13 +827,13 @@ async def roll_over_proofs(
801827
802828 # ───────────────────────── Proof Management ─────────────────────────────────
803829
804- async def create_quote (self , amount : int ) -> tuple [str , str ]:
830+ async def create_quote (self , amount : int , mint_url : str ) -> tuple [str , str ]:
805831 """Create a Lightning invoice (quote) at the mint and return the BOLT-11 string and quote ID.
806832
807833 Returns:
808834 Tuple of (lightning_invoice, quote_id)
809835 """
810- mint = self ._get_mint (self . _get_primary_mint_url () )
836+ mint = self ._get_mint (mint_url )
811837
812838 # Create mint quote
813839 quote_resp = await mint .create_mint_quote (
@@ -821,7 +847,7 @@ async def create_quote(self, amount: int) -> tuple[str, str]:
821847 # TODO: Implement quote tracking as per NIP-60:
822848 # await self.publish_quote_tracker(
823849 # quote_id=quote_resp["quote"],
824- # mint_url=self.mint_urls[0] ,
850+ # mint_url=mint_url ,
825851 # expiration=int(time.time()) + 14 * 24 * 60 * 60 # 2 weeks
826852 # )
827853
@@ -846,20 +872,8 @@ async def _consolidate_proofs(
846872 state = await self .fetch_wallet_state (check_proofs = True )
847873 proofs = state .proofs
848874
849- # Group proofs by mint
850- proofs_by_mint : dict [str , list [ProofDict ]] = {}
851- for proof in proofs :
852- mint_url = proof .get ("mint" ) or (
853- self ._get_primary_mint_url () if self .mint_urls else ""
854- )
855- if target_mint and mint_url != target_mint :
856- continue # Skip if not the target mint
857- if mint_url not in proofs_by_mint :
858- proofs_by_mint [mint_url ] = []
859- proofs_by_mint [mint_url ].append (proof )
860-
861875 # Process each mint
862- for mint_url , mint_proofs in proofs_by_mint .items ():
876+ for mint_url , mint_proofs in state . proofs_by_mints .items ():
863877 if not mint_proofs :
864878 continue
865879
@@ -1057,7 +1071,7 @@ async def _swap_proof_denominations(
10571071 self ,
10581072 proofs : list [ProofDict ],
10591073 target_denominations : dict [int , int ],
1060- mint_url : str | None = None ,
1074+ mint_url : str ,
10611075 ) -> list [ProofDict ]:
10621076 """Swap proofs to specific target denominations.
10631077
@@ -1079,11 +1093,6 @@ async def _swap_proof_denominations(
10791093 if not proofs :
10801094 return []
10811095
1082- # Determine mint URL
1083- if mint_url is None :
1084- mint_url = proofs [0 ].get ("mint" ) or (
1085- self ._get_primary_mint_url () if self .mint_urls else None
1086- )
10871096 if not mint_url :
10881097 raise WalletError ("No mint URL available" )
10891098
@@ -1345,21 +1354,11 @@ async def store_proofs(self, proofs: list[ProofDict]) -> None:
13451354 pass
13461355 return
13471356
1348- # Group proofs by mint for efficient storage
1349- proofs_by_mint : dict [str , list [ProofDict ]] = {}
1350- for proof in new_proofs :
1351- mint_url = proof .get ("mint" ) or (
1352- self ._get_primary_mint_url () if self .mint_urls else ""
1353- )
1354- if mint_url not in proofs_by_mint :
1355- proofs_by_mint [mint_url ] = []
1356- proofs_by_mint [mint_url ].append (proof )
1357-
13581357 # Publish token events for each mint
13591358 published_count = 0
13601359 failed_mints = []
13611360
1362- for mint_url , mint_proofs in proofs_by_mint .items ():
1361+ for mint_url , mint_proofs in state . proofs_by_mints .items ():
13631362 try :
13641363 # Publish token event
13651364 event_manager = await self ._ensure_event_manager ()
@@ -1547,8 +1546,8 @@ async def transfer_proofs(
15471546 # Just propagate the error with more context
15481547 if "Lightning payment infrastructure" in str (e ):
15491548 raise WalletError (
1550- f "Multi-mint transfers require Lightning infrastructure which is not yet implemented. "
1551- f "Your proofs are safe and not consumed."
1549+ "Multi-mint transfers require Lightning infrastructure which is not yet implemented. "
1550+ "Your proofs are safe and not consumed."
15521551 ) from e
15531552 else :
15541553 raise WalletError (
@@ -1558,6 +1557,14 @@ async def transfer_proofs(
15581557 # Return both existing target mint proofs and newly transferred proofs
15591558 return target_mint_proofs + transferred_proofs
15601559
1560+ async def rebalance_until_target (self , target_mint : str , total_needed : int ) -> None :
1561+ """Rebalance until the target mint has at least the total needed."""
1562+ raise NotImplementedError ("Not implemented" ) # TODO: Implement
1563+ # mint_balances = await self.mint_balances()
1564+ # if mint_balances[target_mint] >= total_needed:
1565+ # return
1566+ # await self.transfer_balance_to_mint(total_needed, target_mint)
1567+
15611568 async def _create_proofs_at_mint (
15621569 self , mint_url : str , amount : int , denominations : dict [int , int ]
15631570 ) -> list [ProofDict ]:
@@ -1568,10 +1575,7 @@ async def _create_proofs_at_mint(
15681575 """
15691576 from .crypto import (
15701577 create_blinded_message_with_secret ,
1571- get_mint_pubkey_for_amount ,
1572- unblind_signature ,
15731578 )
1574- from coincurve import PublicKey
15751579
15761580 mint = self ._get_mint (mint_url )
15771581
@@ -1613,7 +1617,8 @@ async def _create_proofs_at_mint(
16131617 amount = amount ,
16141618 )
16151619 quote_id = quote_resp ["quote" ]
1616-
1620+ print (f"Quote ID: { quote_id } " )
1621+ # TODO: Implement mint quote payment
16171622 # Attempt to mint using the quote
16181623 # Note: This will fail unless the invoice is actually paid
16191624 # For now, we'll raise an error indicating Lightning payment is needed
@@ -1734,11 +1739,26 @@ async def transfer_balance_to_mint(self, amount: int, target_mint: str) -> None:
17341739 if "Lightning payment infrastructure" in str (e ):
17351740 # For now, raise a more user-friendly error
17361741 raise WalletError (
1737- f "Multi-mint transfers require Lightning infrastructure which is not yet implemented. "
1738- f "Please consolidate your funds to a single mint manually, or wait for this feature to be completed."
1742+ "Multi-mint transfers require Lightning infrastructure which is not yet implemented. "
1743+ "Please consolidate your funds to a single mint manually, or wait for this feature to be completed."
17391744 ) from e
17401745 raise
17411746
1747+ async def summon_mint_with_balance (self , amount : int ) -> str :
1748+ """Summon a mint with at least the given amount of balance."""
1749+ state = await self .fetch_wallet_state ()
1750+ total_balance = state .balance
1751+ if total_balance * 0.99 < amount :
1752+ raise WalletError (
1753+ f"Insufficient balance. Need at least { amount } { self .currency } "
1754+ f"(amount: { amount } ), but have { total_balance } "
1755+ )
1756+ mint_balances = state .mint_balances
1757+ target_mint = max (mint_balances , key = lambda k : mint_balances [k ])
1758+ if mint_balances [target_mint ] < amount :
1759+ await self .rebalance_until_target (target_mint , amount )
1760+ return target_mint
1761+
17421762 # ───────────────────────── Helper Methods ─────────────────────────────────
17431763
17441764 def _get_mint (self , mint_url : str ) -> Mint :
@@ -1989,22 +2009,10 @@ async def _validate_proofs_with_cache(
19892009 else :
19902010 proofs_to_check .append (proof )
19912011
1992- # Second pass: validate uncached proofs
19932012 if proofs_to_check :
1994- # Group by mint for batch validation
1995- proofs_by_mint : dict [str , list [ProofDict ]] = {}
1996- for proof in proofs_to_check :
1997- # Get mint URL from proof, fallback to primary mint URL
1998- mint_url = proof .get ("mint" ) or (
1999- self ._get_primary_mint_url () if self .mint_urls else None
2000- )
2001- if mint_url :
2002- if mint_url not in proofs_by_mint :
2003- proofs_by_mint [mint_url ] = []
2004- proofs_by_mint [mint_url ].append (proof )
2005-
2006- # Validate with each mint
2007- for mint_url , mint_proofs in proofs_by_mint .items ():
2013+ for mint_url , mint_proofs in self ._sort_proofs_by_mint (
2014+ proofs_to_check
2015+ ).items ():
20082016 try :
20092017 mint = self ._get_mint (mint_url )
20102018 y_values = self ._compute_proof_y_values (mint_proofs )
@@ -2056,6 +2064,7 @@ async def fetch_wallet_state(self, *, check_proofs: bool = True) -> WalletState:
20562064 wallet_event = max (wallet_events , key = lambda e : e ["created_at" ])
20572065
20582066 # Parse wallet metadata
2067+ # TODO this should not always fetch the wallet event
20592068 if wallet_event :
20602069 try :
20612070 decrypted = nip44_decrypt (wallet_event ["content" ], self ._privkey )
@@ -2129,9 +2138,9 @@ async def fetch_wallet_state(self, *, check_proofs: bool = True) -> WalletState:
21292138 continue
21302139
21312140 proofs = token_data .get ("proofs" , [])
2132- mint_url = token_data .get (
2133- "mint" , self . _get_primary_mint_url () if self . mint_urls else None
2134- )
2141+ mint_url = token_data .get ("mint" )
2142+ if not mint_url :
2143+ raise WalletError ( "No mint URL found in token event" )
21352144
21362145 for proof in proofs :
21372146 # Convert from NIP-60 format (base64) to internal format (hex)
@@ -2165,11 +2174,9 @@ async def fetch_wallet_state(self, *, check_proofs: bool = True) -> WalletState:
21652174 pending_token_data = self .relay_manager .get_pending_proofs ()
21662175
21672176 for token_data in pending_token_data :
2168- mint_url = token_data .get (
2169- "mint" , self ._get_primary_mint_url () if self .mint_urls else None
2170- )
2171- if not isinstance (mint_url , str ):
2172- continue
2177+ mint_url = token_data .get ("mint" )
2178+ if not mint_url or not isinstance (mint_url , str ):
2179+ raise WalletError ("No mint URL found in pending token event" )
21732180
21742181 proofs = token_data .get ("proofs" , [])
21752182 if not isinstance (proofs , list ):
@@ -2272,6 +2279,11 @@ async def get_balance(self, *, check_proofs: bool = True) -> int:
22722279 state = await self .fetch_wallet_state (check_proofs = check_proofs )
22732280 return state .balance
22742281
2282+ async def get_balance_by_mint (self , mint_url : str ) -> int :
2283+ """Get balance for a specific mint."""
2284+ state = await self .fetch_wallet_state (check_proofs = True )
2285+ return sum (p ["amount" ] for p in state .proofs if p ["mint" ] == mint_url )
2286+
22752287 # ─────────────────────────────── Cleanup ──────────────────────────────────
22762288
22772289 async def aclose (self ) -> None :
@@ -2616,19 +2628,9 @@ async def cleanup_wallet_state(self, *, dry_run: bool = False) -> dict[str, int]
26162628
26172629 print (f"🔄 Consolidating { len (state .proofs )} proofs into fresh events..." )
26182630
2619- # Group proofs by mint for consolidation
2620- proofs_by_mint : dict [str , list [ProofDict ]] = {}
2621- for proof in state .proofs :
2622- mint_url = proof .get ("mint" ) or (
2623- self ._get_primary_mint_url () if self .mint_urls else ""
2624- )
2625- if mint_url not in proofs_by_mint :
2626- proofs_by_mint [mint_url ] = []
2627- proofs_by_mint [mint_url ].append (proof )
2628-
26292631 # Create fresh consolidated events
26302632 new_event_ids = []
2631- for mint_url , mint_proofs in proofs_by_mint .items ():
2633+ for mint_url , mint_proofs in state . proofs_by_mints .items ():
26322634 try :
26332635 event_manager = await self ._ensure_event_manager ()
26342636 new_id = await event_manager .publish_token_event (
@@ -2680,7 +2682,7 @@ async def cleanup_wallet_state(self, *, dry_run: bool = False) -> dict[str, int]
26802682 )
26812683 return stats
26822684
2683- def _get_primary_mint_url (self ) -> str :
2685+ def _primary_mint_url (self ) -> str :
26842686 """Get the primary mint URL (first one when sorted).
26852687
26862688 Returns:
@@ -2692,3 +2694,11 @@ def _get_primary_mint_url(self) -> str:
26922694 if not self .mint_urls :
26932695 raise WalletError ("No mint URLs configured" )
26942696 return sorted (self .mint_urls )[0 ] # Use sorted order for consistency
2697+
2698+ def _sort_proofs_by_mint (
2699+ self , proofs : list [ProofDict ]
2700+ ) -> dict [str , list [ProofDict ]]:
2701+ return {
2702+ mint_url : [proof for proof in proofs if proof ["mint" ] == mint_url ]
2703+ for mint_url in set (proof ["mint" ] for proof in proofs )
2704+ }
0 commit comments