Skip to content

Commit 195a053

Browse files
committed
fix mint url selection
1 parent 74d5026 commit 195a053

File tree

1 file changed

+99
-89
lines changed

1 file changed

+99
-89
lines changed

sixty_nuts/wallet.py

Lines changed: 99 additions & 89 deletions
Original file line numberDiff line numberDiff line change
@@ -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

Comments
 (0)