diff --git a/backend/.env.example b/backend/.env.example index 2c0ef4a8..cde71398 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -5,4 +5,7 @@ SITE_URL="https://grant.io" # No trailing slash DATABASE_URL="sqlite:////tmp/dev.db" REDISTOGO_URL="redis://localhost:6379" SECRET_KEY="not-so-secret" -SENDGRID_API_KEY="optional, but emails won't send without it" \ No newline at end of file +SENDGRID_API_KEY="optional, but emails won't send without it" +# for ropsten use the following +# ETHEREUM_ENDPOINT_URI = "https://ropsten.infura.io/API_KEY" +ETHEREUM_ENDPOINT_URI = "http://localhost:8545" \ No newline at end of file diff --git a/backend/grant/app.py b/backend/grant/app.py index cf17fdd3..18916a25 100644 --- a/backend/grant/app.py +++ b/backend/grant/app.py @@ -4,7 +4,7 @@ from flask import Flask from flask_cors import CORS from grant import commands, proposal, user, comment, milestone, admin, email -from grant.extensions import bcrypt, migrate, db, ma, mail +from grant.extensions import bcrypt, migrate, db, ma, mail, web3 def create_app(config_object="grant.settings"): @@ -25,6 +25,7 @@ def register_extensions(app): migrate.init_app(app, db) ma.init_app(app) mail.init_app(app) + web3.init_app(app) CORS(app) return None diff --git a/backend/grant/extensions.py b/backend/grant/extensions.py index ba89d3fe..2dc1f7e9 100644 --- a/backend/grant/extensions.py +++ b/backend/grant/extensions.py @@ -5,9 +5,11 @@ from flask_marshmallow import Marshmallow from flask_migrate import Migrate from flask_sqlalchemy import SQLAlchemy from flask_sendgrid import SendGrid +from flask_web3 import FlaskWeb3 bcrypt = Bcrypt() db = SQLAlchemy() migrate = Migrate() ma = Marshmallow() mail = SendGrid() +web3 = FlaskWeb3() diff --git a/backend/grant/settings.py b/backend/grant/settings.py index c9f64137..a818349f 100644 --- a/backend/grant/settings.py +++ b/backend/grant/settings.py @@ -24,4 +24,6 @@ DEBUG_TB_INTERCEPT_REDIRECTS = False CACHE_TYPE = "simple" # Can be "memcached", "redis", etc. SQLALCHEMY_TRACK_MODIFICATIONS = False SENDGRID_API_KEY = env.str("SENDGRID_API_KEY", default="") -SENDGRID_DEFAULT_FROM = "noreply@grant.io" \ No newline at end of file +SENDGRID_DEFAULT_FROM = "noreply@grant.io" +ETHEREUM_PROVIDER = "http" +ETHEREUM_ENDPOINT_URI = env.str("ETHEREUM_ENDPOINT_URI") diff --git a/backend/grant/web3/__init__.py b/backend/grant/web3/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/grant/web3/proposal.py b/backend/grant/web3/proposal.py new file mode 100644 index 00000000..5102a40a --- /dev/null +++ b/backend/grant/web3/proposal.py @@ -0,0 +1,124 @@ +import json +import time +from flask_web3 import current_web3 +from .util import batch_call, call_array + + +crowd_fund_abi = None + + +def get_crowd_fund_abi(): + global crowd_fund_abi + if crowd_fund_abi: + return crowd_fund_abi + with open("../contract/build/contracts/CrowdFund.json", "r") as read_file: + crowd_fund_abi = json.load(read_file)['abi'] + return crowd_fund_abi + + +def read_proposal(address): + crowd_fund_abi = get_crowd_fund_abi() + contract = current_web3.eth.contract(address=address, abi=crowd_fund_abi) + + crowd_fund = {} + methods = [ + "immediateFirstMilestonePayout", + "raiseGoal", + "amountVotingForRefund", + "beneficiary", + "deadline", + "milestoneVotingPeriod", + "frozen", + "isRaiseGoalReached", + ] + + # batched + calls = list(map(lambda x: [x, None], methods)) + crowd_fund = batch_call(current_web3, address, crowd_fund_abi, calls, contract) + + # balance (sync) + crowd_fund['balance'] = current_web3.eth.getBalance(address) + + # arrays (sync) + crowd_fund['milestones'] = call_array(contract.functions.milestones) + crowd_fund['trustees'] = call_array(contract.functions.trustees) + contributor_list = call_array(contract.functions.contributorList) + + # make milestones + def make_ms(enum_ms): + index = enum_ms[0] + ms = enum_ms[1] + is_immediate = index == 0 and crowd_fund['immediateFirstMilestonePayout'] + deadline = ms[2] * 1000 + amount_against = ms[1] + pct_against = round(amount_against * 100 / crowd_fund['raiseGoal']) + paid = ms[3] + state = 'WAITING' + if crowd_fund["isRaiseGoalReached"] and deadline > 0: + if paid: + state = 'PAID' + elif deadline > time.time() * 1000: + state = 'ACTIVE' + elif pct_against >= 50: + state = 'REJECTED' + else: + state = 'PAID' + return { + "index": index, + "state": state, + "amount": str(ms[0]), + "amountAgainstPayout": str(amount_against), + "percentAgainstPayout": pct_against, + "payoutRequestVoteDeadline": deadline, + "isPaid": paid, + "isImmediatePayout": is_immediate + } + + crowd_fund['milestones'] = list(map(make_ms, enumerate(crowd_fund['milestones']))) + + # contributor calls (batched) + contributors_calls = list(map(lambda c_addr: ['contributors', (c_addr,)], contributor_list)) + contrib_votes_calls = [] + for c_addr in contributor_list: + for msi in range(len(crowd_fund['milestones'])): + contrib_votes_calls.append(['getContributorMilestoneVote', (c_addr, msi)]) + derived_calls = contributors_calls + contrib_votes_calls + derived_results = batch_call(current_web3, address, crowd_fund_abi, derived_calls, contract) + + # make contributors + contributors = [] + for contrib_address in contributor_list: + contrib_raw = derived_results['contributors' + contrib_address] + + def get_no_vote(i): + return derived_results['getContributorMilestoneVote' + contrib_address + str(i)] + no_votes = list(map(get_no_vote, range(len(crowd_fund['milestones'])))) + + contrib = { + "address": contrib_address, + "contributionAmount": str(contrib_raw[0]), + "refundVote": contrib_raw[1], + "refunded": contrib_raw[2], + "milestoneNoVotes": no_votes, + } + contributors.append(contrib) + crowd_fund['contributors'] = contributors + + # massage names and numbers + crowd_fund['target'] = crowd_fund.pop('raiseGoal') + crowd_fund['isFrozen'] = crowd_fund.pop('frozen') + crowd_fund['deadline'] = crowd_fund['deadline'] * 1000 + crowd_fund['milestoneVotingPeriod'] = crowd_fund['milestoneVotingPeriod'] * 60 * 1000 + if crowd_fund['isRaiseGoalReached']: + crowd_fund['funded'] = crowd_fund['target'] + crowd_fund['percentFunded'] = 100 + else: + crowd_fund['funded'] = crowd_fund['balance'] + crowd_fund['percentFunded'] = round(crowd_fund['balance'] * 100 / crowd_fund['target']) + crowd_fund['percentVotingForRefund'] = round(crowd_fund['amountVotingForRefund'] * 100 / crowd_fund['target']) + + bn_keys = ['amountVotingForRefund', 'balance', 'funded', 'target'] + for k in bn_keys: + crowd_fund[k] = str(crowd_fund[k]) + + return crowd_fund diff --git a/backend/grant/web3/util.py b/backend/grant/web3/util.py new file mode 100644 index 00000000..1844f8d3 --- /dev/null +++ b/backend/grant/web3/util.py @@ -0,0 +1,77 @@ +import requests +from web3.providers.base import JSONBaseProvider +from web3.utils.contracts import prepare_transaction, find_matching_fn_abi +from web3 import EthereumTesterProvider +from grant.settings import ETHEREUM_ENDPOINT_URI +from hexbytes import HexBytes +from eth_abi import decode_abi +from web3.utils.abi import get_abi_output_types, map_abi_data +from web3.utils.normalizers import BASE_RETURN_NORMALIZERS + + +def call_array(fn): + results = [] + no_error = True + index = 0 + while no_error: + try: + results.append(fn(index).call()) + index += 1 + except Exception: + no_error = False + return results + + +def make_key(method, args): + return method + "".join(list(map(lambda z: str(z), args))) if args else method + + +def tester_batch(calls, contract): + # fallback to sync calls for eth-tester instead of implementing batching + results = {} + for call in calls: + method, args = call + args = args if args else () + results[make_key(method, args)] = contract.functions[method](*args).call() + return results + + +def batch(node_address, params): + base_provider = JSONBaseProvider() + request_data = b'[' + b','.join( + [base_provider.encode_rpc_request('eth_call', p) for p in params] + ) + b']' + r = requests.post(node_address, data=request_data, headers={'Content-Type': 'application/json'}) + responses = base_provider.decode_rpc_response(r.content) + return responses + + +def batch_call(w3, address, abi, calls, contract): + # TODO: use web3py batching once its added + # this implements batched rpc calls using web3py helper methods + # web3py doesn't support this out-of-box yet + # issue: https://github.com/ethereum/web3.py/issues/832 + if type(w3.providers[0]) is EthereumTesterProvider: + return tester_batch(calls, contract) + inputs = [] + for c in calls: + name, args = c + tx = {"from": w3.eth.defaultAccount, "to": address} + prepared = prepare_transaction(address, w3, name, abi, None, tx, args) + inputs.append([prepared, 'latest']) + responses = batch(ETHEREUM_ENDPOINT_URI, inputs) + results = {} + for r in zip(calls, responses): + result = HexBytes(r[1]['result']) + fn_id, args = r[0] + fn_abi = find_matching_fn_abi(abi, fn_id, args) + output_types = get_abi_output_types(fn_abi) + output_data = decode_abi(output_types, result) + normalized_data = map_abi_data(BASE_RETURN_NORMALIZERS, output_types, output_data) + key = make_key(fn_id, args) + if len(normalized_data) == 1: + results[key] = normalized_data[0] + else: + results[key] = normalized_data + + return results diff --git a/backend/requirements/dev.txt b/backend/requirements/dev.txt index 6c3da7e4..533f62c8 100644 --- a/backend/requirements/dev.txt +++ b/backend/requirements/dev.txt @@ -5,6 +5,7 @@ pytest==3.7.1 WebTest==2.0.30 factory-boy==2.11.1 +eth-tester[py-evm]==0.1.0b33 # Lint and code style flake8==3.5.0 diff --git a/backend/requirements/prod.txt b/backend/requirements/prod.txt index 428ccba8..140120bd 100644 --- a/backend/requirements/prod.txt +++ b/backend/requirements/prod.txt @@ -55,4 +55,9 @@ flask-sendgrid==0.6 sendgrid==5.3.0 # input validation -flask-yolo2API==0.2.6 \ No newline at end of file +flask-yolo2API==0.2.6 + +#web3 +flask-web3==0.1.1 +web3==4.8.1 + diff --git a/backend/tests/settings.py b/backend/tests/settings.py index 0a298ac4..0e2ee7e3 100644 --- a/backend/tests/settings.py +++ b/backend/tests/settings.py @@ -8,3 +8,5 @@ DEBUG_TB_ENABLED = False CACHE_TYPE = 'simple' # Can be "memcached", "redis", etc. SQLALCHEMY_TRACK_MODIFICATIONS = False WTF_CSRF_ENABLED = False # Allows form testing +ETHEREUM_PROVIDER = "test" +ETHEREUM_ENDPOINT_URI = "" diff --git a/backend/tests/web3/__init__.py b/backend/tests/web3/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/tests/web3/test_proposal_read.py b/backend/tests/web3/test_proposal_read.py new file mode 100644 index 00000000..22528221 --- /dev/null +++ b/backend/tests/web3/test_proposal_read.py @@ -0,0 +1,132 @@ +import copy +import json +import time + +from grant.extensions import web3 +from ..config import BaseTestConfig +from grant.web3.proposal import read_proposal +from flask_web3 import current_web3 +import eth_tester.backends.pyevm.main as py_evm_main + +# increase gas limit on eth-tester +# https://github.com/ethereum/web3.py/issues/1013 +# https://gitter.im/ethereum/py-evm?at=5b7eb68c4be56c5918854337 +py_evm_main.GENESIS_GAS_LIMIT = 10000000 + + +class TestWeb3ProposalRead(BaseTestConfig): + def create_app(self): + self.real_app = BaseTestConfig.create_app(self) + return self.real_app + + def setUp(self): + BaseTestConfig.setUp(self) + # the following will properly configure web3 with test config + web3.init_app(self.real_app) + with open("../contract/build/contracts/CrowdFundFactory.json", "r") as read_file: + crowd_fund_factory_json = json.load(read_file) + with open("../contract/build/contracts/CrowdFund.json", "r") as read_file: + self.crowd_fund_json = json.load(read_file) + current_web3.eth.defaultAccount = current_web3.eth.accounts[0] + CrowdFundFactory = current_web3.eth.contract( + abi=crowd_fund_factory_json['abi'], bytecode=crowd_fund_factory_json['bytecode']) + tx_hash = CrowdFundFactory.constructor().transact() + tx_receipt = current_web3.eth.waitForTransactionReceipt(tx_hash) + self.crowd_fund_factory = current_web3.eth.contract( + address=tx_receipt.contractAddress, + abi=crowd_fund_factory_json['abi'] + ) + + def get_mock_proposal_read(self, contributors=[]): + mock_proposal_read = { + "immediateFirstMilestonePayout": True, + "amountVotingForRefund": "0", + "beneficiary": current_web3.eth.accounts[0], + # "deadline": 1541706179000, + "milestoneVotingPeriod": 3600000, + "isRaiseGoalReached": False, + "balance": "0", + "milestones": [ + { + "index": 0, + "state": "WAITING", + "amount": "5000000000000000000", + "amountAgainstPayout": "0", + "percentAgainstPayout": 0, + "payoutRequestVoteDeadline": 0, + "isPaid": False, + "isImmediatePayout": True + } + ], + "trustees": [ + current_web3.eth.accounts[0] + ], + "contributors": [], + "target": "5000000000000000000", + "isFrozen": False, + "funded": "0", + "percentFunded": 0, + "percentVotingForRefund": 0 + } + for c in contributors: + mock_proposal_read['contributors'].append({ + "address": current_web3.eth.accounts[c[0]], + "contributionAmount": str(c[1] * 1000000000000000000), + "refundVote": False, + "refunded": False, + "milestoneNoVotes": [ + False + ] + }) + return mock_proposal_read + + def create_crowd_fund(self): + tx_hash = self.crowd_fund_factory.functions.createCrowdFund( + 5000000000000000000, # ethAmount + current_web3.eth.accounts[0], # payout + [current_web3.eth.accounts[0]], # trustees + [5000000000000000000], # milestone amounts + 60, # duration (minutes) + 60, # voting period (minutes) + True # immediate first milestone payout + ).transact() + tx_receipt = current_web3.eth.waitForTransactionReceipt(tx_hash) + tx_events = self.crowd_fund_factory.events.ContractCreated().processReceipt(tx_receipt) + contract_address = tx_events[0]['args']['newAddress'] + return contract_address + + def fund_crowd_fund(self, address): + contract = current_web3.eth.contract(address=address, abi=self.crowd_fund_json['abi']) + accts = current_web3.eth.accounts + for c in [[5, 1], [6, 1], [7, 3]]: + tx_hash = contract.functions.contribute().transact({ + "from": accts[c[0]], + "value": c[1] * 1000000000000000000 + }) + current_web3.eth.waitForTransactionReceipt(tx_hash) + + def test_proposal_read_new(self): + contract_address = self.create_crowd_fund() + proposal_read = read_proposal(contract_address) + deadline = proposal_read.pop('deadline') + deadline_diff = deadline - time.time() * 1000 + self.assertGreater(60000, deadline_diff) + self.assertGreater(deadline_diff, 58000) + self.maxDiff = None + self.assertEqual(proposal_read, self.get_mock_proposal_read()) + + def test_proposal_funded(self): + contract_address = self.create_crowd_fund() + self.fund_crowd_fund(contract_address) + proposal_read = read_proposal(contract_address) + expected = self.get_mock_proposal_read([[5, 1], [6, 1], [7, 3]]) + expected['funded'] = expected['target'] + expected['balance'] = expected['target'] + expected['isRaiseGoalReached'] = True + expected['percentFunded'] = 100 + deadline = proposal_read.pop('deadline') + deadline_diff = deadline - time.time() * 1000 + self.assertGreater(60000, deadline_diff) + self.assertGreater(deadline_diff, 50000) + self.maxDiff = None + self.assertEqual(proposal_read, expected)