diff --git a/blockapi/test/v2/api/data/simplehash/fungibles.json b/blockapi/test/v2/api/data/simplehash/fungibles.json new file mode 100644 index 00000000..4e7be08f --- /dev/null +++ b/blockapi/test/v2/api/data/simplehash/fungibles.json @@ -0,0 +1,85 @@ +{ + "next_cursor": null, + "fungibles": [ + { + "fungible_id": "bitcoin.840697:454", + "name": "ART•IS•THE•TICKER", + "symbol": "🎨", + "decimals": 0, + "chain": "bitcoin", + "prices": [ + { + "marketplace_id": "magiceden", + "marketplace_name": "Magic Eden", + "value_usd_cents": 1, + "value_usd_string": "0.01330", + "value_usd_string_high_precision": "0.01330088630", + "liquidity_usd_string": null, + "bonding_curve_progress_percent": null, + "dex_contract": null, + "factory_contract": null + } + ], + "fungible_details": { + "fungible_id": "bitcoin.840697:454", + "name": "ART•IS•THE•TICKER", + "symbol": "🎨", + "decimals": 0, + "chain": "bitcoin", + "previews": { + "image_small_url": null, + "image_medium_url": null, + "image_large_url": null, + "image_opengraph_url": null, + "blurhash": null, + "predominant_color": null + }, + "image_url": null, + "image_properties": null, + "created_date": "2024-04-24T19:05:31Z", + "created_by": "bc1p6s2wf9f26nqkqjf3n4vd8j4hkgj6rsqsc2hwqzuhrkqm4n0t2fvqtfv7fx", + "supply": "21000000", + "holder_count": 4370, + "extra_metadata": { + "terms": { + "amount": 1000, + "cap": 19320, + "height": { + "start": null, + "end": null + }, + "offset": { + "start": null, + "end": null + } + }, + "burned": 0, + "premine": 1680000, + "mints": 19320 + } + }, + "total_quantity": 250, + "total_quantity_string": "250", + "total_value_usd_cents": 333, + "total_value_usd_string": "3.33", + "queried_wallet_balances": [ + { + "address": "bc1pve69ahm9k62yqpxdzfhgsnj3f5zyy5v80fzk5epfwcuhgy9fmv6qgt3e6p", + "quantity": 250, + "quantity_string": "250", + "value_usd_cents": 333, + "value_usd_string": "3.33", + "first_transferred_date": "2025-01-17T19:33:40Z", + "last_transferred_date": "2025-01-17T19:33:40Z", + "subaccounts": [ + { + "account": "355098474785b7edf28a9de55237a20c725b4290efee9eded1e5ec666d6c6225:406", + "quantity": 250, + "quantity_string": "250" + } + ] + } + ] + } + ] +} \ No newline at end of file diff --git a/blockapi/test/v2/api/nft/test_simple_hash.py b/blockapi/test/v2/api/nft/test_simple_hash.py index 9faa1e9d..dd02757e 100644 --- a/blockapi/test/v2/api/nft/test_simple_hash.py +++ b/blockapi/test/v2/api/nft/test_simple_hash.py @@ -21,12 +21,17 @@ def test_parse_nfts(requests_mock, api, nfts_response): text=nfts_response, ) + requests_mock.get( + f'https://api.simplehash.com/api/v0/fungibles/balances?chains=bitcoin&wallet_addresses={nfts_test_address}&include_fungible_details=1', + text="[]", + ) + nfts = api.fetch_nfts(nfts_test_address) parsed = api.parse_nfts(nfts) assert not nfts.errors assert parsed.cursor == ( - 'YnRjLW0uODc5MGYyYmMyZmU0YmQ5ZWNmZGIxMzVmNmEwYzFjZmZkYzRiY2RhZGMzN' + 'token:YnRjLW0uODc5MGYyYmMyZmU0YmQ5ZWNmZGIxMzVmNmEwYzFjZmZkYzRiY2RhZGMzN' 'jE2MjYxZjcwNDQwNGViMmY2NzVlNmkwLjAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMD' 'AwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDA' 'wMDAwMF8yMDIzLTA1LTI2IDE3OjE0OjQ5KzAwOjAwX19uZXh0' @@ -91,6 +96,32 @@ def test_parse_listed_nfts( assert parsed.data[1].ident == 'solana.7bwsNfaSWurdCTpWv5idrt4FgeJYbPCffGUY7FsUQzSV' +def test_parse_fungible_nfts(requests_mock, api, fungibles_response): + requests_mock.get( + f'https://api.simplehash.com/api/v0/fungibles/balances?chains=bitcoin&wallet_addresses={nfts_test_address}&include_fungible_details=1', + text=fungibles_response, + ) + + requests_mock.get( + f'https://api.simplehash.com/api/v0/nfts/owners?chains=bitcoin&wallet_addresses={nfts_test_address}', + text="[]", + ) + + nfts = api.fetch_nfts(nfts_test_address) + parsed = api.parse_nfts(nfts) + + assert not nfts.errors + assert not parsed.cursor + + assert len(parsed.data) == 1 + item = parsed.data[0] + assert item.ident == 'bitcoin.840697.454' + assert item.name == 'ART•IS•THE•TICKER' + assert item.collection == 'runes' + assert item.collection_name == 'Runes' + assert item.amount == 250 + + def test_parse_collection( requests_mock, api, collection_response, collection_activity_response ): @@ -297,6 +328,11 @@ def solana_nfts_response(): return read_file('data/simplehash/solana-nfts.json') +@pytest.fixture +def fungibles_response(): + return read_file('data/simplehash/fungibles.json') + + @pytest.fixture def solana_listings_nfts_response(): return read_file('data/simplehash/solana-listings-nfts.json') diff --git a/blockapi/v2/api/nft/simple_hash.py b/blockapi/v2/api/nft/simple_hash.py index 97841f35..bd17cfe4 100644 --- a/blockapi/v2/api/nft/simple_hash.py +++ b/blockapi/v2/api/nft/simple_hash.py @@ -38,18 +38,20 @@ class SimpleHashApi(BlockchainApi, INftProvider, INftParser): api_options = ApiOptions( blockchain=NotImplemented, - base_url='https://api.simplehash.com/api/v0/nfts/', + base_url='https://api.simplehash.com/api/v0/', rate_limit=0.01, # 100 per second for free account ) supported_requests = { - 'get_nfts': 'owners?chains={chain}&wallet_addresses={address}', - 'get_collection': 'collections/ids?collection_ids={slug}', - 'get_collection_activity': 'collections_activity?collection_ids={slug}', - 'get_bids': 'bids/collection/{slug}', - 'get_listings': 'listings/collection/{slug}', - 'get_wallet_bids': 'bids/wallets?chains={chain}&wallet_addresses={address}', - 'get_wallet_listings': 'listings/wallets?chains={chain}&wallet_addresses={address}' + 'get_nfts': 'nfts/owners?chains={chain}&wallet_addresses={address}', + 'get_fungibles': 'fungibles/balances?chains={chain}&wallet_addresses={address}' + '&include_fungible_details=1&include_native_tokens=0', + 'get_collection': 'nfts/collections/ids?collection_ids={slug}', + 'get_collection_activity': 'nfts/collections_activity?collection_ids={slug}', + 'get_bids': 'nfts/bids/collection/{slug}', + 'get_listings': 'nfts/listings/collection/{slug}', + 'get_wallet_bids': 'nfts/bids/wallets?chains={chain}&wallet_addresses={address}', + 'get_wallet_listings': 'nfts/listings/wallets?chains={chain}&wallet_addresses={address}' '&include_nft_details={include_nft_details}', } @@ -99,6 +101,9 @@ def _yield_parsed_nfts(self, data, address): yield from self._yield_parsed_nfts_from_tokens( self._yield_tokens(data.get('listings')), address ) + yield from self._yield_parsed_nfts_from_fungibles( + data.get('fungibles'), address + ) yield from self._yield_parsed_nfts_from_tokens(data.get('nfts'), address) @staticmethod @@ -145,6 +150,54 @@ def _yield_parsed_nfts_from_tokens(self, items, address): market_url=self._get_market_url(collection), ) + def _yield_parsed_nfts_from_fungibles(self, items, address): + if not items: + return + + for item in items: + fungible = item.get('fungible_id').split(':') + collection_id = 'runes' + ident = item.get('fungible_id') + if ident: + ident = ident.replace(':', '.') + + if not ident: + continue + + standard = 'rune' + blockchain = self._get_blockchain(item) + details = item.get('fungible_details') + + yield NftToken.from_api( + ident=ident, + collection=collection_id, + collection_name='Runes', + contract=collection_id, + standard=standard, + name=details.get('name'), + description=None, + amount=item.get('total_quantity'), + image_url=item.get('image_url'), + metadata_url=None, + updated_time=item.get('created_date'), + is_disabled=False, + is_nsfw=False, + blockchain=blockchain, + asset_type=AssetType.AVAILABLE, + market_url=None, + ) + + @staticmethod + def _update_cursor(data, prefix): + if not data.data: + return False + + if crs := data.data.get('next_cursor'): + data.data['next_cursor'] = f'{prefix}:{crs}' + return True + + return False + @staticmethod def _get_market_url(collection): opensea = None @@ -444,6 +497,45 @@ class SimpleHashBitcoinApi(SimpleHashApi): def __init__(self, api_key: str, sleep_provider: ISleepProvider): super().__init__(Blockchain.BITCOIN, api_key, sleep_provider) + def fetch_nfts(self, address: str, cursor: Optional[str] = None) -> FetchResult: + token_cursor = None + fungibles_cursor = None + if cursor: + target, crs = cursor.split(':') + if target == 'token': + token_cursor = crs + elif target == 'fungibles': + fungibles_cursor = crs + + print(f'token = {token_cursor}, fungibles_cursor = {fungibles_cursor}') + + fungibles = None + if not token_cursor: + fungibles = self.get_data( + 'get_fungibles', + headers=self.headers, + params=dict(cursor=fungibles_cursor) if fungibles_cursor else None, + chain=self.simplehash_blockchains, + include_nft_details=1, + address=address, + extra=dict(address=address), + ) + + if self._update_cursor(fungibles, 'fungibles'): + return fungibles + + data = self.get_data( + 'get_nfts', + headers=self.headers, + params=dict(cursor=token_cursor) if token_cursor else None, + chain=self.simplehash_blockchains, + address=address, + extra=dict(address=address), + ) + + self._update_cursor(data, 'token') + return self._coallesce(fungibles, data) + def fetch_collection(self, collection: str) -> FetchResult: if collection == self.default_collection: return FetchResult.from_dict( @@ -461,6 +553,22 @@ def fetch_collection(self, collection: str) -> FetchResult: return super().fetch_collection(collection) + def _coallesce(self, fungibles, data): + if data and data.errors: + return data + + if fungibles and fungibles.errors: + return fungibles + + if not data or not data.data: + return fungibles + + if not fungibles or not fungibles.data: + return data + + data.data['fungibles'] = fungibles.data.get('fungibles', []) + return data + class SimpleHashSolanaApi(SimpleHashApi): coin = COIN_SOL @@ -509,17 +617,6 @@ def fetch_nfts(self, address: str, cursor: Optional[str] = None) -> FetchResult: self._update_cursor(data, 'token') return self._coallesce(listings, data) - @staticmethod - def _update_cursor(data, prefix): - if not data.data: - return False - - if crs := data.data.get('next_cursor'): - data.data['next_cursor'] = f'{prefix}:{crs}' - return True - - return False - def _coallesce(self, listings, data): if data and data.errors: return data diff --git a/blockapi/v2/models.py b/blockapi/v2/models.py index 44b4f3bd..a65b51fb 100644 --- a/blockapi/v2/models.py +++ b/blockapi/v2/models.py @@ -694,7 +694,7 @@ def from_api( ident: str, collection: str, contract: str, - standard: Literal['erc721', 'erc1155', 'ordinals'], + standard: Literal['erc721', 'erc1155', 'ordinals', 'rune'], name: str, description: Optional[str], amount: int,