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
85 changes: 85 additions & 0 deletions blockapi/test/v2/api/data/simplehash/fungibles.json
Original file line number Diff line number Diff line change
@@ -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"
}
]
}
]
}
]
}
38 changes: 37 additions & 1 deletion blockapi/test/v2/api/nft/test_simple_hash.py
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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
):
Expand Down Expand Up @@ -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')
135 changes: 116 additions & 19 deletions blockapi/v2/api/nft/simple_hash.py
Original file line number Diff line number Diff line change
Expand Up @@ -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}',
}

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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(
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion blockapi/v2/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Loading