diff --git a/app/app/urls.py b/app/app/urls.py index 952f1206546..a91bd4de2d8 100644 --- a/app/app/urls.py +++ b/app/app/urls.py @@ -155,6 +155,11 @@ r'^api/v0.1/profile/(?P.*)/verify_user_twitter', dashboard.views.verify_user_twitter, name='verify_user_twitter' + ), + url( + r'^api/v0.1/profile/(?P.*)/verify_user_poap', + dashboard.views.verify_user_poap, + name='verify_user_poap' ), url(r'^api/v0.1/profile/(?P.*)', dashboard.views.profile_details, name='profile_details'), url(r'^api/v0.1/user_card/(?P.*)', dashboard.views.user_card, name='user_card'), diff --git a/app/assets/v2/images/project_logos/poap.svg b/app/assets/v2/images/project_logos/poap.svg new file mode 100644 index 00000000000..f5e5a8cfd3c --- /dev/null +++ b/app/assets/v2/images/project_logos/poap.svg @@ -0,0 +1,35 @@ + + + + Logo + Created with Sketch. + + + + + + + + + + + \ No newline at end of file diff --git a/app/assets/v2/js/pages/profile-trust.js b/app/assets/v2/js/pages/profile-trust.js index 9856cde1965..e2bb0ac2d23 100644 --- a/app/assets/v2/js/pages/profile-trust.js +++ b/app/assets/v2/js/pages/profile-trust.js @@ -288,6 +288,168 @@ Vue.component('twitter-verify-modal', { } }); +Vue.component('poap-verify-modal', { + delimiters: [ '[[', ']]' ], + data: function() { + return { + showValidation: false, + validationStep: 'validate-address', + ethAddress: '', + signature: '', + validationError: '' + }; + }, + mounted: function() { + + $(document).on('click', '#verify-poap-link', function(event) { + event.preventDefault(); + this.showValidation = true; + }.bind(this)); + }, + template: ` + + `, + methods: { + dismissVerification() { + this.showValidation = false; + }, + clickedGoBack(event) { + event.preventDefault(); + this.validationStep = 'validate-address'; + this.ethAddress = ''; + this.validationError = ''; + }, + getEthAddress(){ + const accounts = web3.eth.getAccounts(); + $.when(accounts).then((result) => { + const ethAddress = result[0]; + this.ethAddress = ethAddress; + this.validationStep = 'validate-poap'; + this.showValidation = true; + }).catch((_error) => { + this.validationError = 'Error getting ethereum accounts'; + this.validationStep = 'validate-address'; + this.showValidation = true; + }); + + }, + generateSignature(){ + // Create a signature using the provided web3 account + web3.eth.personal.sign('verify_poap_badges', this.ethAddress) + .then(signature => { + this.signature = signature; + this.verifyPOAP(); + }); + }, + connectWeb3Wallet(){ + this.showValidation = false; + onConnect().then((result) => { + this.getEthAddress(); + }).catch((_error) => { + this.validationError = 'Error connecting ethereum accounts'; + this.validationStep = 'validate-address'; + this.showValidation = true; + }); + }, + clickedPullEthAddress(event) { + // Prompt web3 login if not connected + event.preventDefault(); + if (!provider) { + this.connectWeb3Wallet(); + }else{ + this.getEthAddress(); + } + }, + clickedChangeWallet(event){ + event.preventDefault(); + this.connectWeb3Wallet(); + }, + clickedValidate(event) { + event.preventDefault(); + this.validationError = ''; + this.validationStep = 'perform-validation'; + this.generateSignature(); + }, + verifyPOAP() { + const csrfmiddlewaretoken = document.querySelector('[name=csrfmiddlewaretoken]').value; + const payload = JSON.stringify({ + 'eth_address': this.ethAddress, + 'signature': this.signature + }); + const headers = {'X-CSRFToken': csrfmiddlewaretoken}; + + const verificationRequest = fetchData(`/api/v0.1/profile/${trustHandle}/verify_user_poap`, 'POST', payload, headers); + + $.when(verificationRequest).then(response => { + if (response.ok) { + this.validationStep = 'validation-complete'; + } else { + this.validationError = response.msg; + this.validationStep = 'validate-poap'; + } + + }).catch((_error) => { + console.log(_error); + this.validationError = 'There was an error; please try again later'; + this.validationStep = 'validate-poap'; + }); + } + } +}); // TODO: This component consists primarily of code taken from the SMS verification flow in the cart. // This approach is not DRY, and after Grants Round 7 completes, the cart should be refactored to include // this as a shared component, rather than duplicating the code. @@ -517,4 +679,4 @@ if (document.getElementById('gc-trust-verify-modal')) { el: '#gc-trust-verify-modal', data: { } }); -} \ No newline at end of file +} diff --git a/app/dashboard/migrations/0155_auto_20201014_0946.py b/app/dashboard/migrations/0155_auto_20201014_0946.py new file mode 100644 index 00000000000..fea8f8db326 --- /dev/null +++ b/app/dashboard/migrations/0155_auto_20201014_0946.py @@ -0,0 +1,19 @@ +# Generated by Django 2.2.4 on 2020-10-14 09:46 + +import django.contrib.postgres.fields +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('dashboard', '0154_profile_override_dict'), + ] + + operations = [ + migrations.AddField( + model_name='profile', + name='is_poap_verified', + field=models.BooleanField(default=False), + ) + ] diff --git a/app/dashboard/models.py b/app/dashboard/models.py index 7b02abf46ba..81ec359eed4 100644 --- a/app/dashboard/models.py +++ b/app/dashboard/models.py @@ -2908,6 +2908,7 @@ class Profile(SuperModel): brightid_uuid=models.UUIDField(default=uuid.uuid4, unique=True) is_brightid_verified=models.BooleanField(default=False) is_twitter_verified=models.BooleanField(default=False) + is_poap_verified=models.BooleanField(default=False) twitter_handle=models.CharField(blank=True, null=True, max_length=15) bio = models.TextField(default='', blank=True, help_text=_('User bio.')) interests = ArrayField(models.CharField(max_length=200), blank=True, default=list) @@ -5616,6 +5617,11 @@ def investigate_sybil(instance): total_sybil_score -= 1 htmls.append('(REDEMPTIONx1)') + htmls.append(f'POAP Verified: {instance.is_poap_verified}') + if instance.is_poap_verified: + total_sybil_score -= 1 + htmls.append('(REDEMPTIONx1)') + if instance.squelches.filter(active=True).exists(): htmls.append('USER HAS ACTIVE SQUELCHES') total_sybil_score += 3 diff --git a/app/dashboard/templates/profiles/tab_trust.html b/app/dashboard/templates/profiles/tab_trust.html index ef75dc488ab..036581c6c82 100644 --- a/app/dashboard/templates/profiles/tab_trust.html +++ b/app/dashboard/templates/profiles/tab_trust.html @@ -5,6 +5,7 @@
+

Trust Bonus

The higher the Trust @@ -143,7 +144,37 @@
Coming Soon ™️
{% include "profiles/trust_soon_row.html" with service="Activity on Gitcoin" %} {% include "profiles/trust_soon_row.html" with service="Idena Network" %} - {% include "profiles/trust_soon_row.html" with service="POAP" %} + +
+
+ + POAP Logo + +
+
+
+ Verify With POAP +
+
+ Verify your POAP badges. +
+
+
+
+ +5% +
+
+ Grants CLR Match +
+
+
+ {% if is_poap_verified %} + Verified + {% else %} + Verify + {% endif %} +
+
{% include "profiles/trust_soon_row.html" with service="Upala" %} {% include "profiles/trust_soon_row.html" with service="Duniter" %} {% include "profiles/trust_soon_row.html" with service="Equality Protocol" %} diff --git a/app/dashboard/utils.py b/app/dashboard/utils.py index 070c69b94a3..805902e7579 100644 --- a/app/dashboard/utils.py +++ b/app/dashboard/utils.py @@ -385,6 +385,45 @@ def getBountyContract(network): getBountyContract = web3.eth.contract(standardbounties_addr, abi=bounty_abi) return getBountyContract +def get_poap_contract_addresss(network): + if network == 'mainnet': + return to_checksum_address('0x22C1f6050E56d2876009903609a2cC3fEf83B415') + elif network == 'ropsten': + return to_checksum_address('0x50C5CA3e7f5566dA3Aa64eC687D283fdBEC2A2F2') + raise UnsupportedNetworkException(network) + + +def get_poap_contract(network): + web3 = get_web3(network) + poap_abi = '[{"constant":true,"inputs":[{"name":"interfaceId","type":"bytes4"}],"name":"supportsInterface","outputs":[{"name":"","type":"bool"}],"payable":false,"stateMutability":"view","type":"function"},{"constant":false,"inputs":[{"name":"eventId","type":"uint256"}],"name":"renounceEventMinter","outputs":[],"payable":false,"stateMutability":"nonpayable","type":"function"},{"constant":true,"inputs":[{"name":"tokenId","type":"uint256"}],"name":"getApproved","outputs":[{"name":"","type":"address"}],"payable":false,"stateMutability":"view","type":"function"},{"constant":false,"inputs":[{"name":"eventId","type":"uint256"},{"name":"account","type":"address"}],"name":"removeEventMinter","outputs":[],"payable":false,"stateMutability":"nonpayable","type":"function"},{"constant":true,"inputs":[],"name":"totalSupply","outputs":[{"name":"","type":"uint256"}],"payable":false,"stateMutability":"view","type":"function"},{"constant":true,"inputs":[{"name":"account","type":"address"}],"name":"isAdmin","outputs":[{"name":"","type":"bool"}],"payable":false,"stateMutability":"view","type":"function"},{"constant":true,"inputs":[{"name":"eventId","type":"uint256"},{"name":"account","type":"address"}],"name":"isEventMinter","outputs":[{"name":"","type":"bool"}],"payable":false,"stateMutability":"view","type":"function"},{"constant":true,"inputs":[{"name":"owner","type":"address"},{"name":"index","type":"uint256"}],"name":"tokenOfOwnerByIndex","outputs":[{"name":"","type":"uint256"}],"payable":false,"stateMutability":"view","type":"function"},{"constant":false,"inputs":[],"name":"unpause","outputs":[],"payable":false,"stateMutability":"nonpayable","type":"function"},{"constant":false,"inputs":[{"name":"from","type":"address"},{"name":"to","type":"address"},{"name":"tokenId","type":"uint256"}],"name":"safeTransferFrom","outputs":[],"payable":false,"stateMutability":"nonpayable","type":"function"},{"constant":true,"inputs":[{"name":"index","type":"uint256"}],"name":"tokenByIndex","outputs":[{"name":"","type":"uint256"}],"payable":false,"stateMutability":"view","type":"function"},{"constant":true,"inputs":[],"name":"paused","outputs":[{"name":"","type":"bool"}],"payable":false,"stateMutability":"view","type":"function"},{"constant":true,"inputs":[{"name":"tokenId","type":"uint256"}],"name":"ownerOf","outputs":[{"name":"","type":"address"}],"payable":false,"stateMutability":"view","type":"function"},{"constant":false,"inputs":[{"name":"account","type":"address"}],"name":"addAdmin","outputs":[],"payable":false,"stateMutability":"nonpayable","type":"function"},{"constant":true,"inputs":[{"name":"owner","type":"address"}],"name":"balanceOf","outputs":[{"name":"","type":"uint256"}],"payable":false,"stateMutability":"view","type":"function"},{"constant":false,"inputs":[],"name":"pause","outputs":[],"payable":false,"stateMutability":"nonpayable","type":"function"},{"constant":false,"inputs":[],"name":"renounceAdmin","outputs":[],"payable":false,"stateMutability":"nonpayable","type":"function"},{"constant":false,"inputs":[{"name":"eventId","type":"uint256"},{"name":"account","type":"address"}],"name":"addEventMinter","outputs":[],"payable":false,"stateMutability":"nonpayable","type":"function"},{"constant":false,"inputs":[{"name":"from","type":"address"},{"name":"to","type":"address"},{"name":"tokenId","type":"uint256"},{"name":"_data","type":"bytes"}],"name":"safeTransferFrom","outputs":[],"payable":false,"stateMutability":"nonpayable","type":"function"},{"constant":true,"inputs":[{"name":"owner","type":"address"},{"name":"operator","type":"address"}],"name":"isApprovedForAll","outputs":[{"name":"","type":"bool"}],"payable":false,"stateMutability":"view","type":"function"},{"anonymous":false,"inputs":[{"indexed":false,"name":"eventId","type":"uint256"},{"indexed":false,"name":"tokenId","type":"uint256"}],"name":"EventToken","type":"event"},{"anonymous":false,"inputs":[{"indexed":false,"name":"account","type":"address"}],"name":"Paused","type":"event"},{"anonymous":false,"inputs":[{"indexed":false,"name":"account","type":"address"}],"name":"Unpaused","type":"event"},{"anonymous":false,"inputs":[{"indexed":true,"name":"account","type":"address"}],"name":"AdminAdded","type":"event"},{"anonymous":false,"inputs":[{"indexed":true,"name":"account","type":"address"}],"name":"AdminRemoved","type":"event"},{"anonymous":false,"inputs":[{"indexed":true,"name":"eventId","type":"uint256"},{"indexed":true,"name":"account","type":"address"}],"name":"EventMinterAdded","type":"event"},{"anonymous":false,"inputs":[{"indexed":true,"name":"eventId","type":"uint256"},{"indexed":true,"name":"account","type":"address"}],"name":"EventMinterRemoved","type":"event"},{"anonymous":false,"inputs":[{"indexed":true,"name":"from","type":"address"},{"indexed":true,"name":"to","type":"address"},{"indexed":true,"name":"tokenId","type":"uint256"}],"name":"Transfer","type":"event"},{"anonymous":false,"inputs":[{"indexed":true,"name":"owner","type":"address"},{"indexed":true,"name":"approved","type":"address"},{"indexed":true,"name":"tokenId","type":"uint256"}],"name":"Approval","type":"event"},{"anonymous":false,"inputs":[{"indexed":true,"name":"owner","type":"address"},{"indexed":true,"name":"operator","type":"address"},{"indexed":false,"name":"approved","type":"bool"}],"name":"ApprovalForAll","type":"event"},{"constant":true,"inputs":[],"name":"name","outputs":[{"name":"","type":"string"}],"payable":false,"stateMutability":"view","type":"function"},{"constant":true,"inputs":[],"name":"symbol","outputs":[{"name":"","type":"string"}],"payable":false,"stateMutability":"view","type":"function"},{"constant":true,"inputs":[{"name":"tokenId","type":"uint256"}],"name":"tokenEvent","outputs":[{"name":"","type":"uint256"}],"payable":false,"stateMutability":"view","type":"function"},{"constant":true,"inputs":[{"name":"owner","type":"address"},{"name":"index","type":"uint256"}],"name":"tokenDetailsOfOwnerByIndex","outputs":[{"name":"tokenId","type":"uint256"},{"name":"eventId","type":"uint256"}],"payable":false,"stateMutability":"view","type":"function"},{"constant":true,"inputs":[{"name":"tokenId","type":"uint256"}],"name":"tokenURI","outputs":[{"name":"","type":"string"}],"payable":false,"stateMutability":"view","type":"function"},{"constant":false,"inputs":[{"name":"baseURI","type":"string"}],"name":"setBaseURI","outputs":[],"payable":false,"stateMutability":"nonpayable","type":"function"},{"constant":false,"inputs":[{"name":"to","type":"address"},{"name":"tokenId","type":"uint256"}],"name":"approve","outputs":[],"payable":false,"stateMutability":"nonpayable","type":"function"},{"constant":false,"inputs":[{"name":"to","type":"address"},{"name":"approved","type":"bool"}],"name":"setApprovalForAll","outputs":[],"payable":false,"stateMutability":"nonpayable","type":"function"},{"constant":false,"inputs":[{"name":"from","type":"address"},{"name":"to","type":"address"},{"name":"tokenId","type":"uint256"}],"name":"transferFrom","outputs":[],"payable":false,"stateMutability":"nonpayable","type":"function"},{"constant":false,"inputs":[{"name":"eventId","type":"uint256"},{"name":"to","type":"address"}],"name":"mintToken","outputs":[{"name":"","type":"bool"}],"payable":false,"stateMutability":"nonpayable","type":"function"},{"constant":false,"inputs":[{"name":"eventId","type":"uint256"},{"name":"to","type":"address[]"}],"name":"mintEventToManyUsers","outputs":[{"name":"","type":"bool"}],"payable":false,"stateMutability":"nonpayable","type":"function"},{"constant":false,"inputs":[{"name":"eventIds","type":"uint256[]"},{"name":"to","type":"address"}],"name":"mintUserToManyEvents","outputs":[{"name":"","type":"bool"}],"payable":false,"stateMutability":"nonpayable","type":"function"},{"constant":false,"inputs":[{"name":"tokenId","type":"uint256"}],"name":"burn","outputs":[],"payable":false,"stateMutability":"nonpayable","type":"function"},{"constant":false,"inputs":[{"name":"__name","type":"string"},{"name":"__symbol","type":"string"},{"name":"__baseURI","type":"string"},{"name":"admins","type":"address[]"}],"name":"initialize","outputs":[],"payable":false,"stateMutability":"nonpayable","type":"function"},{"constant":false,"inputs":[{"name":"sender","type":"address"}],"name":"initialize","outputs":[],"payable":false,"stateMutability":"nonpayable","type":"function"},{"constant":false,"inputs":[],"name":"initialize","outputs":[],"payable":false,"stateMutability":"nonpayable","type":"function"}]' + poap_addr = get_poap_contract_addresss(network) + poap_abi = json.loads(poap_abi) + poap_contract = web3.eth.contract(poap_addr, abi=poap_abi) + return poap_contract + +def get_poap_earliest_owned_token_timestamp(network, address): + poap_contract = get_poap_contract(network) + from_block = 7844308 + if network == "ropsten": + from_block = 5592255 + # Filter the contract events by owner address + transfer_filter = poap_contract.events.Transfer.createFilter(argument_filters={'to': address}, fromBlock=from_block, toBlock='latest') + log_entries = transfer_filter.get_all_entries() + if len(log_entries) == 0: + # We find no event for this address + return None + else: + # get block number of the earliest tokenId that still owned by owner + for entry in log_entries: + token_id = entry.args.tokenId + block_number = entry.blockNumber + owner = poap_contract.functions.ownerOf(token_id).call() + if address.lower() == owner.lower(): + # Gotcha + web3 = get_web3(network) + return web3.eth.getBlock(block_number).timestamp + + def get_bounty(bounty_enum, network): if (settings.DEBUG or settings.ENV != 'prod') and network == 'mainnet': diff --git a/app/dashboard/views.py b/app/dashboard/views.py index df42bda2ab7..097ded1dfe4 100644 --- a/app/dashboard/views.py +++ b/app/dashboard/views.py @@ -78,7 +78,7 @@ from dashboard.tasks import increment_view_count from dashboard.utils import ( ProfileHiddenException, ProfileNotFoundException, build_profile_pairs, get_bounty_from_invite_url, get_orgs_perms, - profile_helper, + profile_helper, get_poap_earliest_owned_token_timestamp ) from economy.utils import ConversionRateNotFoundError, convert_amount, convert_token_to_usdt from eth_utils import to_checksum_address, to_normalized_address @@ -134,6 +134,8 @@ re_market_bounty, record_user_action_on_interest, release_bounty_to_the_public, sync_payout, web3_process_bounty, ) +from eth_account.messages import defunct_hash_message + logger = logging.getLogger(__name__) confirm_time_minutes_target = 4 @@ -142,6 +144,7 @@ w3 = Web3(HTTPProvider(settings.WEB3_HTTP_PROVIDER)) + @protected_resource() def oauth_connect(request, *args, **kwargs): active_user_profile = Profile.objects.filter(user_id=request.user.id).select_related()[0] @@ -2913,6 +2916,7 @@ def get_profile_tab(request, profile, tab, prev_context): context['upcoming_calls'] = [] context['is_sms_verified'] = profile.sms_verification + context['is_poap_verified'] = profile.is_poap_verified context['is_twitter_verified'] = profile.is_twitter_verified context['verify_tweet_text'] = verify_text_for_tweet(profile.handle) else: @@ -6239,3 +6243,60 @@ def events(request, hackathon): return JsonResponse({ 'events': events, }) + + +@login_required +@require_POST +def verify_user_poap(request, handle): + is_logged_in_user = request.user.is_authenticated and request.user.username.lower() == handle.lower() + if not is_logged_in_user: + return JsonResponse({ + 'ok': False, + 'msg': f'Request must be for the logged in user' + }) + + profile = profile_helper(handle, True) + if profile.is_poap_verified: + return JsonResponse({ + 'ok': True, + 'msg': f'User was verified previously' + }) + + request_data = json.loads(request.body.decode('utf-8')) + signature = request_data.get('signature', '') + eth_address = request_data.get('eth_address', '') + if eth_address == '' or signature == '': + return JsonResponse({ + 'ok': False, + 'msg': 'Empty signature or Ethereum address', + }) + + message_hash = defunct_hash_message(text="verify_poap_badges") + signer = w3.eth.account.recoverHash(message_hash, signature=signature) + if eth_address != signer: + return JsonResponse({ + 'ok': False, + 'msg': 'Invalid signature', + }) + + # commented out because network = get_default_network() results in dashboard.utils.UnsupportedNetworkException: rinkeby + # network = get_default_network() + network = "mainnet" + fifteen_days_ago = datetime.now()-timedelta(days=15) + + timestamp = get_poap_earliest_owned_token_timestamp(network, eth_address) + if timestamp is None or timestamp > fifteen_days_ago.timestamp(): + # We couldn't find any POAP badge for this ethereum address + return JsonResponse({ + 'ok': False, + 'msg': 'No POAP badges(ERC721 NFTs) has been sitting in this wallet for more than 15 days!', + }) + + profile = profile_helper(handle, True) + profile.is_poap_verified = True + profile.save() + return JsonResponse({ + 'ok': True, + 'msg': 'Found a POAP badge that has been sitting in this wallet more than 15 days' + } + )