Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- **BTC Karma Bitcoin staking card (DeFi page)**: tracked Bitcoin wallets are now scanned for [BTC Karma](https://staking.btckarma.io) stakes, shown as a "Bitcoin · BTC Karma" card under Staked Positions with the staked BTC, its USD value and the Cardano wallet that receives the KARMA rewards. Data comes straight from the Bitcoin chain through the public Esplora API (mempool.space, blockstream.info fallback) — no BTC Karma login or key. A stake is a transaction spent from your address that pays a P2WSH vault and carries an `OP_RETURN` of `CB|` + blake2b-512 of the bound Cardano address; an unspent vault is an active stake, so unstaking removes the card on its own. To use it, add the Bitcoin address you staked from as an ordinary Bitcoin wallet (single address, not an xpub). Unlock time and KARMA reward balances are not public on-chain and are not shown. New endpoint `GET /defi/btc-karma/{address}` (1 h cache, stale-cache fallback, shows "Data unavailable" rather than erroring). 24 new unit tests in `tests/unit/test_btc_karma.py` (recorded mainnet responses, no live calls).

### Fixed
- **Polygon/Base NFTs missing for EVM addresses stored as Ethereum (ABCT-EVM-NFT-FANOUT-2026-09-28)**: wallets are stored one-row-per-(address, blockchain), and a bare `0x` address is always filed as `ethereum` (`detect_blockchain` only assigns `polygon`/`base` from an explicit `polygon:0x...`/`base:0x...` prefix). `/nfts/polygon` and `/nfts/base` only ever asked Alchemy about addresses whose own row already said that chain, so a real Polygon/Base holder registered as `ethereum` was invisible to those pages — confirmed live: 15 of 15 tracked `ethereum` addresses had no matching `polygon`/`base` row, and several genuinely hold non-spam Polygon/Base NFTs today (Unstoppable Domains, Artificial Intelligence For Dummies, and others), including one wallet literally labeled "... Base" that was stored as `ethereum`. Every EVM-family wallet (ethereum/polygon/base/bsc/arbitrum/avalanche) is now a candidate address for Ethereum, Polygon, and Base NFT fetches (deduplicated by address, so an address registered on two chains is never fetched or counted twice), with the same spam filter, persistent cache, and wipe-guard-on-provider-failure behaviour from #11/#16, plus the #16 wallet-set-fingerprint cache invalidation extended to Ethereum/Polygon/Base so adding an EVM wallet is picked up immediately. `detect_blockchain` and how wallets are stored are unchanged. Totals are unaffected by this PR (NFT value only counts toward the portfolio total if the user has turned on "include NFTs in total", off by default) — EVM native/ERC-20 balance fan-out was split out of the original PR (#17) after independent review found its totals math wrong and is being reworked separately. 8 new unit tests in `tests/unit/test_evm_fanout.py` (mocked HTTP/DB, fake addresses, no live calls or real keys).
- **NFT page showed only Ethereum after the Cardano image fix**: #12 pointed every Cardano NFT image at the public `ipfs.io` gateway, which now answers hot-linked images with a Cloudflare challenge (HTTP 403/429). All Cardano (and the Algorand) images failed; Gallery Mode hides tiles whose image fails, so only Ethereum was left, and the grid showed placeholders. IPFS images are now offered on several gateways (`image_url` plus ordered `image_fallbacks`, configurable with `NFT_DISPLAY_IPFS_GATEWAYS`, default `ipfs.blockfrost.dev` → Pinata → `ipfs.io` → `dweb.link`) and the page tries the next gateway before giving up. The page only ever uses `https://` / `data:image/` image URLs (closes the raw `http://` fallback noted in the #12 review). The All Chains grid no longer stops at 100 cards (a "Show more" button reveals the rest, so Algorand/Solana are reachable), collection names no longer render as `[object Object]`, and the collection count is per chain. Solana NFTs are refetched when a Solana wallet is added or removed (a hash of the wallet set is stored with the cache) instead of staying hidden for up to 24 h / 30 days. 36 new unit tests in `tests/unit/test_nft_display_images.py`.
- **Solana wallet balances failed, and Settings showed Helius as failing, even though Helius NFTs worked (ABCT-HELIUS-BALANCES-FIX-2026-09-28)**: Helius retired the v0 REST `/addresses/{address}/balances` endpoint (now HTTP 404 "Method not found"), which `services/solana.py` used for every Solana wallet's SOL + SPL token balance, and which the Settings page's Helius health/test check also called — so Solana balances failed on every wallet (`wallet_balance_bgsync`: "Failed to fetch balance from all sources (Helius + public RPC)", 21× in the logs) and a newly added Solana wallet showed 0 SOL and 0 tokens even with real holdings, while Helius NFT (DAS) fetches kept working fine. `get_address_info()` now uses the Helius DAS `searchAssets` JSON-RPC method (`tokenType: "fungible"`, `displayOptions.showNativeBalance`) — the current, GA, free-tier-compatible way to get native SOL + SPL token balances in one call ([docs](https://www.helius.dev/docs/api-reference/das/searchassets)) — instead of the retired endpoint; same output shape (`balance_sol`, `balance_lamports`, `tokens[]` with mint/symbol/name/balance/amount_raw/decimals), same DB-first-then-env key lookup, same public-RPC fallback on a network-level failure. The Settings "helius" health/test check now POSTs a plain, address-independent Solana `getHealth` JSON-RPC call instead of hitting the retired endpoint. No other chain's balance path used this endpoint. 11 new unit tests in `tests/unit/test_helius_balances_fix.py` (mocked HTTP/DB, fake addresses, no live calls or real keys), including SOL-only, SOL+SPL (real HNT mint/decimals), zero-balance, provider-error and health-check cases, plus an end-to-end test through the real wallet-refresh save path.
- **System-wide cache entries piled up and were read back stale (ABCT-NFT-DIAG-2026-09-27)**: the `cache` table's `UNIQUE(user_id, key)` never matches system-wide entries, because SQLite treats NULLs as distinct. `set_cache(..., user_id=None)` therefore inserted a new row on every write instead of updating, and `get_cache` read the oldest row. A production snapshot had 1,057 system-wide rows for 127 keys. The persistent Ethereum/Polygon/Base/Solana NFT caches (and every other system-wide cache) were read back as long-expired entries, so each restart re-fetched from the providers. System-wide writes now update in place (inserting only when absent, and bringing every legacy duplicate up to date), and reads take the newest row. No schema change. 3 new unit tests in `tests/unit/test_system_cache_upsert.py`.
Expand Down
37 changes: 19 additions & 18 deletions backend/routers/nfts.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
from services.base import base_service
from services.algorand_nft import algorand_nft_service
from services.evm_chain import bsc_service, arbitrum_service, avalanche_service
from services.alchemy_nft_utils import evm_fanout_wallets
from services.pricing import pricing_service
from services.nft_price_client import nft_price_client
from services.demo_nft_service import demo_nft_service
Expand Down Expand Up @@ -552,7 +553,7 @@ async def get_polygon_nfts(user_id: int = Depends(verify_session), force_refresh

# Get all Polygon wallets
wallets = await get_all_wallets(user_id=user_id)
polygon_wallets = [w for w in wallets if w['blockchain'] == 'polygon']
polygon_wallets = evm_fanout_wallets(wallets) # ABCT-EVM-FANOUT-2026-09-28: every EVM-family wallet, not just rows stored as 'polygon'

if not polygon_wallets:
return {
Expand Down Expand Up @@ -614,7 +615,7 @@ async def get_polygon_nft_summary(user_id: int = Depends(verify_session)):

# Get all Polygon wallets
wallets = await get_all_wallets(user_id=user_id)
polygon_wallets = [w for w in wallets if w['blockchain'] == 'polygon']
polygon_wallets = evm_fanout_wallets(wallets) # ABCT-EVM-FANOUT-2026-09-28: every EVM-family wallet, not just rows stored as 'polygon'

summary = await polygon_service.get_nft_summary(polygon_wallets)

Expand Down Expand Up @@ -649,7 +650,7 @@ async def refresh_polygon_nfts(user_id: int = Depends(verify_session)):

# Get all Polygon wallets
wallets = await get_all_wallets(user_id=user_id)
polygon_wallets = [w for w in wallets if w['blockchain'] == 'polygon']
polygon_wallets = evm_fanout_wallets(wallets) # ABCT-EVM-FANOUT-2026-09-28: every EVM-family wallet, not just rows stored as 'polygon'

polygon_service.clear_cache()
all_nfts = await polygon_service.get_all_polygon_nfts(polygon_wallets, force_refresh=True)
Expand Down Expand Up @@ -701,7 +702,7 @@ async def get_base_nfts(user_id: int = Depends(verify_session), force_refresh: b

# Get Base wallets
wallets = await get_all_wallets(user_id=user_id)
base_wallets = [w for w in wallets if w['blockchain'] == 'base']
base_wallets = evm_fanout_wallets(wallets) # ABCT-EVM-FANOUT-2026-09-28: every EVM-family wallet, not just rows stored as 'base'

if not base_wallets:
return {
Expand Down Expand Up @@ -753,7 +754,7 @@ async def get_base_nft_summary(user_id: int = Depends(verify_session)):
}

wallets = await get_all_wallets(user_id=user_id)
base_wallets = [w for w in wallets if w['blockchain'] == 'base']
base_wallets = evm_fanout_wallets(wallets) # ABCT-EVM-FANOUT-2026-09-28: every EVM-family wallet, not just rows stored as 'base'

summary = await base_service.get_nft_summary(base_wallets)

Expand All @@ -779,7 +780,7 @@ async def refresh_base_nfts(user_id: int = Depends(verify_session)):
return {'success': False, 'message': 'Alchemy API key not configured'}

wallets = await get_all_wallets(user_id=user_id)
base_wallets = [w for w in wallets if w['blockchain'] == 'base']
base_wallets = evm_fanout_wallets(wallets) # ABCT-EVM-FANOUT-2026-09-28: every EVM-family wallet, not just rows stored as 'base'

base_service.clear_cache()
all_nfts = await base_service.get_all_base_nfts(base_wallets, force_refresh=True)
Expand Down Expand Up @@ -1156,8 +1157,8 @@ async def get_all_chains_nft_summary(user_id: int = Depends(verify_session)):
arbitrum_service.is_configured(),
avalanche_service.is_configured(),
)
polygon_wallets = [w for w in wallets if w['blockchain'] == 'polygon']
base_wallets = [w for w in wallets if w['blockchain'] == 'base']
polygon_wallets = evm_fanout_wallets(wallets) # ABCT-EVM-FANOUT-2026-09-28: every EVM-family wallet, not just rows stored as 'polygon'
base_wallets = evm_fanout_wallets(wallets) # ABCT-EVM-FANOUT-2026-09-28: every EVM-family wallet, not just rows stored as 'base'
bsc_wallets = [w for w in wallets if w['blockchain'] == 'bsc']
arb_wallets = [w for w in wallets if w['blockchain'] == 'arbitrum']
avax_wallets = [w for w in wallets if w['blockchain'] == 'avalanche']
Expand Down Expand Up @@ -1920,8 +1921,8 @@ async def get_nft_wall_status(user_id: int = Depends(verify_session)):

# Pre-fetch wallets once (needed for polygon and base)
wallets = await get_all_wallets(user_id=user_id)
polygon_wallets = [w for w in wallets if w['blockchain'] == 'polygon']
base_wallets = [w for w in wallets if w['blockchain'] == 'base']
polygon_wallets = evm_fanout_wallets(wallets) # ABCT-EVM-FANOUT-2026-09-28: every EVM-family wallet, not just rows stored as 'polygon'
base_wallets = evm_fanout_wallets(wallets) # ABCT-EVM-FANOUT-2026-09-28: every EVM-family wallet, not just rows stored as 'base'

# Check which chains are configured (all async, run in parallel)
eth_configured, sol_configured, poly_configured, base_configured = await asyncio.gather(
Expand Down Expand Up @@ -2090,7 +2091,7 @@ async def process_chain(chain: str):

elif chain == 'polygon' and await polygon_service.is_configured():
wallets = await get_all_wallets(user_id=user_id)
polygon_wallets = [w for w in wallets if w['blockchain'] == 'polygon']
polygon_wallets = evm_fanout_wallets(wallets) # ABCT-EVM-FANOUT-2026-09-28: every EVM-family wallet, not just rows stored as 'polygon'
nfts = await polygon_service.get_all_polygon_nfts(polygon_wallets, force_refresh=False)
for nft in nfts:
if _get_nft_image_url(nft):
Expand All @@ -2101,7 +2102,7 @@ async def process_chain(chain: str):

elif chain == 'base' and await base_service.is_configured():
wallets = await get_all_wallets(user_id=user_id)
base_wallets = [w for w in wallets if w['blockchain'] == 'base']
base_wallets = evm_fanout_wallets(wallets) # ABCT-EVM-FANOUT-2026-09-28: every EVM-family wallet, not just rows stored as 'base'
nfts = await base_service.get_all_base_nfts(base_wallets, force_refresh=False)
for nft in nfts:
if _get_nft_image_url(nft):
Expand Down Expand Up @@ -2347,8 +2348,8 @@ async def get_nfts_with_images(

# Pre-fetch wallets once (needed for polygon/base)
wallets = await get_all_wallets(user_id=user_id)
polygon_wallets = [w for w in wallets if w['blockchain'] == 'polygon']
base_wallets = [w for w in wallets if w['blockchain'] == 'base']
polygon_wallets = evm_fanout_wallets(wallets) # ABCT-EVM-FANOUT-2026-09-28: every EVM-family wallet, not just rows stored as 'polygon'
base_wallets = evm_fanout_wallets(wallets) # ABCT-EVM-FANOUT-2026-09-28: every EVM-family wallet, not just rows stored as 'base'

# Check configured status for async services
eth_configured = ethereum_nft_service.is_configured() # sync
Expand Down Expand Up @@ -2704,7 +2705,7 @@ async def get_nft_wall_details(

elif blockchain == 'polygon':
wallets = await get_all_wallets(user_id=user_id)
polygon_wallets = [w for w in wallets if w['blockchain'] == 'polygon']
polygon_wallets = evm_fanout_wallets(wallets) # ABCT-EVM-FANOUT-2026-09-28: every EVM-family wallet, not just rows stored as 'polygon'
nfts = await polygon_service.get_all_polygon_nfts(polygon_wallets, force_refresh=False)

parts = asset_id.split('_')
Expand All @@ -2721,7 +2722,7 @@ async def get_nft_wall_details(

elif blockchain == 'base':
wallets = await get_all_wallets(user_id=user_id)
base_wallets = [w for w in wallets if w['blockchain'] == 'base']
base_wallets = evm_fanout_wallets(wallets) # ABCT-EVM-FANOUT-2026-09-28: every EVM-family wallet, not just rows stored as 'base'
nfts = await base_service.get_all_base_nfts(base_wallets, force_refresh=False)

parts = asset_id.split('_')
Expand Down Expand Up @@ -2782,12 +2783,12 @@ async def get_nft_wall_details(
price_key = 'eth'
elif blockchain == 'polygon':
wallets = await get_all_wallets(user_id=user_id)
polygon_wallets = [w for w in wallets if w['blockchain'] == 'polygon']
polygon_wallets = evm_fanout_wallets(wallets) # ABCT-EVM-FANOUT-2026-09-28: every EVM-family wallet, not just rows stored as 'polygon'
all_nfts = await polygon_service.get_all_polygon_nfts(polygon_wallets, force_refresh=False)
price_key = 'matic'
else: # base
wallets = await get_all_wallets(user_id=user_id)
base_wallets = [w for w in wallets if w['blockchain'] == 'base']
base_wallets = evm_fanout_wallets(wallets) # ABCT-EVM-FANOUT-2026-09-28: every EVM-family wallet, not just rows stored as 'base'
all_nfts = await base_service.get_all_base_nfts(base_wallets, force_refresh=False)
price_key = 'eth'

Expand Down
62 changes: 62 additions & 0 deletions backend/services/alchemy_nft_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,71 @@
previously cached NFTs for any wallet whose fetch failed.
"""

import hashlib
from typing import Dict, Iterable, List, Optional, Set


# ABCT-EVM-FANOUT-2026-09-28: one EVM private key/address can hold assets on
# any EVM chain. Wallets are stored one-row-per-(address, blockchain)
# (database.py wallets table, UNIQUE(user_id, address, blockchain)), and
# utils/address.detect_blockchain files any bare 0x address as 'ethereum'
# (chain prefixes like 'polygon:0x...' are the only way a row gets a
# different EVM blockchain value). Before this fix, each EVM NFT/balance
# fetcher only ever queried addresses whose OWN row said that exact chain,
# so a real Polygon/Base holder stored as 'ethereum' was invisible to
# /nfts/polygon and /nfts/base. EVM_ADDRESS_CHAINS is every blockchain value
# that is an EVM address (a "donor" of candidate addresses); a wallet whose
# blockchain is any of these is fanned out to every chain in
# EVM_FANOUT_TARGET_CHAINS instead of only its own stored chain.
EVM_ADDRESS_CHAINS = frozenset({
'ethereum', 'polygon', 'base', 'bsc', 'arbitrum', 'avalanche',
})

# Chains this fan-out actually queries per ABCT-EVM-FANOUT-2026-09-28 (user
# decision, 2026-09-28). BSC/Arbitrum/Avalanche wallets are still valid
# ADDRESS DONORS above (their 0x address is equally an Ethereum/Polygon/Base
# address), but their own dedicated endpoints are out of scope for this fix.
EVM_FANOUT_TARGET_CHAINS = ('ethereum', 'polygon', 'base')


def evm_fanout_wallets(wallets: Iterable[dict]) -> List[dict]:
"""Every EVM-family wallet, deduplicated by address (case-insensitive).

Used to build the candidate address list for an EVM NFT/balance fetcher
that should query every EVM address the user has ever registered, not
just the ones whose own DB row happens to already say that chain. First
occurrence wins on a duplicate address (stable, deterministic ordering
from the input list).
"""
seen: Set[str] = set()
result: List[dict] = []
for w in wallets:
addr = w.get('address') if isinstance(w, dict) else None
if not addr:
continue
if w.get('blockchain') not in EVM_ADDRESS_CHAINS:
continue
key = addr.lower()
if key in seen:
continue
seen.add(key)
result.append(w)
return result


def wallet_fingerprint(addresses: Iterable[str]) -> str:
"""Stable, non-reversible fingerprint of a set of addresses (case-insensitive).

Stored next to a cached fetch result so an EVM wallet added or removed
after the last fetch is noticed instead of hiding behind a long TTL.
Mirrors services/solana_nft.py's wallet_fingerprint (kept as an
independent copy here rather than imported, so the EVM caching path
does not depend on the Solana module). ABCT-EVM-FANOUT-2026-09-28.
"""
joined = "\n".join(sorted({a.lower() for a in addresses if a}))
return hashlib.sha256(joined.encode("utf-8")).hexdigest()


def build_owner_params(address: str, page_key: Optional[str] = None, page_size: int = 100) -> Dict[str, str]:
"""Query parameters for getNFTsForOwner that work on every Alchemy plan.

Expand Down
Loading