Skip to content

Commit 75c1c6d

Browse files
authored
Merge pull request #227 from crypkit/2149-blockapi-btc-nft-api-replacement-after-simplehash-shutdown
feat: implement Unisat API for BTC NFTs; fetch NFTs, collections, offers, listings
2 parents b884de2 + 176786d commit 75c1c6d

4 files changed

Lines changed: 80 additions & 72 deletions

File tree

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
{
2+
"code": 0,
3+
"msg": "ok",
4+
"data": {
5+
"collectionId": "pixel-pepes",
6+
"name": "Pixel Pepes",
7+
"desc": "The first ever airdrop on the Bitcoin network. 1563 rare Pixel Pepes airdropped to users who had made a transaction on ordinalswallet.com before block 777888.",
8+
"icon": "47c1d21c508f6d49dfde64d958f14acd041244e1bb616f9b78114b8d9dc7b945i0",
9+
"iconContentType": "image/png",
10+
"btcValue": 39900000,
11+
"btcValuePercent": 1,
12+
"floorPrice": 990000,
13+
"listed": 20,
14+
"total": 1563,
15+
"supply": null,
16+
"attrs": [],
17+
"twitter": "https://twitter.com/PepesPixel",
18+
"discord": "https://discord.gg/ordinalswallet",
19+
"website": "",
20+
"pricePercent": 1,
21+
"verification": false
22+
}
23+
}

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

Lines changed: 19 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -113,23 +113,14 @@ def test_parse_nfts_edge_cases(
113113
assert nft.asset_type == AssetType.AVAILABLE
114114

115115

116-
def test_fetch_collection(
117-
requests_mock, unisat_client, collection_info, collection_items, collection_stats
118-
):
119-
requests_mock.get(
120-
f"{unisat_client.api_options.base_url}v1/collection-indexer/collection/{test_collection_id}/info",
121-
text=collection_info,
122-
)
123-
requests_mock.get(
124-
f"{unisat_client.api_options.base_url}v1/collection-indexer/collection/{test_collection_id}/items",
125-
text=collection_items,
126-
)
116+
def test_fetch_collection(requests_mock, unisat_client, collection_stats_v4):
127117
requests_mock.post(
128-
f"{unisat_client.api_options.base_url}v3/market/collection/auction/collection_statistic",
129-
text=collection_stats,
118+
f"{unisat_client.api_options.base_url}market-v4/collection/auction/collection_statistic",
119+
text=collection_stats_v4,
130120
)
131121

132-
fetch_result = unisat_client.fetch_collection(test_collection_id)
122+
test_collection = "pixel-pepes"
123+
fetch_result = unisat_client.fetch_collection(test_collection)
133124
assert not fetch_result.errors, f"Fetch errors: {fetch_result.errors}"
134125

135126
parse_result = unisat_client.parse_collection(fetch_result)
@@ -138,16 +129,20 @@ def test_fetch_collection(
138129

139130
collection = parse_result.data[0]
140131
assert isinstance(collection, NftCollection)
141-
assert collection.ident == test_collection_id
142-
assert collection.name == "Ordinal Punks"
132+
assert collection.ident == "pixel-pepes"
133+
assert collection.name == "Pixel Pepes"
143134
assert (
144135
collection.image
145-
== "https://ordinals.com/content/6fb976ab49dcec017f1e2015b625126c5c4d6b71174f5bc5af4f39b274a4b6b5i0"
136+
== "https://static.unisat.io/content/47c1d21c508f6d49dfde64d958f14acd041244e1bb616f9b78114b8d9dc7b945i0"
146137
)
147138
assert not collection.is_disabled
148139
assert not collection.is_nsfw
149-
assert str(collection.total_stats.owners_count) == "150"
150-
assert str(collection.total_stats.floor_price) == "0.1"
140+
assert collection.blockchain == Blockchain.BITCOIN
141+
assert str(collection.total_stats.floor_price) == "990000"
142+
assert str(collection.total_stats.owners_count) == "1563"
143+
assert str(collection.total_stats.sales_count) == "20"
144+
assert str(collection.total_stats.volume) == "39900000"
145+
assert str(collection.total_stats.market_cap) == str(990000 * 1563)
151146

152147

153148
def test_fetch_listings(requests_mock, unisat_client, listings_data):
@@ -252,21 +247,6 @@ def inscription_data():
252247
return read_file('data/unisat/inscription_data.json')
253248

254249

255-
@pytest.fixture
256-
def collection_info():
257-
return read_file('data/unisat/collection_info.json')
258-
259-
260-
@pytest.fixture
261-
def collection_items():
262-
return read_file('data/unisat/collection_items.json')
263-
264-
265-
@pytest.fixture
266-
def collection_stats():
267-
return read_file('data/unisat/collection_stats.json')
268-
269-
270250
@pytest.fixture
271251
def inscription_data_edge_cases():
272252
return read_file('data/unisat/inscription_data_edge_cases.json')
@@ -285,3 +265,8 @@ def listings_data():
285265
@pytest.fixture
286266
def offers_data():
287267
return read_file('data/unisat/offers.json')
268+
269+
270+
@pytest.fixture
271+
def collection_stats_v4():
272+
return read_file('data/unisat/collection_stats_v4.json')

blockapi/v2/api/nft/unisat.py

Lines changed: 37 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -49,11 +49,9 @@ class UnisatApi(BlockchainApi, INftParser, INftProvider):
4949

5050
supported_requests = {
5151
'get_nfts': 'v1/indexer/address/{address}/inscription-data',
52-
'get_collection': 'v1/collection-indexer/collection/{collectionId}/info',
53-
'get_collection_items': 'v1/collection-indexer/collection/{collectionId}/items',
54-
'get_collection_stats': 'v3/market/collection/auction/collection_statistic',
5552
'get_listings': 'v3/market/collection/auction/list',
5653
'get_offers': 'v3/market/collection/auction/actions',
54+
'get_collection_stats': 'market-v4/collection/auction/collection_statistic',
5755
}
5856

5957
def __init__(self, api_key: str, sleep_provider: Optional[ISleepProvider] = None):
@@ -192,27 +190,19 @@ def _yield_parsed_nfts(self, data: Dict) -> Generator[NftToken, None, None]:
192190

193191
def fetch_collection(self, collection: str) -> FetchResult:
194192
"""Fetch collection data from Unisat API."""
195-
info_response = self.get_data(
196-
'get_collection',
197-
headers=self.headers,
198-
collectionId=collection,
199-
)
200-
items_response = self.get_data(
201-
'get_collection_items',
202-
headers=self.headers,
203-
collectionId=collection,
204-
)
205-
206-
stats_data = self.post(
207-
'get_collection_stats',
208-
json={'collectionId': collection},
209-
headers=self.headers,
210-
)
211-
stats_response = FetchResult(data=stats_data)
212-
213-
return FetchResult.from_fetch_results(
214-
info=info_response, items=items_response, stats=stats_response
215-
)
193+
try:
194+
stats_data = self.post(
195+
'get_collection_stats',
196+
json={'collectionId': collection},
197+
headers=self.headers,
198+
)
199+
return FetchResult(data=stats_data)
200+
except (HTTPError, ValueError, TypeError) as e:
201+
logger.error(f"Error fetching collection {collection}: {str(e)}")
202+
return FetchResult(errors=[str(e)])
203+
except Exception as e:
204+
logger.error(f"Unexpected error fetching collection {collection}: {str(e)}")
205+
return FetchResult(errors=[str(e)])
216206

217207
def parse_collection(self, fetch_result: FetchResult) -> ParseResult:
218208
"""
@@ -227,33 +217,45 @@ def parse_collection(self, fetch_result: FetchResult) -> ParseResult:
227217
if not fetch_result or not fetch_result.data:
228218
return ParseResult(errors=fetch_result.errors if fetch_result else None)
229219

230-
info = fetch_result.data.get("info", {}).get("data", {})
231-
items = fetch_result.data.get("items", {}).get("data", {})
232-
stats = fetch_result.data.get("stats", {}).get("data", {})
220+
stats = fetch_result.data.get("data", {})
221+
if not stats:
222+
return ParseResult(errors=["No collection data found in response"])
233223

234224
collection_id = stats.get("collectionId")
235225
if not collection_id:
236226
return ParseResult(errors=["No collection ID found in response"])
237227

228+
# Format the icon URL
229+
icon = stats.get("icon")
230+
icon_url = None
231+
if icon:
232+
icon_url = f"https://static.unisat.io/content/{icon}"
233+
234+
# Create NftCollectionTotalStats
235+
floor_price = stats.get("floorPrice", 0)
236+
total_nfts = stats.get("total", 0)
237+
# Calculate market cap as floor price × total supply
238+
market_cap = floor_price * total_nfts if floor_price and total_nfts else 0
239+
238240
total_stats = NftCollectionTotalStats.from_api(
239-
volume="",
240-
sales_count="",
241-
owners_count=str(info.get("holders", "")),
242-
market_cap="",
243-
floor_price=str(stats.get("floorPrice", "")),
244-
average_price="",
241+
volume=str(stats.get("btcValue", 0)),
242+
sales_count=str(stats.get("listed", 0)),
243+
owners_count=str(total_nfts),
244+
market_cap=str(market_cap),
245+
floor_price=str(floor_price),
246+
average_price="0",
245247
coin=self.coin,
246248
)
247249

248250
collection = NftCollection.from_api(
249-
ident=collection_id, # Matches test_collection_id
251+
ident=collection_id,
250252
name=stats.get("name", f"Collection {collection_id}"),
251253
contracts=[
252254
ContractInfo.from_api(
253255
blockchain=Blockchain.BITCOIN, address=collection_id
254256
)
255257
],
256-
image=stats.get("icon"),
258+
image=icon_url,
257259
is_disabled=False,
258260
is_nsfw=False,
259261
blockchain=Blockchain.BITCOIN,
@@ -574,8 +576,6 @@ def _yield_parsed_offers(
574576
formatted_time = None
575577
if timestamp:
576578
try:
577-
from datetime import datetime
578-
579579
timestamp_seconds = timestamp / 1000
580580
formatted_time = datetime.fromtimestamp(
581581
timestamp_seconds

blockapi/v2/models.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -839,7 +839,7 @@ def from_api(
839839
contract=contract,
840840
blockchain=blockchain,
841841
offerer=offerer,
842-
start_time=parse_dt(start_time) if start_time else None,
842+
start_time=parse_dt(start_time) if start_time else datetime.utcnow(),
843843
end_time=parse_dt(end_time) if end_time else None,
844844
offer_coin=offer_coin,
845845
offer_contract=offer_contract.lower() if offer_contract else None,

0 commit comments

Comments
 (0)