Backend Proposal Reads Pt. 1 (#198)

* web3 flask + read proposal

* tests

* use build/contracts indtead of build/abi

* fail if endpoint not set

* batched calls
This commit is contained in:
AMStrix 2018-11-14 08:30:18 -06:00 committed by Daniel Ternyak
parent 8c2e43c51b
commit 03de8c2543
12 changed files with 353 additions and 4 deletions

View File

@ -6,3 +6,6 @@ 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"
# for ropsten use the following
# ETHEREUM_ENDPOINT_URI = "https://ropsten.infura.io/API_KEY"
ETHEREUM_ENDPOINT_URI = "http://localhost:8545"

View File

@ -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

View File

@ -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()

View File

@ -25,3 +25,5 @@ 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"
ETHEREUM_PROVIDER = "http"
ETHEREUM_ENDPOINT_URI = env.str("ETHEREUM_ENDPOINT_URI")

View File

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -56,3 +56,8 @@ sendgrid==5.3.0
# input validation
flask-yolo2API==0.2.6
#web3
flask-web3==0.1.1
web3==4.8.1

View File

@ -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 = ""

View File

View File

@ -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)