diff --git a/discovery-provider/src/queries/get_premium_track_signatures.py b/discovery-provider/src/queries/get_premium_track_signatures.py index 39ba5dbd896..76338992050 100644 --- a/discovery-provider/src/queries/get_premium_track_signatures.py +++ b/discovery-provider/src/queries/get_premium_track_signatures.py @@ -1,11 +1,16 @@ +import asyncio +import base64 import concurrent.futures import json import logging import pathlib +import struct from collections import defaultdict from typing import Dict, List, Set +import base58 from eth_typing import ChecksumAddress +from solana.publickey import PublicKey from sqlalchemy.orm.session import Session from src.models.tracks.track import Track from src.models.users.user import User @@ -14,7 +19,10 @@ ) from src.premium_content.signature import get_premium_content_signature_for_user from src.queries.get_associated_user_wallet import get_associated_user_wallet +from src.solana.solana_client_manager import SolanaClientManager +from src.solana.solana_helpers import METADATA_PROGRAM_ID_PK from src.utils import db_session, web3_provider +from src.utils.config import shared_config from web3 import Web3 logger = logging.getLogger(__name__) @@ -22,6 +30,8 @@ erc721_abi = None erc1155_abi = None +solana_client_manager = None + eth_web3 = web3_provider.get_eth_web3() @@ -91,6 +101,7 @@ def _get_nft_gated_tracks(track_ids: List[int], session: Session): return list( filter( lambda track: track.is_premium # type: ignore + and track.premium_conditions != None # type: ignore and "nft_collection" in track.premium_conditions, # type: ignore _get_tracks(track_ids, session), ) @@ -104,6 +115,7 @@ def _get_eth_nft_gated_track_signatures( track_token_id_map: Dict[int, List[str]], ): track_signature_map = {} + track_cid_to_id_map = {} user_eth_wallets = list( map(Web3.toChecksumAddress, eth_associated_wallets + [user_wallet]) @@ -126,6 +138,7 @@ def _get_eth_nft_gated_track_signatures( track.premium_conditions["nft_collection"]["address"] # type: ignore ) erc721_collection_track_map[contract_address].append(track.track_cid) + track_cid_to_id_map[track.track_cid] = track.track_id erc1155_gated_tracks = list( filter( @@ -152,6 +165,7 @@ def _get_eth_nft_gated_track_signatures( contract_address_token_id_map[contract_address] = contract_address_token_id_map[ contract_address ].union(track_token_id_set) + track_cid_to_id_map[track.track_cid] = track.track_id with concurrent.futures.ThreadPoolExecutor() as executor: # Check ownership of nfts from erc721 collections from given contract addresses, @@ -171,8 +185,9 @@ def _get_eth_nft_gated_track_signatures( # nft collection is owned by the user. if future.result(): for track_cid in erc721_collection_track_map[contract_address]: + track_id = track_cid_to_id_map[track_cid] track_signature_map[ - track_cid + track_id ] = get_premium_content_signature_for_user( { "id": track_cid, @@ -183,7 +198,7 @@ def _get_eth_nft_gated_track_signatures( ) except Exception as e: logger.error( - f"Could not future result for erc721 contract_address {contract_address}. Error: {e}" + f"Could not get future result for erc721 contract_address {contract_address}. Error: {e}" ) # Check ownership of nfts from erc1155 collections from given contract addresses, @@ -206,8 +221,9 @@ def _get_eth_nft_gated_track_signatures( # nft collection is owned by the user. if future.result(): for track_cid in erc1155_collection_track_map[contract_address]: + track_id = track_cid_to_id_map[track_cid] track_signature_map[ - track_cid + track_id ] = get_premium_content_signature_for_user( { "id": track_cid, @@ -218,19 +234,191 @@ def _get_eth_nft_gated_track_signatures( ) except Exception as e: logger.error( - f"Could not future result for erc1155 contract_address {contract_address}. Error: {e}" + f"Could not get future result for erc1155 contract_address {contract_address}. Error: {e}" ) return track_signature_map -# todo: this will be implemented later +# Extended and simplified based on the reference links below +# https://docs.metaplex.com/programs/token-metadata/accounts#metadata +# https://github.com/metaplex-foundation/python-api/blob/441c2ba9be76962d234d7700405358c72ee1b35b/metaplex/metadata.py#L123 +def _unpack_metadata_account_for_metaplex_nft(data): + assert data[0] == 4 + i = 1 # key + i += 32 # update authority + i += 32 # mint + i += 36 # name + i += 14 # symbol + i += 204 # uri + i += 2 # seller fee basis points + has_creator = data[i] + i += 1 # whether has creators + if has_creator: + creator_len = struct.unpack(" track ids + # so that only one chain call will be made for premium tracks + # that share the same nft collection gate. + collection_track_map = defaultdict(list) + for track in tracks: + collection_mint_address = track.premium_conditions["nft_collection"]["address"] # type: ignore + collection_track_map[collection_mint_address].append(track.track_cid) + track_cid_to_id_map[track.track_cid] = track.track_id + + with concurrent.futures.ThreadPoolExecutor() as executor: + # Check ownership of nfts from collections from given collection mint addresses, + # using all user sol wallets, and generate signatures for corresponding tracks. + future_to_collection_mint_address_map = { + executor.submit( + _does_user_own_sol_nft_collection, + collection_mint_address, + sol_associated_wallets, + ): collection_mint_address + for collection_mint_address in list(collection_track_map.keys()) + } + for future in concurrent.futures.as_completed( + future_to_collection_mint_address_map + ): + collection_mint_address = future_to_collection_mint_address_map[future] + try: + # Generate premium content signatures for tracks whose + # nft collection is owned by the user. + if future.result(): + for track_cid in collection_track_map[collection_mint_address]: + track_id = track_cid_to_id_map[track_cid] + track_signature_map[ + track_id + ] = get_premium_content_signature_for_user( + { + "id": track_cid, + "type": "track", + "user_wallet": user_wallet, + "is_premium": True, + } + ) + except Exception as e: + logger.error( + f"Could not get future result for collection_mint_address {collection_mint_address}. Error: {e}" + ) + + return track_signature_map # Generates a premium content signature for each of the nft-gated tracks. @@ -353,4 +541,10 @@ def _load_abis(): erc1155_abi = json.dumps(json.load(f1155)) +def _init_solana_client_manager(): + global solana_client_manager + solana_client_manager = SolanaClientManager(shared_config["solana"]["endpoint"]) + + _load_abis() +_init_solana_client_manager() diff --git a/discovery-provider/src/schemas/track_schema.json b/discovery-provider/src/schemas/track_schema.json index 49014bc0af4..8a46918f8c8 100644 --- a/discovery-provider/src/schemas/track_schema.json +++ b/discovery-provider/src/schemas/track_schema.json @@ -409,13 +409,13 @@ "type": "string", "const": "sol" }, - "name": { + "address": { "type": "string" } }, "required": [ "chain", - "name" + "address" ], "title": "PremiumConditionsSolNFTCollection" }, diff --git a/discovery-provider/src/solana/solana_client_manager.py b/discovery-provider/src/solana/solana_client_manager.py index 5218964a026..14efc68db30 100644 --- a/discovery-provider/src/solana/solana_client_manager.py +++ b/discovery-provider/src/solana/solana_client_manager.py @@ -8,7 +8,9 @@ from solana.keypair import Keypair from solana.publickey import PublicKey from solana.rpc.api import Client, Commitment +from solana.rpc.types import TokenAccountOpts from src.exceptions import UnsupportedVersionError +from src.solana.solana_helpers import SPL_TOKEN_ID_PK from src.solana.solana_transaction_types import ( ConfirmedSignatureForAddressResponse, ConfirmedTransaction, @@ -149,6 +151,69 @@ def _get_slot(client: Client, index): "solana_client_manager.py | get_slot | All requests failed to fetch", ) + def get_token_accounts_by_owner( + self, owner: PublicKey, retries=DEFAULT_MAX_RETRIES + ): + def _get_token_accounts_by_owner(client: Client, index): + endpoint = self.endpoints[index] + num_retries = retries + while num_retries > 0: + try: + response = client.get_token_accounts_by_owner( + owner, + TokenAccountOpts( + program_id=SPL_TOKEN_ID_PK, encoding="jsonParsed" + ), + ) + return response["result"] + except Exception as e: + logger.error( + f"solana_client_manager.py | get_token_accounts_by_owner, {e}", + exc_info=True, + ) + num_retries -= 1 + time.sleep(DELAY_SECONDS) + logger.error( + f"solana_client_manager.py | get_token_accounts_by_owner | Retrying with endpoint {endpoint}" + ) + raise Exception( + f"solana_client_manager.py | get_token_accounts_by_owner | Failed with endpoint {endpoint}" + ) + + return _try_all( + self.clients, + _get_token_accounts_by_owner, + "solana_client_manager.py | get_token_accounts_by_owner | All requests failed to fetch", + ) + + def get_account_info(self, account: PublicKey, retries=DEFAULT_MAX_RETRIES): + def _get_account_info(client: Client, index): + endpoint = self.endpoints[index] + num_retries = retries + while num_retries > 0: + try: + response = client.get_account_info(account) + return response["result"] + except Exception as e: + logger.error( + f"solana_client_manager.py | get_account_info, {e}", + exc_info=True, + ) + num_retries -= 1 + time.sleep(DELAY_SECONDS) + logger.error( + f"solana_client_manager.py | get_account_info | Retrying with endpoint {endpoint}" + ) + raise Exception( + f"solana_client_manager.py | get_account_info | Failed with endpoint {endpoint}" + ) + + return _try_all( + self.clients, + _get_account_info, + "solana_client_manager.py | get_account_info | All requests failed to fetch", + ) + @contextmanager def timeout(time): diff --git a/discovery-provider/src/solana/solana_helpers.py b/discovery-provider/src/solana/solana_helpers.py index fc2881654d8..117c9a66c27 100644 --- a/discovery-provider/src/solana/solana_helpers.py +++ b/discovery-provider/src/solana/solana_helpers.py @@ -30,3 +30,8 @@ def get_derived_address(base, hashed_eth_pk, spl_token_id): # NOTE: This is static and will not change ASSOCIATED_TOKEN_PROGRAM_ID = "ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL" ASSOCIATED_TOKEN_PROGRAM_ID_PK = PublicKey(ASSOCIATED_TOKEN_PROGRAM_ID) + +# Static Metaplex Metadata Program ID +# NOTE: This is static and will not change +METADATA_PROGRAM_ID = "metaqbxxUerdq28cj1RbAWkYQm3ybzjb6a8bt518x1s" +METADATA_PROGRAM_ID_PK = PublicKey(METADATA_PROGRAM_ID) diff --git a/libs/src/services/schemaValidator/schemas/trackSchema.json b/libs/src/services/schemaValidator/schemas/trackSchema.json index ba40b6dfbc5..28c3449eafd 100644 --- a/libs/src/services/schemaValidator/schemas/trackSchema.json +++ b/libs/src/services/schemaValidator/schemas/trackSchema.json @@ -322,11 +322,11 @@ "type": "string", "const": "sol" }, - "name": { + "address": { "type": "string" } }, - "required": ["chain", "name"], + "required": ["chain", "address"], "title": "PremiumConditionsSolNFTCollection" }, "PremiumConditionsFollowUserId": { diff --git a/libs/src/utils/types.ts b/libs/src/utils/types.ts index 90421803b16..cad09422344 100644 --- a/libs/src/utils/types.ts +++ b/libs/src/utils/types.ts @@ -96,7 +96,7 @@ export type PremiumConditionsEthNFTCollection = { export type PremiumConditionsSolNFTCollection = { chain: 'sol' - name: string + address: string } export type PremiumConditions = {