diff --git a/app/economy/tx.py b/app/economy/tx.py index ad38f4d7663..beb5c68bd39 100644 --- a/app/economy/tx.py +++ b/app/economy/tx.py @@ -11,6 +11,7 @@ from bs4 import BeautifulSoup from dashboard.abi import erc20_abi from dashboard.utils import get_tx_status, get_web3 +from economy.models import Token from hexbytes import HexBytes from web3 import HTTPProvider, Web3 from web3.exceptions import BadFunctionCallOutput @@ -50,6 +51,9 @@ class TransactionNotFound(Exception): check_event_transfer = lambda contract_address, search, txid : w3.eth.filter({ "address": contract_address, "topics": [search, txid]}) get_decimals = lambda contract : int(contract.functions.decimals().call()) +# BulkCheckout parameters +bulk_checkout_address = "0x7d655c57f71464B6f83811C55D84009Cd9f5221C" # same address on mainnet and rinkeby +bulk_checkout_abi = '[{"anonymous":false,"inputs":[{"indexed":true,"internalType":"address","name":"token","type":"address"},{"indexed":true,"internalType":"uint256","name":"amount","type":"uint256"},{"indexed":false,"internalType":"address","name":"dest","type":"address"},{"indexed":true,"internalType":"address","name":"donor","type":"address"}],"name":"DonationSent","type":"event"},{"anonymous":false,"inputs":[{"indexed":true,"internalType":"address","name":"previousOwner","type":"address"},{"indexed":true,"internalType":"address","name":"newOwner","type":"address"}],"name":"OwnershipTransferred","type":"event"},{"anonymous":false,"inputs":[{"indexed":false,"internalType":"address","name":"account","type":"address"}],"name":"Paused","type":"event"},{"anonymous":false,"inputs":[{"indexed":true,"internalType":"address","name":"token","type":"address"},{"indexed":true,"internalType":"uint256","name":"amount","type":"uint256"},{"indexed":true,"internalType":"address","name":"dest","type":"address"}],"name":"TokenWithdrawn","type":"event"},{"anonymous":false,"inputs":[{"indexed":false,"internalType":"address","name":"account","type":"address"}],"name":"Unpaused","type":"event"},{"inputs":[{"components":[{"internalType":"address","name":"token","type":"address"},{"internalType":"uint256","name":"amount","type":"uint256"},{"internalType":"address payable","name":"dest","type":"address"}],"internalType":"struct BulkCheckout.Donation[]","name":"_donations","type":"tuple[]"}],"name":"donate","outputs":[],"stateMutability":"payable","type":"function"},{"inputs":[],"name":"owner","outputs":[{"internalType":"address","name":"","type":"address"}],"stateMutability":"view","type":"function"},{"inputs":[],"name":"pause","outputs":[],"stateMutability":"nonpayable","type":"function"},{"inputs":[],"name":"paused","outputs":[{"internalType":"bool","name":"","type":"bool"}],"stateMutability":"view","type":"function"},{"inputs":[],"name":"renounceOwnership","outputs":[],"stateMutability":"nonpayable","type":"function"},{"inputs":[{"internalType":"address","name":"newOwner","type":"address"}],"name":"transferOwnership","outputs":[],"stateMutability":"nonpayable","type":"function"},{"inputs":[],"name":"unpause","outputs":[],"stateMutability":"nonpayable","type":"function"},{"inputs":[{"internalType":"address payable","name":"_dest","type":"address"}],"name":"withdrawEther","outputs":[],"stateMutability":"nonpayable","type":"function"},{"inputs":[{"internalType":"address","name":"_tokenAddress","type":"address"},{"internalType":"address","name":"_dest","type":"address"}],"name":"withdrawToken","outputs":[],"stateMutability":"nonpayable","type":"function"}]' def getReplacedTX(tx): from economy.models import TXUpdate @@ -96,6 +100,185 @@ def check_transaction_contract(transaction_tax): return transaction_status(transaction, transaction_tax) +def get_token(token_symbol, network): + """ + For a given token symbol and amount, returns the token's details. For ETH, we change the + token address to 0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE since that's the address + BulkCheckout uses to represent ETH (default here is the zero address) + """ + token = Token.objects.filter(network=network, symbol=token_symbol, approved=True).first().to_dict + if token_symbol == 'ETH': + token['addr'] = '0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE' + return token + +def parse_token_amount(token_symbol, amount, network): + """ + For a given token symbol and amount, returns the integer version in "wei", i.e. the integer + form based on the token's number of decimals + """ + token = get_token(token_symbol, network) + decimals = token['decimals'] + parsed_amount = int(amount * 10 ** decimals) + return parsed_amount + +def check_for_replaced_tx(tx_hash, network): + """ + Get status of the provided transaction hash, and look for a replacement transaction hash. If a + replacement exists, return the status and hash of the new transaction + """ + status, timestamp = get_tx_status(tx_hash, network, timezone.now()) + if status in ['pending', 'dropped', 'unknown', '']: + new_tx = getReplacedTX(tx_hash) + if new_tx: + tx_hash = new_tx + status, timestamp = get_tx_status(tx_hash, network, timezone.now()) + + return tx_hash, status, timestamp + +def is_bulk_checkout_tx(receipt): + """ + Returns true if the to address of the recipient is the bulk checkout contract + """ + to_address = receipt['to'].lower() + is_bulk_checkout = to_address == bulk_checkout_address.lower() + return is_bulk_checkout + +def grants_transaction_validator_v2(contribution, w3): + """ + This function is used to validate contributions sent on L1 through the BulkCheckout contract. + This contract can be found here: + - On GitHub: https://github.com/gitcoinco/BulkTransactions/blob/master/contracts/BulkCheckout.sol + - On mainnet: https://rinkeby.etherscan.io/address/0x7d655c57f71464b6f83811c55d84009cd9f5221c#code + + To facilitate testing on Rinkeby, we pass in a web3 instance instead of using the mainnet + instance defined at the top of this file + """ + + # Get bulk checkout contract instance + bulk_checkout_contract = w3.eth.contract(address=bulk_checkout_address, abi=bulk_checkout_abi) + + # Get specific info about this contribution that we use later + tx_hash = contribution.split_tx_id + network = contribution.subscription.network + + # Response that calling function uses to set fields on Contribution + response = { + # Set passed to True if matching transfer is found for this contribution. The comment + # field is used to provide details when false + 'validation': { + 'passed': False, + 'comment': 'Default' + }, + # Array of addresses where funds were intially sourced from. This is used to detect someone + # funding many addresses from a single address. This functionality is currently not + # implemented in grants_transaction_validator_v2 so for now we assume the originator is + # msg.sender + 'originator': [ '' ], + # Once tx_cleared is true, the validator is not run again for this contribution + 'tx_cleared': False, + # True if the checkout transaction was mined + 'split_tx_confirmed': False + } + + # Return if tx_hash is not valid + if not tx_hash or len(tx_hash) != 66: + # Set to true so this doesn't run again, since there's no transaction hash to check + response['tx_cleared'] = True + response['validation']['comment'] = 'Invalid transaction hash in split_tx_id' + return response + + # Check for dropped and replaced txn + tx_hash, status, timestamp = check_for_replaced_tx(tx_hash, network) + + # If transaction was successful, continue to validate it + if status == 'success': + # Transaction was successful so we know it cleared + response['tx_cleared'] = True + response['split_tx_confirmed'] = True + + # Get the receipt to parse parameters + receipt = w3.eth.getTransactionReceipt(tx_hash) + + # Validator currently assumes msg.sender == originator + response['originator'] = [ receipt['from'] ] + + # Return if recipient is not the BulkCheckout contract + is_bulk_checkout = is_bulk_checkout_tx(receipt) + if not is_bulk_checkout: + to_address = receipt['to'] + response['validation']['comment'] = f'This function only validates transactions through the BulkCheckout contract, but this transaction was sent to {to_address}' + return response + + # Parse receipt logs to look for expected transfer info. We don't need to look at any other + # receipt parameters because all contributions are emitted as an event + receipt = w3.eth.getTransactionReceipt(tx_hash) + parsed_logs = bulk_checkout_contract.events.DonationSent().processReceipt(receipt) + + # Return if no donation logs were found + if (len(parsed_logs) == 0): + response['validation']['comment'] = 'No DonationSent events found in this BulkCheckout transaction' + return response + + # Parse out the transfer details we are looking to find in the event logs + token_symbol = contribution.normalized_data['token_symbol'] + expected_recipient = contribution.normalized_data['admin_address'].lower() + expected_token = get_token(token_symbol, network)['addr'].lower() # we compare by token address + expected_amount = parse_token_amount( + token_symbol=token_symbol, + amount=contribution.subscription.amount_per_period_minus_gas_price, + network=network + ) + transfer_tolerance = 0.05 # use a 5% tolerance when checking amounts to account for floating point error + expected_amount_min = int(expected_amount * (1 - transfer_tolerance)) + expected_amount_max = int(expected_amount * (1 + transfer_tolerance)) + + # Loop through each event to find one that matches + for event in parsed_logs: + is_correct_recipient = event['args']['dest'].lower() == expected_recipient + is_correct_token = event['args']['token'].lower() == expected_token + + transfer_amount = event['args']['amount'] + is_correct_amount = transfer_amount > expected_amount_min and transfer_amount < expected_amount_max + + if is_correct_recipient and is_correct_token and is_correct_amount: + # We found the event log corresponding to the contribution parameters + response['validation']['passed'] = True + response['validation']['comment'] = 'BulkCheckout. Success' + return response + + # Transaction was successful, but the expected contribution was not included in the transaction + response['validation']['comment'] = 'DonationSent event with expected recipient, amount, and token was not found in transaction logs' + return response + + # If we get here, none of the above failure conditions have been met, so we try to find + # more information about why it failed + if status == 'pending': + response['validation']['comment'] = 'Transaction is still pending' + return response + + try: + # Get receipt and set originator to msg.sender + receipt = w3.eth.getTransactionReceipt(tx_hash) + response['originator'] = [ receipt['from'] ] + + if receipt.status == 0: + # Transaction was minded, but + response['tx_cleared'] = True + response['split_tx_confirmed'] = True + response['validation']['comment'] = 'Transaction failed. See Etherscan for more details' + return response + + # If here, transaction was successful. This code block should never execute, but it means + # the transaction was successful but for some reason not parsed above + raise Exception('Unknown transaction validation flow 1') + + except w3.exceptions.TransactionNotFound: + response['validation']['comment'] = 'Transaction receipt not found. Transaction may still be pending or was dropped' + return response + + raise Exception('Unknown transaction validation flow 2') + + def grants_transaction_validator(contribution, w3): # To facilitate testing on Rinkeby, we pass in a web3 instance instead of using the mainnet # instance defined at the top of this file @@ -292,10 +475,14 @@ def get_token_originators(to_address, token, from_address='', return_what='trans if from_address: url += '&filter[from]=' + from_address + # OLD: THIS REQUEST THROWS WITH A 500 INTERNAL SERVER ERROR transfers = requests.get( url, headers=headers ).json() + + # NEW: PARSE EVENT LOGS TO SEE WHAT'S GOING ON + if transfers.get('message') == 'API rate limit exceeded. Please upgrade your account.': raise Exception("RATE LIMIT EXCEEDED") # TODO - pull more than one page in case there are many transfers. diff --git a/app/grants/models.py b/app/grants/models.py index a8a6a1eb11f..751116d3676 100644 --- a/app/grants/models.py +++ b/app/grants/models.py @@ -1305,7 +1305,7 @@ def identity_identifier(self, mechanism): def update_tx_status(self): """Updates tx status.""" try: - from economy.tx import grants_transaction_validator + from economy.tx import grants_transaction_validator_v2 from dashboard.utils import get_tx_status from economy.tx import getReplacedTX if self.tx_override: @@ -1381,13 +1381,13 @@ def update_tx_status(self): if case_number == 1: # actually validate token transfers try: - response = grants_transaction_validator(self, w3) + response = grants_transaction_validator_v2(self, w3) if len(response['originator']): self.originated_address = response['originator'][0] self.validator_passed = response['validation']['passed'] self.validator_comment = response['validation']['comment'] - self.tx_cleared = True - self.split_tx_confirmed = True + self.tx_cleared = response['tx_cleared'] + self.split_tx_confirmed = response['split_tx_confirmed'] self.success = self.validator_passed except Exception as e: if 'Expecting value' in str(e): @@ -1445,6 +1445,9 @@ def update_tx_status(self): expected_transfer_amount = Decimal( self.subscription.amount_per_period_minus_gas_price * 10 ** decimals ) + transfer_tolerance = 0.05 # use a 5% tolerance + expected_amount_min = expected_transfer_amount * (Decimal(1 - transfer_tolerance)) + expected_amount_max = expected_transfer_amount * (Decimal(1 + transfer_tolerance)) # Look through zkSync transfers to find one with the expected amounts is_correct_recipient = False @@ -1468,14 +1471,12 @@ def update_tx_status(self): is_correct_token = transaction['tx']['token'] == expected_token transfer_amount = Decimal(transaction['tx']['amount']) - transfer_tolerance = 0.05 # use a 5% tolerance - transfer_amount_min = transfer_amount * (Decimal(1 - transfer_tolerance)) - transfer_amount_max = transfer_amount * (Decimal(1 + transfer_tolerance)) - is_correct_amount = transfer_amount > transfer_amount_min and transfer_amount < transfer_amount_max + is_correct_amount = transfer_amount > expected_amount_min and transfer_amount < expected_amount_max if is_correct_recipient and is_correct_token and is_correct_amount: self.tx_cleared = True self.success = transaction['success'] + self.validator_comment = f"{self.validator_comment}. Success" break if not is_correct_recipient or not is_correct_token or not is_correct_amount: