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"
|
||||
REDISTOGO_URL="redis://localhost:6379"
|
||||
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 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
|
||||
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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"
|
||||
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
|
||||
WebTest==2.0.30
|
||||
factory-boy==2.11.1
|
||||
eth-tester[py-evm]==0.1.0b33
|
||||
|
||||
# Lint and code style
|
||||
flake8==3.5.0
|
||||
|
|
|
@ -55,4 +55,9 @@ flask-sendgrid==0.6
|
|||
sendgrid==5.3.0
|
||||
|
||||
# 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.
|
||||
SQLALCHEMY_TRACK_MODIFICATIONS = False
|
||||
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