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:
parent
8c2e43c51b
commit
03de8c2543
|
@ -5,4 +5,7 @@ SITE_URL="https://grant.io" # No trailing slash
|
||||||
DATABASE_URL="sqlite:////tmp/dev.db"
|
DATABASE_URL="sqlite:////tmp/dev.db"
|
||||||
REDISTOGO_URL="redis://localhost:6379"
|
REDISTOGO_URL="redis://localhost:6379"
|
||||||
SECRET_KEY="not-so-secret"
|
SECRET_KEY="not-so-secret"
|
||||||
SENDGRID_API_KEY="optional, but emails won't send without it"
|
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"
|
|
@ -4,7 +4,7 @@ from flask import Flask
|
||||||
from flask_cors import CORS
|
from flask_cors import CORS
|
||||||
|
|
||||||
from grant import commands, proposal, user, comment, milestone, admin, email
|
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"):
|
def create_app(config_object="grant.settings"):
|
||||||
|
@ -25,6 +25,7 @@ def register_extensions(app):
|
||||||
migrate.init_app(app, db)
|
migrate.init_app(app, db)
|
||||||
ma.init_app(app)
|
ma.init_app(app)
|
||||||
mail.init_app(app)
|
mail.init_app(app)
|
||||||
|
web3.init_app(app)
|
||||||
CORS(app)
|
CORS(app)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
|
@ -5,9 +5,11 @@ from flask_marshmallow import Marshmallow
|
||||||
from flask_migrate import Migrate
|
from flask_migrate import Migrate
|
||||||
from flask_sqlalchemy import SQLAlchemy
|
from flask_sqlalchemy import SQLAlchemy
|
||||||
from flask_sendgrid import SendGrid
|
from flask_sendgrid import SendGrid
|
||||||
|
from flask_web3 import FlaskWeb3
|
||||||
|
|
||||||
bcrypt = Bcrypt()
|
bcrypt = Bcrypt()
|
||||||
db = SQLAlchemy()
|
db = SQLAlchemy()
|
||||||
migrate = Migrate()
|
migrate = Migrate()
|
||||||
ma = Marshmallow()
|
ma = Marshmallow()
|
||||||
mail = SendGrid()
|
mail = SendGrid()
|
||||||
|
web3 = FlaskWeb3()
|
||||||
|
|
|
@ -24,4 +24,6 @@ DEBUG_TB_INTERCEPT_REDIRECTS = False
|
||||||
CACHE_TYPE = "simple" # Can be "memcached", "redis", etc.
|
CACHE_TYPE = "simple" # Can be "memcached", "redis", etc.
|
||||||
SQLALCHEMY_TRACK_MODIFICATIONS = False
|
SQLALCHEMY_TRACK_MODIFICATIONS = False
|
||||||
SENDGRID_API_KEY = env.str("SENDGRID_API_KEY", default="")
|
SENDGRID_API_KEY = env.str("SENDGRID_API_KEY", default="")
|
||||||
SENDGRID_DEFAULT_FROM = "noreply@grant.io"
|
SENDGRID_DEFAULT_FROM = "noreply@grant.io"
|
||||||
|
ETHEREUM_PROVIDER = "http"
|
||||||
|
ETHEREUM_ENDPOINT_URI = env.str("ETHEREUM_ENDPOINT_URI")
|
||||||
|
|
|
@ -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
|
|
@ -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
|
|
@ -5,6 +5,7 @@
|
||||||
pytest==3.7.1
|
pytest==3.7.1
|
||||||
WebTest==2.0.30
|
WebTest==2.0.30
|
||||||
factory-boy==2.11.1
|
factory-boy==2.11.1
|
||||||
|
eth-tester[py-evm]==0.1.0b33
|
||||||
|
|
||||||
# Lint and code style
|
# Lint and code style
|
||||||
flake8==3.5.0
|
flake8==3.5.0
|
||||||
|
|
|
@ -55,4 +55,9 @@ flask-sendgrid==0.6
|
||||||
sendgrid==5.3.0
|
sendgrid==5.3.0
|
||||||
|
|
||||||
# input validation
|
# input validation
|
||||||
flask-yolo2API==0.2.6
|
flask-yolo2API==0.2.6
|
||||||
|
|
||||||
|
#web3
|
||||||
|
flask-web3==0.1.1
|
||||||
|
web3==4.8.1
|
||||||
|
|
||||||
|
|
|
@ -8,3 +8,5 @@ DEBUG_TB_ENABLED = False
|
||||||
CACHE_TYPE = 'simple' # Can be "memcached", "redis", etc.
|
CACHE_TYPE = 'simple' # Can be "memcached", "redis", etc.
|
||||||
SQLALCHEMY_TRACK_MODIFICATIONS = False
|
SQLALCHEMY_TRACK_MODIFICATIONS = False
|
||||||
WTF_CSRF_ENABLED = False # Allows form testing
|
WTF_CSRF_ENABLED = False # Allows form testing
|
||||||
|
ETHEREUM_PROVIDER = "test"
|
||||||
|
ETHEREUM_ENDPOINT_URI = ""
|
||||||
|
|
|
@ -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)
|
Loading…
Reference in New Issue