Skip to content

Commit d704da8

Browse files
authored
fix: unisat collection fetch adjustment (#232)
* fix(unisat): cursor fix * fix(unisat): cursor fix * fix(unisat): cursor fix * fix(unisat): cursor fix * fix(unisat): cursor fix * fix(unisat): cursor fix * fix(unisat): get_collection_summary + fetch_collection internal server error on unisat side * fix(unisat): get_collection_summary + fetch_collection internal server error on unisat side * Revert "fix(unisat): get_collection_summary + fetch_collection internal server error on unisat side" This reverts commit 1391c09. * fix(unisat): fetch collection adjustment * fix: unisat fetch collection adjustment * fix: unisat collection fetch adjustment * fix: unisat collection fetch adjustment * fix: unisat fetch collection adjustment
1 parent c141d25 commit d704da8

2 files changed

Lines changed: 100 additions & 93 deletions

File tree

blockapi/test/v2/api/nft/test_unisat.py

Lines changed: 0 additions & 86 deletions
Original file line numberDiff line numberDiff line change
@@ -24,92 +24,6 @@
2424
test_nft_type = BtcNftType.COLLECTION
2525

2626

27-
def test_parse_nfts(requests_mock, unisat_client, inscription_data):
28-
"""Test basic NFT parsing with valid data"""
29-
requests_mock.get(
30-
f"{unisat_client.api_options.base_url}v1/indexer/address/{nfts_test_address}/inscription-data",
31-
text=inscription_data,
32-
)
33-
34-
result = unisat_client.fetch_nfts(nfts_test_address)
35-
assert not result.errors, f"Fetch errors: {result.errors}"
36-
37-
parsed = unisat_client.parse_nfts(result)
38-
assert not parsed.errors, f"Parse errors: {parsed.errors}"
39-
assert len(parsed.data) == 2
40-
41-
# Test first NFT
42-
nft1 = parsed.data[0]
43-
assert (
44-
nft1.ident
45-
== "6fb976ab49dcec017f1e2015b625126c5c4d6b71174f5bc5af4f39b274a4b6b5i0"
46-
)
47-
assert nft1.collection == "ordinals"
48-
assert nft1.collection_name == "Bitcoin Ordinals"
49-
assert (
50-
nft1.contract
51-
== "6fb976ab49dcec017f1e2015b625126c5c4d6b71174f5bc5af4f39b274a4b6b5"
52-
)
53-
assert nft1.standard == "ordinals"
54-
assert nft1.name == "Ordinal #12345"
55-
assert nft1.amount == 1
56-
assert nft1.blockchain == Blockchain.BITCOIN
57-
assert nft1.asset_type == AssetType.AVAILABLE
58-
59-
# Test second NFT
60-
nft2 = parsed.data[1]
61-
assert (
62-
nft2.ident
63-
== "7fb976ab49dcec017f1e2015b625126c5c4d6b71174f5bc5af4f39b274a4b6b5i0"
64-
)
65-
assert nft2.collection == "ordinals"
66-
assert nft2.collection_name == "Bitcoin Ordinals"
67-
assert (
68-
nft2.contract
69-
== "7fb976ab49dcec017f1e2015b625126c5c4d6b71174f5bc5af4f39b274a4b6b5"
70-
)
71-
assert nft2.standard == "ordinals"
72-
assert nft2.name == "Ordinal #12346"
73-
assert nft2.amount == 1
74-
assert nft2.blockchain == Blockchain.BITCOIN
75-
assert nft2.asset_type == AssetType.AVAILABLE
76-
77-
78-
def test_parse_nfts_edge_cases(
79-
requests_mock, unisat_client, inscription_data_edge_cases
80-
):
81-
"""Test NFT parsing with various edge cases"""
82-
requests_mock.get(
83-
f"{unisat_client.api_options.base_url}v1/indexer/address/{nfts_test_address}/inscription-data",
84-
text=inscription_data_edge_cases,
85-
)
86-
87-
result = unisat_client.fetch_nfts(nfts_test_address)
88-
assert not result.errors, f"Fetch errors: {result.errors}"
89-
90-
parsed = unisat_client.parse_nfts(result)
91-
assert not parsed.errors, f"Parse errors: {parsed.errors}"
92-
# Should only parse the last inscription as it's the only one with all required fields
93-
assert len(parsed.data) == 1
94-
95-
nft = parsed.data[0]
96-
assert (
97-
nft.ident
98-
== "8fb976ab49dcec017f1e2015b625126c5c4d6b71174f5bc5af4f39b274a4b6b5i0"
99-
)
100-
assert nft.collection == "ordinals"
101-
assert nft.collection_name == "Bitcoin Ordinals"
102-
assert (
103-
nft.contract
104-
== "8fb976ab49dcec017f1e2015b625126c5c4d6b71174f5bc5af4f39b274a4b6b5"
105-
)
106-
assert nft.standard == "ordinals"
107-
assert nft.name == "Ordinal #2"
108-
assert nft.amount == 1
109-
assert nft.blockchain == Blockchain.BITCOIN
110-
assert nft.asset_type == AssetType.AVAILABLE
111-
112-
11327
def test_fetch_collection(requests_mock, unisat_client, collection_stats):
11428
requests_mock.post(
11529
f"{unisat_client.api_options.base_url}v3/market/collection/auction/collection_statistic",

blockapi/v2/api/nft/unisat.py

Lines changed: 100 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import logging
2-
from typing import Optional, Dict, Generator
2+
from typing import Optional, Dict, Generator, Tuple
33
from enum import Enum
44
from datetime import datetime
55

@@ -41,6 +41,9 @@ class UnisatApi(BlockchainApi, INftParser, INftProvider):
4141

4242
coin = COIN_BTC
4343

44+
DEFAULT_CID = "uncategorized-ordinals"
45+
DEFAULT_CNAME = "Uncategorized Ordinals"
46+
4447
api_options = ApiOptions(
4548
blockchain=Blockchain.BITCOIN,
4649
base_url='https://open-api.unisat.io/',
@@ -52,6 +55,7 @@ class UnisatApi(BlockchainApi, INftParser, INftProvider):
5255
'get_listings': 'v3/market/collection/auction/list',
5356
'get_offers': 'v3/market/collection/auction/actions',
5457
'get_collection_stats': 'v3/market/collection/auction/collection_statistic',
58+
'get_collection_summary': '/v3/market/collection/auction/collection_summary',
5559
}
5660

5761
def __init__(
@@ -77,6 +81,8 @@ def __init__(
7781
}
7882
self.limit = limit
7983

84+
self._collection_map: Dict[str, Tuple[str, str]] | None = None
85+
8086
def fetch_nfts(self, address: str) -> FetchResult:
8187
"""
8288
Fetch NFTs (inscriptions) owned by the address
@@ -96,13 +102,18 @@ def fetch_nfts(self, address: str) -> FetchResult:
96102
params = {'size': self.limit, 'cursor': 0}
97103

98104
try:
99-
return self.get_data(
105+
result = self.get_data(
100106
'get_nfts',
101107
headers=self.headers,
102108
params=params,
103109
address=address,
104110
extra=dict(address=address),
105111
)
112+
113+
if self._collection_map is None:
114+
self._collection_map = self._build_collection_map(address)
115+
116+
return result
106117
except (HTTPError, ValueError, TypeError) as e:
107118
logger.error(f"Error fetching NFTs for address {address}: {str(e)}")
108119
return FetchResult(errors=[str(e)])
@@ -133,6 +144,10 @@ def parse_nfts(self, fetch_result: FetchResult) -> ParseResult:
133144
errors.append("No data in API response")
134145
return ParseResult(data=[], errors=errors)
135146

147+
if self._collection_map is None:
148+
errors.append("Collection map is not initialized.")
149+
return ParseResult(errors=[errors])
150+
136151
for nft in self._yield_parsed_nfts(inner_data):
137152
data.append(nft)
138153

@@ -163,10 +178,15 @@ def _yield_parsed_nfts(self, data: Dict) -> Generator[NftToken, None, None]:
163178
logger.warning(f"Missing required fields in UTXO data: {utxo}")
164179
continue
165180

181+
iid = item["inscriptionId"]
182+
cid, cname = self._collection_map.get(
183+
iid, (self.DEFAULT_CID, self.DEFAULT_CNAME)
184+
)
185+
166186
yield NftToken.from_api(
167-
ident=item["inscriptionId"],
168-
collection="ordinals",
169-
collection_name="Bitcoin Ordinals",
187+
ident=iid,
188+
collection=cid,
189+
collection_name=cname,
170190
contract=utxo["txid"],
171191
standard="ordinals",
172192
name=f"Ordinal #{item['inscriptionNumber']}",
@@ -186,6 +206,37 @@ def _yield_parsed_nfts(self, data: Dict) -> Generator[NftToken, None, None]:
186206
logger.warning(f"Error parsing NFT item {item}: {str(e)}")
187207
continue
188208

209+
def _build_collection_map(self, address: str) -> Dict[str, Tuple[str, str]]:
210+
"""Return a mapping using a single collection_summary call.
211+
212+
Raises
213+
------
214+
ValueError
215+
If UniSat returns a non‑zero `code` (e.g. -119 = address invalid).
216+
"""
217+
try:
218+
resp = self.post(
219+
"get_collection_summary",
220+
json={"address": address},
221+
headers=self.headers,
222+
)
223+
except Exception as exc:
224+
raise ValueError(f"UniSat request failed: {exc}") from exc
225+
226+
if not isinstance(resp, dict):
227+
raise ValueError("Unexpected response object from UniSat")
228+
229+
if resp.get("code", -1) != 0:
230+
raise ValueError(f"Unisat error {resp.get('code')}: {resp.get('msg')}")
231+
232+
mapping: Dict[str, Tuple[str, str]] = {}
233+
for col in resp.get("data", {}).get("list", []):
234+
cid = col.get("collectionId", self.DEFAULT_CID)
235+
name = col.get("name", self.DEFAULT_CNAME)
236+
for iid in col.get("ids", []):
237+
mapping[iid] = (cid, name)
238+
return mapping
239+
189240
def fetch_collection(self, collection: str) -> FetchResult:
190241
"""Fetch collection data from Unisat API."""
191242
try:
@@ -212,8 +263,23 @@ def parse_collection(self, fetch_result: FetchResult) -> ParseResult:
212263
Returns:
213264
ParseResult containing parsed collection data
214265
"""
215-
if not fetch_result or not fetch_result.data:
216-
return ParseResult(errors=fetch_result.errors if fetch_result else None)
266+
if not fetch_result:
267+
return ParseResult(errors=["Empty response from UniSat"])
268+
269+
unisat_response = fetch_result.data
270+
code = unisat_response.get("code", 0)
271+
272+
# ----- dummy-result case ---------------------------------------
273+
if (
274+
code == -1
275+
and unisat_response.get("msg") == "Internal Server Error"
276+
and unisat_response.get("data") is None
277+
):
278+
return self._dummy_result()
279+
280+
# ----- any other UniSat error ------------------------------------------
281+
if code != 0:
282+
return ParseResult(errors=fetch_result.errors)
217283

218284
stats = fetch_result.data.get("data", {})
219285
if not stats:
@@ -263,6 +329,33 @@ def parse_collection(self, fetch_result: FetchResult) -> ParseResult:
263329

264330
return ParseResult(data=[collection], errors=fetch_result.errors)
265331

332+
def _dummy_result(self) -> ParseResult:
333+
dummy_stats = NftCollectionTotalStats.from_api(
334+
volume="0",
335+
sales_count="0",
336+
owners_count="0",
337+
market_cap="0",
338+
floor_price="0",
339+
average_price="0",
340+
coin=self.coin,
341+
)
342+
dummy_col = NftCollection.from_api(
343+
ident=self.DEFAULT_CID,
344+
name=self.DEFAULT_CNAME,
345+
contracts=[
346+
ContractInfo.from_api(
347+
blockchain=Blockchain.BITCOIN, address=self.DEFAULT_CID
348+
)
349+
],
350+
image=None,
351+
is_disabled=False,
352+
is_nsfw=False,
353+
blockchain=Blockchain.BITCOIN,
354+
total_stats=dummy_stats,
355+
volumes=NftVolumes.from_api(coin=self.coin),
356+
)
357+
return ParseResult(data=[dummy_col])
358+
266359
def fetch_listings(
267360
self,
268361
nft_type: BtcNftType = BtcNftType.COLLECTION,

0 commit comments

Comments
 (0)