Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Start tx validator v2, and fix bug in zkSync validations #7510

Merged
merged 3 commits into from
Sep 24, 2020
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
195 changes: 195 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"}]'

# scrapes etherscan to get the replaced tx
def getReplacedTX(tx):
Expand Down Expand Up @@ -102,6 +106,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 @@ -147,14 +330,17 @@ def grants_transaction_validator(contribution, w3):
transaction = w3.eth.getTransaction(transaction_hash)
if transaction.value > 0.001:
recipient_address = Web3.toChecksumAddress(contribution.subscription.grant.admin_address)
print(1111)
transfer = get_token_originators(recipient_address, '0x0', from_address=from_address, return_what='transfers', tx_id=tx, amounts=amounts)
if not transfer:
print(2222)
transfer = get_token_originators(recipient_address, '0x0', from_address=from_address, return_what='transfers', tx_id=tx)
if transfer:
token_transfer = transfer
maybeprint(148, round(time.time(),2))
if not token_originators:

print(3333)
token_originators = get_token_originators(from_address, '0x0', from_address=None, return_what='originators')

maybeprint(150, round(time.time(),2))
Expand All @@ -170,11 +356,13 @@ def grants_transaction_validator(contribution, w3):
maybeprint(160, round(time.time(),2))
# get token transfers
if not token_transfer:
print(4444)
transfers = get_token_originators(recipient_address, token_address, from_address=from_address, return_what='transfers', tx_id=tx, amounts=amounts)
if transfers:
token_transfer = transfers
maybeprint(169, round(time.time(),2))
if not token_originators:
print(5555)
token_originators = get_token_originators(from_address, token_address, from_address=None, return_what='originators')
maybeprint(170, round(time.time(),2))

Expand Down Expand Up @@ -294,19 +482,26 @@ def get_token_originators(to_address, token, from_address='', return_what='trans
endpoint = 'token-transfers' if token != '0x0' else 'ether-transfers'
url = f'https://api.aleth.io/v1/{endpoint}?filter[to]=' + address + '&filter[token]=' + token + '&page%5Blimit%5D=100'
if token == '0x0':
print('qqqq')
url = f'https://api.aleth.io/v1/{endpoint}?filter[account]=' + address + '&page%5Blimit%5D=100'
if from_address:
print('wwww')
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

^ do we wanna remove all the print stmt :P

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oops, yea those were leftover from my expert debugging techniques. Removed in f61690e

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

do we need the prints?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oops, yea those were leftover from my expert debugging techniques. Removed in f61690e

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.

if return_what == 'transfers':
print(1)
for transfer in transfers.get('data', {}):
this_is_the_one = tx_id and tx_id.lower() in str(transfer).lower()
_decimals = transfer.get('attributes', {}).get('decimals', 18)
Expand Down
17 changes: 9 additions & 8 deletions app/grants/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -1247,7 +1247,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 @@ -1323,13 +1323,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 @@ -1387,6 +1387,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 @@ -1410,14 +1413,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