diff --git a/app/app/context.py b/app/app/context.py index 4b456ea37e5..529dea93e21 100644 --- a/app/app/context.py +++ b/app/app/context.py @@ -194,7 +194,9 @@ def preprocess(request): 'ptoken_factory_address': settings.PTOKEN_FACTORY_ADDRESS, 'ptoken_factory_abi': settings.PTOKEN_FACTORY_ABI, 'ptoken_address': ptoken.token_address if ptoken else '', - 'ptoken_id': ptoken.id if ptoken else None + 'ptoken_id': ptoken.id if ptoken else None, + 'match_payouts_abi': settings.MATCH_PAYOUTS_ABI, + 'match_payouts_address': settings.MATCH_PAYOUTS_ADDRESS, } context['json_context'] = json.dumps(context) context['last_posts'] = cache.get_or_set('last_posts', fetchPost, 5000) diff --git a/app/app/settings.py b/app/app/settings.py index 1ed91a300a7..758e4c05588 100644 --- a/app/app/settings.py +++ b/app/app/settings.py @@ -885,3 +885,7 @@ def callback(request): # Idena IDENA_TOKEN_EXPIRY = 60 * 60 # 1 Hours IDENA_NONCE_EXPIRY = 60 * 2 # 2 Min + +# Match Payouts contract +MATCH_PAYOUTS_ABI = '[ { "inputs": [ { "internalType": "address", "name": "_owner", "type": "address" }, { "internalType": "address", "name": "_funder", "type": "address" }, { "internalType": "contract IERC20", "name": "_dai", "type": "address" } ], "stateMutability": "nonpayable", "type": "constructor" }, { "anonymous": false, "inputs": [], "name": "Finalized", "type": "event" }, { "anonymous": false, "inputs": [], "name": "Funded", "type": "event" }, { "anonymous": false, "inputs": [], "name": "FundingWithdrawn", "type": "event" }, { "anonymous": false, "inputs": [ { "indexed": false, "internalType": "address", "name": "recipient", "type": "address" }, { "indexed": false, "internalType": "uint256", "name": "amount", "type": "uint256" } ], "name": "PayoutAdded", "type": "event" }, { "anonymous": false, "inputs": [ { "indexed": false, "internalType": "address", "name": "recipient", "type": "address" }, { "indexed": false, "internalType": "uint256", "name": "amount", "type": "uint256" } ], "name": "PayoutClaimed", "type": "event" }, { "inputs": [ { "internalType": "address", "name": "_recipient", "type": "address" } ], "name": "claimMatchPayout", "outputs": [], "stateMutability": "nonpayable", "type": "function" }, { "inputs": [], "name": "dai", "outputs": [ { "internalType": "contract IERC20", "name": "", "type": "address" } ], "stateMutability": "view", "type": "function" }, { "inputs": [], "name": "enablePayouts", "outputs": [], "stateMutability": "nonpayable", "type": "function" }, { "inputs": [], "name": "finalize", "outputs": [], "stateMutability": "nonpayable", "type": "function" }, { "inputs": [], "name": "funder", "outputs": [ { "internalType": "address", "name": "", "type": "address" } ], "stateMutability": "view", "type": "function" }, { "inputs": [], "name": "owner", "outputs": [ { "internalType": "address", "name": "", "type": "address" } ], "stateMutability": "view", "type": "function" }, { "inputs": [ { "internalType": "address", "name": "", "type": "address" } ], "name": "payouts", "outputs": [ { "internalType": "uint256", "name": "", "type": "uint256" } ], "stateMutability": "view", "type": "function" }, { "inputs": [ { "components": [ { "internalType": "address", "name": "recipient", "type": "address" }, { "internalType": "uint256", "name": "amount", "type": "uint256" } ], "internalType": "struct MatchPayouts.PayoutFields[]", "name": "_payouts", "type": "tuple[]" } ], "name": "setPayouts", "outputs": [], "stateMutability": "nonpayable", "type": "function" }, { "inputs": [], "name": "state", "outputs": [ { "internalType": "enum MatchPayouts.State", "name": "", "type": "uint8" } ], "stateMutability": "view", "type": "function" }, { "inputs": [], "name": "withdrawFunding", "outputs": [], "stateMutability": "nonpayable", "type": "function" } ]' +MATCH_PAYOUTS_ADDRESS = '0xf2354570bE2fB420832Fb7Ff6ff0AE0dF80CF2c6' diff --git a/app/assets/v2/js/grants/_detail-component.js b/app/assets/v2/js/grants/_detail-component.js index c851dd90cb5..a779a8faf5f 100644 --- a/app/assets/v2/js/grants/_detail-component.js +++ b/app/assets/v2/js/grants/_detail-component.js @@ -347,6 +347,44 @@ Vue.mixin({ } vm.submitted = false; return true; // no errors, continue to create grant + }, + claimMatch: async function(recipient) { + // Helper method to manage state + const waitingState = (state) => { + indicateMetamaskPopup(!state); + $('#claim-match').prop('disabled', state); + }; + + // Connect wallet + if (!provider) { + await onConnect(); + } + + // Confirm wallet was connected (user may have closed wallet connection prompt) + if (!provider) { + return; + } + waitingState(true); + const user = (await web3.eth.getAccounts())[0]; + + // Get contract instance + const matchPayouts = await new web3.eth.Contract( + JSON.parse(document.contxt.match_payouts_abi), + document.contxt.match_payouts_address + ); + + // Claim payout + matchPayouts.methods.claimMatchPayout(recipient) + .send({from: user}) + .on('transactionHash', async function(txHash) { + waitingState(false); + $('#match-payout-section').hide(); + _alert("Match payout claimed! Funds will be sent to this grant's address", 'success'); + }) + .on('error', function (error) { + waitingState(false); + _alert(error, 'error'); + }); } }, computed: { diff --git a/app/grants/management/commands/payout_round.py b/app/grants/management/commands/payout_round.py index 159b7b970c7..0fdd84dbe63 100644 --- a/app/grants/management/commands/payout_round.py +++ b/app/grants/management/commands/payout_round.py @@ -88,7 +88,13 @@ def handle(self, *args, **options): # finalize rankings if what == 'finalize': - total_owed_grants = sum(grant.clr_match_estimate_this_round for grant in grants) + total_owed_grants = 0 + for grant in grants: + try: + for gclr in grant.clr_calculations.filter(grantclr__in=gclrs, latest=True): + total_owed_grants += gclr.clr_prediction_curve[0][1] + except: + pass total_owed_matches = sum(sm.amount for sm in scheduled_matches) print(f"there are {grants.count()} grants to finalize worth ${round(total_owed_grants,2)}") print(f"there are {scheduled_matches.count()} Match Payments already created worth ${round(total_owed_matches,2)}") @@ -97,7 +103,7 @@ def handle(self, *args, **options): if user_input != 'y': return for grant in grants: - amount = grant.clr_match_estimate_this_round + amount = sum(ele.clr_prediction_curve[0][1] for ele in grant.clr_calculations.filter(grantclr__in=gclrs, latest=True)) has_already_kyc = grant.clr_matches.filter(has_passed_kyc=True).exists() if not amount: continue diff --git a/app/grants/management/commands/round8_payouts.py b/app/grants/management/commands/round8_payouts.py new file mode 100644 index 00000000000..8f6e6a36994 --- /dev/null +++ b/app/grants/management/commands/round8_payouts.py @@ -0,0 +1,423 @@ +''' + Copyright (C) 2020 Gitcoin Core + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published + by the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . + +''' + +''' + Used to verify the match payouts that are set in the Grants Round 8 Payout contract. This + contract is deployed on both mainnet and Rinkeby at 0xAf32BDf2e2720f6C6a2Fce8B50Ed66fd2b46d478 +''' + +import json +import time + +from django.conf import settings +from django.contrib.contenttypes.models import ContentType +from django.core import management +from django.core.management.base import BaseCommand +from django.utils import timezone + +from dashboard.abi import erc20_abi +from dashboard.models import Activity, Earning, Profile +from dashboard.utils import get_tx_status, get_web3, has_tx_mined +from gas.utils import recommend_min_gas_price_to_confirm_in_time +from grants.models import CLRMatch, Contribution, Grant, GrantCLR, Subscription +from marketing.mails import ( + grant_match_distribution_final_txn, grant_match_distribution_kyc, grant_match_distribution_test_txn, +) +from townsquare.models import Comment +from web3 import HTTPProvider, Web3 +from decimal import Decimal + +match_payouts_abi = settings.MATCH_PAYOUTS_ABI +match_payouts_address = settings.MATCH_PAYOUTS_ADDRESS +SCALE = Decimal(1e18) # scale factor for converting Dai units +WAIT_TIME_BETWEEN_TXS = 15 # seconds + +class Command(BaseCommand): + + help = 'Sets or verifies the match payouts in the Grants Round 8 Payout contract.' + + def add_arguments(self, parser): + # Required arguments + parser.add_argument('what', + default='verify', + type=str, + help="what do we do? (finalize, payout_test, prepare_final_payout, verify, set_payouts_test, set_payouts)" + ) + parser.add_argument('network', + default='rinkeby', + type=str, + help="Must be either 'mainnet' or 'rinkeby'" + ) + + # Required if what != verify + parser.add_argument('--clr_pks', + default=None, + type=str, + help="what CLR PKs should we payout? (eg 1,2,3,4)" + ) + parser.add_argument('--clr_round', + default=None, + type=int, + help="what CLR round number is this? eg 7" + ) + + + def handle(self, *args, **options): + + # Parse inputs + what = options['what'] + network = options['network'] + + valid_whats = ['finalize', 'payout_test', 'prepare_final_payout', 'verify', 'set_payouts_test', 'set_payouts'] + if what not in valid_whats: + raise Exception(f"Invalid value {what} for 'what' arg") + if network not in ['rinkeby', 'mainnet']: + raise Exception(f"Invalid value {network} for 'network' arg") + if not options['clr_round'] or not options['clr_pks']: + raise Exception('Must provide clr_round and clr_pks') + + # Define parameters that vary by network. The expected total DAI amount uses the value here + # if one is not available in the database + from_block = 11466409 if network == 'mainnet' else 7731622 # block contract was deployed at + dai_address = '0x6B175474E89094C44Da98b954EedeAC495271d0F' if network == 'mainnet' else '0x2e055eEe18284513B993dB7568A592679aB13188' + expected_total_dai_amount = 100_000 if network == 'mainnet' else 5000 # in dollars, not wei, e.g. 500 = 500e18 + + # Get contract instances + PROVIDER = "wss://" + network + ".infura.io/ws/v3/" + settings.INFURA_V3_PROJECT_ID + w3 = Web3(Web3.WebsocketProvider(PROVIDER)) + + match_payouts = w3.eth.contract(address=match_payouts_address, abi=match_payouts_abi) + dai = w3.eth.contract(address=dai_address, abi=erc20_abi) + + # Setup + clr_round = options['clr_round'] + clr_pks = options['clr_pks'].split(',') + KYC_THRESHOLD = settings.GRANTS_PAYOUT_CLR_KYC_THRESHOLD + + # Get data + gclrs = GrantCLR.objects.filter(pk__in=clr_pks) + pks = [] + for gclr in gclrs: + pks += gclr.grants.values_list('pk', flat=True) + scheduled_matches = CLRMatch.objects.filter(round_number=clr_round) + grants = Grant.objects.filter(active=True, network='mainnet', is_clr_eligible=True, link_to_new_grant__isnull=True, pk__in=pks) + print(f"got {grants.count()} grants") + + # Finalize rankings ------------------------------------------------------------------------ + if what == 'finalize': + total_owed_grants = 0 + for grant in grants: + try: + for gclr in grant.clr_calculations.filter(grantclr__in=gclrs, latest=True): + total_owed_grants += gclr.clr_prediction_curve[0][1] + except: + pass + total_owed_matches = sum(sm.amount for sm in scheduled_matches) + print(f"there are {grants.count()} grants to finalize worth ${round(total_owed_grants,2)}") + print(f"there are {scheduled_matches.count()} Match Payments already created worth ${round(total_owed_matches,2)}") + print('------------------------------') + user_input = input("continue? (y/n) ") + if user_input != 'y': + return + for grant in grants: + amount = sum(ele.clr_prediction_curve[0][1] for ele in grant.clr_calculations.filter(grantclr__in=gclrs, latest=True)) + has_already_kyc = grant.clr_matches.filter(has_passed_kyc=True).exists() + if not amount: + continue + already_exists = scheduled_matches.filter(grant=grant).exists() + if already_exists: + continue + needs_kyc = amount > KYC_THRESHOLD and not has_already_kyc + comments = "" if not needs_kyc else "Needs KYC" + ready_for_test_payout = not needs_kyc + match = CLRMatch.objects.create( + round_number=clr_round, + amount=amount, + grant=grant, + comments=comments, + ready_for_test_payout=ready_for_test_payout, + ) + if needs_kyc: + grant_match_distribution_kyc(match) + + # Payout rankings (round must be finalized first) ------------------------------------------ + if what in ['prepare_final_payout']: + payout_matches = scheduled_matches.exclude(test_payout_tx='').filter(ready_for_payout=False) + payout_matches_amount = sum(sm.amount for sm in payout_matches) + print(f"there are {payout_matches.count()} UNPAID Match Payments already created worth ${round(payout_matches_amount,2)} {network} DAI") + print('------------------------------') + user_input = input("continue? (y/n) ") + if user_input != 'y': + return + for match in payout_matches: + match.ready_for_payout=True + match.save() + print('promoted') + + # Set payouts (round must be finalized first) ---------------------------------------------- + if what in ['set_payouts_test', 'set_payouts']: + is_real_payout = what == 'set_payouts' + + # Make sure that is_real_payout corresponds with the configured network + if network == 'rinkeby' and is_real_payout: + raise Exception(f'Network and what do not match: specified {network} and {what}') + elif network == 'mainnet' and not is_real_payout: + raise Exception(f'Network and what do not match: specified {network} and {what}') + + kwargs = {} + token_name = 'DAI' + key = 'ready_for_test_payout' if not is_real_payout else 'ready_for_payout' + kwargs[key] = False + not_ready_scheduled_matches = scheduled_matches.filter(**kwargs) + kwargs[key] = True + kwargs2 = {} + key2 = 'test_payout_tx' if not is_real_payout else 'payout_tx' + kwargs2[key2] = '' + unpaid_scheduled_matches = scheduled_matches.filter(**kwargs).filter(**kwargs2) + paid_scheduled_matches = scheduled_matches.filter(**kwargs).exclude(**kwargs2) + total_not_ready_matches = sum(sm.amount for sm in not_ready_scheduled_matches) + total_owed_matches = sum(sm.amount for sm in unpaid_scheduled_matches) + total_paid_matches = sum(sm.amount for sm in paid_scheduled_matches) + print(f"there are {not_ready_scheduled_matches.count()} NOT READY Match Payments already created worth ${round(total_not_ready_matches,2)} {network} {token_name}") + print(f"there are {unpaid_scheduled_matches.count()} UNPAID Match Payments already created worth ${round(total_owed_matches,2)} {network} {token_name}") + print(f"there are {paid_scheduled_matches.count()} PAID Match Payments already created worth ${round(total_paid_matches,2)} {network} {token_name}") + print('------------------------------') + user_input = input("continue? (y/n) ") + if user_input != 'y': + return + + print(f"continuing with {unpaid_scheduled_matches.count()} unpaid scheduled payouts") + + if is_real_payout: + user_input = input(F"THIS IS A REAL PAYOUT FOR {network} {token_name}. ARE YOU DOUBLE SECRET SUPER SURE? (y/n) ") + if user_input != 'y': + return + + # Generate dict of payout mapping that we'll use to set the contract's payout mapping + full_payouts_mapping_dict = {} + for match in unpaid_scheduled_matches.order_by('amount'): + # Amounts to set + recipient = w3.toChecksumAddress(match.grant.admin_address) + amount = Decimal(match.amount) * SCALE # convert to wei + + # This ensures that even when multiple grants have the same receiving address, + # all match funds are accounted for + if recipient in full_payouts_mapping_dict.keys(): + full_payouts_mapping_dict[recipient] += amount + else: + full_payouts_mapping_dict[recipient] = amount + + # Convert dict to array to use it as inputs to the contract + full_payouts_mapping = [] + for key, value in full_payouts_mapping_dict.items(): + full_payouts_mapping.append([key, str(int(value))]) + total_amount = sum(int(ele[1]) for ele in full_payouts_mapping) + + # In tests, it took 68,080 gas to set 2 payout values. Let's be super conservative + # and say it's 50k gas per payout mapping. If we are ok using 6M gas per transaction, + # that means we can set 6M / 50k = 120 payouts per transaction. So we chunk the + # payout mapping into sub-arrays with max length of 120 each + # KO 12/21 - edited with Matt to make 2.1x that + def chunks(lst, n): + """Yield successive n-sized chunks from lst. https://stackoverflow.com/a/312464""" + for i in range(0, len(lst), n): + yield lst[i:i + n] + chunk_size = 250 if not settings.DEBUG else 120 + chunked_payouts_mapping = chunks(full_payouts_mapping, chunk_size) + # Set payouts + from_address = settings.GRANTS_PAYOUT_ADDRESS + from_pk = settings.GRANTS_PAYOUT_PRIVATE_KEY + for payout_mapping in chunked_payouts_mapping: + + #tx = match_payouts.functions.setPayouts(payout_mapping).buildTransaction(tx_args) + + print(f"#TODO: Send this txn view etherscan {match_payouts_address}") + print(json.dumps(payout_mapping)) + + # Pause until the next one + print("SLEEPING") + time.sleep(WAIT_TIME_BETWEEN_TXS) + print("DONE SLEEPING") + + user_input = input("continue? (y/n) ") + if user_input != 'y': + return + + tx_id = input("enter a txid: ") + + # All payouts have been successfully set, so now we update the database + for match in unpaid_scheduled_matches.order_by('amount'): + # make save state to DB + if is_real_payout: + match.payout_tx = tx_id + match.payout_tx_date = timezone.now() + grant_match_distribution_final_txn(match) + else: + match.test_payout_tx = tx_id + match.test_payout_tx_date = timezone.now() + #grant_match_distribution_test_txn(match) + match.save() + + # create payout obj artifacts + profile = Profile.objects.get(handle__iexact='gitcoinbot') + validator_comment = f"created by ingest payout_round_script" + subscription = Subscription() + subscription.is_postive_vote = True + subscription.active = False + subscription.error = True + subscription.contributor_address = 'N/A' + subscription.amount_per_period = match.amount + subscription.real_period_seconds = 2592000 + subscription.frequency = 30 + subscription.frequency_unit = 'N/A' + subscription.token_address = dai_address + subscription.token_symbol = token_name + subscription.gas_price = 0 + subscription.new_approve_tx_id = '0x0' + subscription.num_tx_approved = 1 + subscription.network = network + subscription.contributor_profile = profile + subscription.grant = match.grant + subscription.comments = validator_comment + subscription.amount_per_period_usdt = match.amount if is_real_payout else 0 + subscription.save() + + contrib = Contribution.objects.create( + success=True, + tx_cleared=True, + tx_override=True, + tx_id=tx_id, + subscription=subscription, + validator_passed=True, + validator_comment=validator_comment, + ) + print(f"ingested {subscription.pk} / {contrib.pk}") + + if is_real_payout: + match.payout_contribution = contrib + else: + match.test_payout_contribution = contrib + match.save() + + metadata = { + 'id': subscription.id, + 'value_in_token': str(subscription.amount_per_period), + 'value_in_usdt_now': str(round(subscription.amount_per_period_usdt,2)), + 'token_name': subscription.token_symbol, + 'title': subscription.grant.title, + 'grant_url': subscription.grant.url, + 'num_tx_approved': subscription.num_tx_approved, + 'category': 'grant', + } + kwargs = { + 'profile': profile, + 'subscription': subscription, + 'grant': subscription.grant, + 'activity_type': 'new_grant_contribution', + 'metadata': metadata, + } + + activity = Activity.objects.create(**kwargs) + + if is_real_payout: + comment = f"CLR Round {clr_round} Payout" + comment = Comment.objects.create(profile=profile, activity=activity, comment=comment) + + # Verify contract is set properly ---------------------------------------------------------- + if what == 'verify': + # Get expected total match amount + total_owed_grants = 0 + for grant in grants: + try: + for gclr in grant.clr_calculations.filter(grantclr__in=gclrs, latest=True): + total_owed_grants += gclr.clr_prediction_curve[0][1] + except: + pass + expected_total_dai_amount = sum(sm.amount for sm in scheduled_matches) + + # Get PayoutAdded events + payout_added_filter = match_payouts.events.PayoutAdded.createFilter(fromBlock=from_block) + payout_added_logs = payout_added_filter.get_all_entries() # print these if you need to inspect them + + # Sort payout logs by ascending block number, this way if a recipient appears in multiple blocks + # we use the value from the latest block + sorted_payout_added_logs = sorted(payout_added_logs, key=lambda log:log['blockNumber'], reverse=False) + + # Get total required DAI balance based on PayoutAdded events. Events will be sorted chronologically, + # so if a recipient is duplicated we only keep the latest entry. We do this by storing our own + # mapping from recipients to match amount and overwriting it as needed just like the contract would. + # We keep another dict that maps the recipient's addresses to the block it was found in. If we find + # two entries for the same user in the same block, we throw, since we don't know which is the + # correct one + payment_dict = {} + user_block_dict = {} + + for log in sorted_payout_added_logs: + # Parse parameters from logs + recipient = log['args']['recipient'] + amount = Decimal(log['args']['amount']) + block = log['blockNumber'] + + # Check if recipient's payout has already been set in this block + if recipient in user_block_dict and user_block_dict[recipient] == block: + raise Exception(f'Recipient {recipient} payout was set twice in block {block}, so unclear which to use') + + # Recipient not seen in this block, so save data + payment_dict[recipient] = amount + user_block_dict[recipient] = block + + # Sum up each entry to get the total required amount + total_dai_required_wei = sum(payment_dict[recipient] for recipient in payment_dict.keys()) + + # Convert to human units + total_dai_required = total_dai_required_wei / SCALE + + # Verify that total DAI required (from event logs) equals the expected amount + if expected_total_dai_amount != total_dai_required: + print('\n* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * *') + print('Total DAI payout amount in the contract does not equal the expected value!') + print(' Total expected amount: ', expected_total_dai_amount) + print(' Total amount from logs: ', total_dai_required) + print('* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * *\n') + raise Exception('Total payout amount in the contract does not equal the expected value!') + print('Total payout amount in the contracts is the expected value') + + # Get contract DAI balance + dai_balance = Decimal(dai.functions.balanceOf(match_payouts_address).call()) / SCALE + + # Verify that contract has sufficient DAI balance to cover all payouts + print('\n* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * *') + if dai_balance == total_dai_required: + print(f'Contract balance of {dai_balance} DAI is exactly equal to the required amount') + + elif dai_balance < total_dai_required: + shortage = total_dai_required - dai_balance + print('Contract DAI balance is insufficient') + print(' Required balance: ', total_dai_required) + print(' Current balance: ', dai_balance) + print(' Extra DAI needed: ', shortage) + print(f'\n Contract needs another {shortage} DAI') + + elif dai_balance > total_dai_required: + excess = dai_balance - total_dai_required + print('Contract has excess DAI balance') + print(' Required balance: ', total_dai_required) + print(' Current balance: ', dai_balance) + print(' Excess DAI amount: ', excess) + print(f'\n Contract has an excess of {excess} DAI') + print('* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * *\n') diff --git a/app/grants/templates/grants/detail/template-grant-details.html b/app/grants/templates/grants/detail/template-grant-details.html index 9b35a3979b7..4259db81e1a 100644 --- a/app/grants/templates/grants/detail/template-grant-details.html +++ b/app/grants/templates/grants/detail/template-grant-details.html @@ -246,6 +246,17 @@
TEAM
Flag + {% if should_show_claim_match_button %} +
+
+ +
+ Match payouts are ready to be claimed! Use the button above to send the earned match amount to this grant. +
+
+ {% endif %}
diff --git a/app/grants/views.py b/app/grants/views.py index 066e057a87b..c89d9c190d4 100644 --- a/app/grants/views.py +++ b/app/grants/views.py @@ -1352,6 +1352,23 @@ def grant_details(request, grant_id, grant_slug): if is_clr_active: title = '💰 ' + title + # If the user viewing the page is team member or admin, check if grant has match funds available + # to withdraw + is_match_available_to_claim = False + if is_team_member or is_admin: + w3 = get_web3(grant.network) + match_payouts_abi = settings.MATCH_PAYOUTS_ABI + match_payouts_address = settings.MATCH_PAYOUTS_ADDRESS + match_payouts = w3.eth.contract(address=match_payouts_address, abi=match_payouts_abi) + amount_available = match_payouts.functions.payouts(grant.admin_address).call() + is_match_available_to_claim = True if amount_available > 0 else False + + # Check if this grant needs to complete KYC before claiming match funds + is_blocked_by_kyc = hasattr(grant, 'clrmatches') and grant.clrmatches.filter(round=8).has_passed_kyc + + # Determine if we should show the claim match button on the grant details page + should_show_claim_match_button = (is_team_member or is_admin) and is_match_available_to_claim and not is_blocked_by_kyc + params = { 'active': 'grant_details', 'grant': grant, @@ -1381,6 +1398,7 @@ def grant_details(request, grant_id, grant_slug): 'user_code': get_user_code(request.user.profile.id, grant, emoji_codes) if request.user.is_authenticated else '', 'verification_tweet': get_grant_verification_text(grant), # 'tenants': grant.tenants, + 'should_show_claim_match_button': should_show_claim_match_button } # Stats if tab == 'stats':