diff --git a/accounts/migrations/0002_account_near_social_profile_data.py b/accounts/migrations/0002_account_near_social_profile_data.py new file mode 100644 index 0000000..7641235 --- /dev/null +++ b/accounts/migrations/0002_account_near_social_profile_data.py @@ -0,0 +1,22 @@ +# Generated by Django 5.0.4 on 2024-06-05 14:02 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("accounts", "0001_initial"), + ] + + operations = [ + migrations.AddField( + model_name="account", + name="near_social_profile_data", + field=models.JSONField( + help_text="NEAR social data contained under 'profile' key.", + null=True, + verbose_name="NEAR social profile data", + ), + ), + ] diff --git a/accounts/models.py b/accounts/models.py index 834c6de..ec301d5 100644 --- a/accounts/models.py +++ b/accounts/models.py @@ -1,7 +1,11 @@ +import requests from django import db +from django.conf import settings from django.db import models from django.utils.translation import gettext_lazy as _ +from base.logging import logger + class Account(models.Model): id = models.CharField( @@ -39,5 +43,83 @@ class Account(models.Model): default=0, help_text=_("Number of donors."), ) + near_social_profile_data = models.JSONField( + _("NEAR social profile data"), + null=True, + help_text=_("NEAR social data contained under 'profile' key."), + ) + + def fetch_near_social_profile_data(self, should_save=True): + # Fetch social profile data from NEAR blockchain + try: + url = f"{settings.FASTNEAR_RPC_URL}/account/{settings.NEAR_SOCIAL_CONTRACT_ADDRESS}/view/get" + keys_value = f'["{self.id}/profile/**"]' + params = {"keys.json": keys_value} + response = requests.get(url, params=params) + if response.status_code == 200: + data = response.json() + if self.id in data and "profile" in data[self.id]: + profile_data = data[self.id][ + "profile" + ] # TODO: validate/sanitize profile data? + # fetch NFT URLs if applicable + for image_type in ["image", "backgroundImage"]: + if ( + image_type in profile_data + and "nft" in profile_data[image_type] + and "contractId" in profile_data[image_type]["nft"] + and "tokenId" in profile_data[image_type]["nft"] + ): + contract_id = profile_data[image_type]["nft"]["contractId"] + token_id = profile_data[image_type]["nft"]["tokenId"] + # get base_uri + url = f"https://rpc.web4.near.page/account/{contract_id}/view/nft_metadata" + response = requests.get(url) + if response.status_code == 200: + metadata = response.json() + if "base_uri" in metadata: + base_uri = metadata["base_uri"] + # store baseUri in profile_data + profile_data[image_type]["nft"][ + "baseUri" + ] = base_uri + else: + logger.error( + f"Request for NFT metadata failed ({response.status_code}) with message: {response.text}" + ) + # get token metadata + url = f"https://rpc.web4.near.page/account/{contract_id}/view/nft_token" + json_data = {"token_id": token_id} + response = requests.post( + url, json=json_data + ) # using a POST request here so that token_id is not coerced into an integer on fastnear's side, causing a contract view error + if response.status_code == 200: + token_metadata = response.json() + if ( + "metadata" in token_metadata + and "media" in token_metadata["metadata"] + ): + # store media in profile_data + profile_data[image_type]["nft"]["media"] = ( + token_metadata["metadata"]["media"] + ) + else: + logger.error( + f"Request for NFT metadata failed ({response.status_code}) with message: {response.text}" + ) + self.near_social_profile_data = profile_data + if should_save: + self.save() + else: + logger.error( + f"Request for NEAR Social profile data failed ({response.status_code}) with message: {response.text}" + ) + except Exception as e: + logger.error(f"Error fetching NEAR social profile data: {e}") - # add Meta, properties & methods as necessary + def save(self, *args, **kwargs): + if self._state.adding: # If the account is being created (not updated) + self.fetch_near_social_profile_data( + False # don't save yet as we want to avoid infinite loop + ) + super().save(*args, **kwargs) diff --git a/accounts/serializers.py b/accounts/serializers.py index e051984..9654744 100644 --- a/accounts/serializers.py +++ b/accounts/serializers.py @@ -12,4 +12,5 @@ class Meta: "total_donations_out_usd", "total_matching_pool_allocations_usd", "donors_count", + "near_social_profile_data", ] diff --git a/base/settings.py b/base/settings.py index dcfdc83..d086918 100644 --- a/base/settings.py +++ b/base/settings.py @@ -59,6 +59,16 @@ POTLOCK_TLA = "potlock.testnet" if ENVIRONMENT == "testnet" else "potlock.near" +NEAR_SOCIAL_CONTRACT_ADDRESS = ( + "v1.social08.testnet" if ENVIRONMENT == "testnet" else "social.near" +) + +FASTNEAR_RPC_URL = ( + "https://rpc.web4.testnet.page" + if ENVIRONMENT == "testnet" + else "https://rpc.web4.near.page" +) + BLOCK_SAVE_HEIGHT = os.environ.get("BLOCK_SAVE_HEIGHT") COINGECKO_URL = ( diff --git a/indexer_app/handler.py b/indexer_app/handler.py index 1981fb3..6aa5bdd 100644 --- a/indexer_app/handler.py +++ b/indexer_app/handler.py @@ -26,6 +26,7 @@ handle_pot_application, handle_pot_application_status_change, handle_set_payouts, + handle_social_profile_update, handle_transfer_payout, ) @@ -46,19 +47,21 @@ async def handle_streamer_message(streamer_message: near_primitives.StreamerMess for shard in streamer_message.shards: for receipt_execution_outcome in shard.receipt_execution_outcomes: - # we only want to proceed if it's a potlock tx and it succeeded.... (unreadable if statement?) - lists_contract = "lists." + settings.POTLOCK_TLA - if not receipt_execution_outcome.receipt.receiver_id.endswith( - settings.POTLOCK_TLA - ) or ( + # we only want to proceed if the tx succeeded + if ( "SuccessReceiptId" not in receipt_execution_outcome.execution_outcome.outcome.status and "SuccessValue" not in receipt_execution_outcome.execution_outcome.outcome.status ): continue + receiver_id = receipt_execution_outcome.receipt.receiver_id + if ( + receiver_id != settings.NEAR_SOCIAL_CONTRACT_ADDRESS + and not receiver_id.endswith(settings.POTLOCK_TLA) + ): + continue # 1. HANDLE LOGS - log_data = [] for log_index, log in enumerate( @@ -89,6 +92,7 @@ async def handle_streamer_message(streamer_message: near_primitives.StreamerMess # # consider logging failures to logging service; for now, just skip # print("here we are...") # continue + lists_contract = "lists." + settings.POTLOCK_TLA for index, action in enumerate( receipt_execution_outcome.receipt.receipt["Action"]["actions"] @@ -125,6 +129,12 @@ async def handle_streamer_message(streamer_message: near_primitives.StreamerMess args_dict = {} match method_name: + case "set": # handle near social profile data updates + if receiver_id == settings.NEAR_SOCIAL_CONTRACT_ADDRESS: + logger.info(f"setting profile data: {args_dict}") + await handle_social_profile_update( + args_dict, receiver_id, signer_id + ) case "new": if match_pot_factory_pattern(receipt.receiver_id): logger.info(f"matched for factory pattern: {args_dict}") diff --git a/indexer_app/utils.py b/indexer_app/utils.py index 3e8ee85..caf9603 100644 --- a/indexer_app/utils.py +++ b/indexer_app/utils.py @@ -1,5 +1,4 @@ import base64 -import decimal import json from datetime import datetime @@ -29,6 +28,23 @@ # GECKO_URL = "https://api.coingecko.com/api/v3" # TODO: move to settings +async def handle_social_profile_update(args_dict, receiver_id, signer_id): + logger.info(f"handling social profile update for {signer_id}") + if ( + "data" in args_dict + and signer_id in args_dict["data"] + and "profile" in args_dict["data"][signer_id] + ): + try: + # only proceed if this account already exists in db + account = await Account.objects.filter(id=signer_id).first() + if account: + logger.info(f"updating social profile for {signer_id}") + account.fetch_near_social_profile_data() + except Exception as e: + logger.error(f"Error in handle_social_profile_update: {e}") + + async def handle_new_pot( data: dict, receiverId: str, diff --git a/lists/migrations/0003_alter_listupvote_options.py b/lists/migrations/0003_alter_listupvote_options.py new file mode 100644 index 0000000..96ef6e5 --- /dev/null +++ b/lists/migrations/0003_alter_listupvote_options.py @@ -0,0 +1,17 @@ +# Generated by Django 5.0.4 on 2024-06-05 14:02 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ("lists", "0002_alter_listupvote_options_and_more"), + ] + + operations = [ + migrations.AlterModelOptions( + name="listupvote", + options={"verbose_name_plural": "List Upvotes"}, + ), + ] diff --git a/pots/migrations/0003_alter_potapplication_options_and_more.py b/pots/migrations/0003_alter_potapplication_options_and_more.py new file mode 100644 index 0000000..48bb30a --- /dev/null +++ b/pots/migrations/0003_alter_potapplication_options_and_more.py @@ -0,0 +1,33 @@ +# Generated by Django 5.0.4 on 2024-06-05 14:02 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ("pots", "0002_alter_potapplication_options_and_more"), + ] + + operations = [ + migrations.AlterModelOptions( + name="potapplication", + options={"verbose_name_plural": "Pot Applications"}, + ), + migrations.AlterModelOptions( + name="potapplicationreview", + options={"verbose_name_plural": "Pot Application Reviews"}, + ), + migrations.AlterModelOptions( + name="potfactory", + options={"verbose_name_plural": "Pot Factories"}, + ), + migrations.AlterModelOptions( + name="potpayoutchallenge", + options={"verbose_name_plural": "Payout Challenges"}, + ), + migrations.AlterModelOptions( + name="potpayoutchallengeadminresponse", + options={"verbose_name_plural": "Payout Challenge Responses"}, + ), + ] diff --git a/pyproject.toml b/pyproject.toml index dca95a0..fee60dd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -17,7 +17,7 @@ djangorestframework = "^3.15.1" django-cachalot = "^2.6.2" django-redis = "^5.4.0" gunicorn = "^22.0.0" -sentry-sdk = {extras = ["django"], version = "^1.45.0"} +sentry-sdk = { extras = ["django"], version = "^1.45.0" } watchtower = "^3.1.0" django-cors-headers = "^4.3.1" drf-spectacular = "^0.27.2"