Skip to content

Commit

Permalink
Start tx validator v2, and fix bug in zkSync validations (#7510)
Browse files Browse the repository at this point in the history
* Start tx validator v2, and fix bug in zkSync validations

* Remove leftover print statements

Co-authored-by: Aditya Anand M C <[email protected]>
  • Loading branch information
mds1 and thelostone-mc authored Sep 24, 2020
1 parent 4ab746e commit d8a1346
Show file tree
Hide file tree
Showing 2 changed files with 196 additions and 8 deletions.
187 changes: 187 additions & 0 deletions app/economy/tx.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down
17 changes: 9 additions & 8 deletions app/grants/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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):
Expand Down Expand Up @@ -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
Expand All @@ -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:
Expand Down

0 comments on commit d8a1346

Please sign in to comment.