Merge develop in.

This commit is contained in:
Will O'Beirne 2018-11-26 18:25:02 -05:00
commit 5afdb2011d
No known key found for this signature in database
GPG Key ID: 44C190DB5DEAF9F6
90 changed files with 3249 additions and 31176 deletions

1
.gitignore vendored
View File

@ -1 +1,2 @@
.idea
contract/build

View File

@ -2,9 +2,9 @@ matrix:
include:
# Frontend
- language: node_js
node_js: 8.11.4
node_js: 8.13.0
before_install:
- cd frontend/
- cd frontend
install: yarn
script:
- yarn run lint
@ -15,17 +15,22 @@ matrix:
before_install:
- cd backend/
- cp .env.example .env
env:
- CROWD_FUND_URL=https://eip-712.herokuapp.com/contract/crowd-fund CROWD_FUND_FACTORY_URL=https://eip-712.herokuapp.com/contract/factory
install: pip install -r requirements/dev.txt
script:
- flask test
# Contracts
- language: node_js
node_js: 8.11.4
node_js: 8.13.0
before_install:
- cd contract/
install: yarn && yarn add global truffle ganache-cli
install: yarn && yarn add global truffle ganache-cli@6.1.8
before_script:
- ganache-cli > /dev/null &
- sleep 10
script:
- yarn run test
env:
- CROWD_FUND_URL=https://eip-712.herokuapp.com/contract/crowd-fund
CROWD_FUND_FACTORY_URL=https://eip-712.herokuapp.com/contract/factory

View File

@ -5,4 +5,19 @@ 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"
# CROWD_FUND_URL = "https://eip-712.herokuapp.com/contract/crowd-fund"
# CROWD_FUND_FACTORY_URL = "https://eip-712.herokuapp.com/contract/factory"
CROWD_FUND_URL = "http://localhost:5000/dev-contracts/CrowdFund.json"
CROWD_FUND_FACTORY_URL = "http://localhost:5000/dev-contracts/CrowdFundFactory.json"
# SENTRY_DSN="https://PUBLICKEY@sentry.io/PROJECTID"
# SENTRY_RELEASE="optional, overrides git hash"
UPLOAD_DIRECTORY = "/tmp"
UPLOAD_URL = "http://localhost:5000" # for constructing download url

View File

@ -1,20 +0,0 @@
# Config file for automatic testing at travis-ci.org
sudo: false # http://docs.travis-ci.com/user/migrating-from-legacy/
language: python
env:
- FLASK_APP=app.py FLASK_DEBUG=1
python:
- 2.7
- 3.4
- 3.5
- 3.6
install:
- pip install -r requirements/dev.txt
- nvm install 6.10
- nvm use 6.10
- npm install
before_script:
- npm run lint
- npm run build
- flask lint
script: flask test

View File

@ -2,9 +2,12 @@
"""The app module, containing the app factory function."""
from flask import Flask
from flask_cors import CORS
from sentry_sdk.integrations.flask import FlaskIntegration
import sentry_sdk
from grant import commands, proposal, user, comment, milestone, admin, email
from grant.extensions import bcrypt, migrate, db, ma, mail
from grant import commands, proposal, user, comment, milestone, admin, email, web3 as web3module
from grant.extensions import bcrypt, migrate, db, ma, mail, web3
from grant.settings import SENTRY_RELEASE, ENV
def create_app(config_object="grant.settings"):
@ -15,6 +18,11 @@ def create_app(config_object="grant.settings"):
register_blueprints(app)
register_shellcontext(app)
register_commands(app)
sentry_sdk.init(
environment=ENV,
release=SENTRY_RELEASE,
integrations=[FlaskIntegration()]
)
return app
@ -25,6 +33,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
@ -37,6 +46,9 @@ def register_blueprints(app):
app.register_blueprint(milestone.views.blueprint)
app.register_blueprint(admin.views.blueprint)
app.register_blueprint(email.views.blueprint)
# Only add these routes locally
if ENV == 'development':
app.register_blueprint(web3module.dev_contracts.blueprint)
def register_shellcontext(app):

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

@ -45,6 +45,33 @@ class ProposalUpdate(db.Model):
self.date_created = datetime.datetime.now()
class ProposalContribution(db.Model):
__tablename__ = "proposal_contribution"
tx_id = db.Column(db.String(255), primary_key=True)
date_created = db.Column(db.DateTime, nullable=False)
proposal_id = db.Column(db.Integer, db.ForeignKey("proposal.id"), nullable=False)
user_id = db.Column(db.Integer, db.ForeignKey("user.id"), nullable=True)
from_address = db.Column(db.String(255), nullable=False)
amount = db.Column(db.String(255), nullable=False) # in eth
def __init__(
self,
tx_id: str,
proposal_id: int,
user_id: int,
from_address: str,
amount: str
):
self.tx_id = tx_id
self.proposal_id = proposal_id
self.user_id = user_id
self.from_address = from_address
self.amount = amount
self.date_created = datetime.datetime.now()
class Proposal(db.Model):
__tablename__ = "proposal"
@ -60,6 +87,7 @@ class Proposal(db.Model):
team = db.relationship("User", secondary=proposal_team)
comments = db.relationship(Comment, backref="proposal", lazy=True)
updates = db.relationship(ProposalUpdate, backref="proposal", lazy=True)
contributions = db.relationship(ProposalContribution, backref="proposal", lazy=True)
milestones = db.relationship("Milestone", backref="proposal", lazy=True)
def __init__(
@ -110,6 +138,7 @@ class ProposalSchema(ma.Schema):
"body",
"comments",
"updates",
"contributions",
"milestones",
"category",
"team"
@ -121,6 +150,7 @@ class ProposalSchema(ma.Schema):
comments = ma.Nested("CommentSchema", many=True)
updates = ma.Nested("ProposalUpdateSchema", many=True)
contributions = ma.Nested("ProposalContributionSchema", many=True)
team = ma.Nested("UserSchema", many=True)
milestones = ma.Nested("MilestoneSchema", many=True)
@ -166,3 +196,30 @@ class ProposalUpdateSchema(ma.Schema):
proposal_update_schema = ProposalUpdateSchema()
proposals_update_schema = ProposalUpdateSchema(many=True)
class ProposalContributionSchema(ma.Schema):
class Meta:
model = ProposalContribution
# Fields to expose
fields = (
"id",
"tx_id",
"proposal_id",
"user_id",
"from_address",
"amount",
"date_created",
)
id = ma.Method("get_id")
date_created = ma.Method("get_date_created")
def get_id(self, obj):
return obj.tx_id
def get_date_created(self, obj):
return dt_to_unix(obj.date_created)
proposal_contribution_schema = ProposalContributionSchema()
proposals_contribution_schema = ProposalContributionSchema(many=True)

View File

@ -10,7 +10,18 @@ from grant.comment.models import Comment, comment_schema, comments_schema
from grant.milestone.models import Milestone
from grant.user.models import User, SocialMedia, Avatar
from grant.utils.auth import requires_sm, requires_team_member_auth, verify_signed_auth, BadSignatureException
from .models import Proposal, proposals_schema, proposal_schema, ProposalUpdate, proposal_update_schema, db
from grant.web3.proposal import read_proposal, validate_contribution_tx
from .models import(
Proposal,
proposals_schema,
proposal_schema,
ProposalUpdate,
proposal_update_schema,
ProposalContribution,
proposal_contribution_schema,
db
)
import traceback
blueprint = Blueprint("proposal", __name__, url_prefix="/api/v1/proposals")
@ -21,6 +32,10 @@ def get_proposal(proposal_id):
proposal = Proposal.query.filter_by(id=proposal_id).first()
if proposal:
dumped_proposal = proposal_schema.dump(proposal)
proposal_contract = read_proposal(dumped_proposal['proposal_address'])
if not proposal_contract:
return {"message": "Proposal retired"}, 404
dumped_proposal['crowd_fund'] = proposal_contract
return dumped_proposal
else:
return {"message": "No proposal matching id"}, 404
@ -108,8 +123,16 @@ def get_proposals(stage):
else:
proposals = Proposal.query.order_by(Proposal.date_created.desc()).all()
dumped_proposals = proposals_schema.dump(proposals)
return dumped_proposals
try:
for p in dumped_proposals:
proposal_contract = read_proposal(p['proposal_address'])
p['crowd_fund'] = proposal_contract
filtered_proposals = list(filter(lambda p: p['crowd_fund'] is not None, dumped_proposals))
return filtered_proposals
except Exception as e:
print(e)
print(traceback.format_exc())
return {"message": "Oops! Something went wrong."}, 500
@blueprint.route("/", methods=["POST"])
@requires_sm
@ -234,3 +257,52 @@ def post_proposal_update(proposal_id, title, content):
dumped_update = proposal_update_schema.dump(update)
return dumped_update, 201
@blueprint.route("/<proposal_id>/contributions", methods=["GET"])
@endpoint.api()
def get_proposal_contributions(proposal_id):
proposal = Proposal.query.filter_by(id=proposal_id).first()
if proposal:
dumped_proposal = proposal_schema.dump(proposal)
return dumped_proposal["contributions"]
else:
return {"message": "No proposal matching id"}, 404
@blueprint.route("/<proposal_id>/contributions/<contribution_id>", methods=["GET"])
@endpoint.api()
def get_proposal_contribution(proposal_id, contribution_id):
proposal = Proposal.query.filter_by(id=proposal_id).first()
if proposal:
contribution = ProposalContribution.query.filter_by(tx_id=contribution_id).first()
if contribution:
return proposal_contribution_schema.dump(contribution)
else:
return {"message": "No contribution matching id"}
else:
return {"message": "No proposal matching id"}, 404
@blueprint.route("/<proposal_id>/contributions", methods=["POST"])
@requires_sm
@endpoint.api(
parameter('txId', type=str, required=True),
parameter('fromAddress', type=str, required=True),
parameter('amount', type=str, required=True)
)
def post_proposal_contribution(proposal_id, tx_id, from_address, amount):
proposal = Proposal.query.filter_by(id=proposal_id).first()
if proposal:
contribution = ProposalContribution(
tx_id=tx_id,
proposal_id=proposal_id,
user_id=g.current_user.id,
from_address=from_address,
amount=amount
)
db.session.add(contribution)
db.session.commit()
dumped_contribution = proposal_contribution_schema.dump(contribution)
return dumped_contribution, 201
return {"message": "No proposal matching id"}, 404

View File

@ -6,8 +6,15 @@ Most configuration is set via environment variables.
For local development, use a .env file to set
environment variables.
"""
import subprocess
from environs import Env
def git_revision_short_hash():
try:
return subprocess.check_output(['git', 'rev-parse', '--short', 'HEAD'])
except subprocess.CalledProcessError:
return 0
env = Env()
env.read_env()
@ -15,6 +22,8 @@ ENV = env.str("FLASK_ENV", default="production")
DEBUG = ENV == "development"
SITE_URL = env.str('SITE_URL', default='https://grant.io')
AUTH_URL = env.str('AUTH_URL', default='https://eip-712.herokuapp.com')
CROWD_FUND_FACTORY_URL = env.str('CROWD_FUND_FACTORY_URL', default=None)
CROWD_FUND_URL = env.str('CROWD_FUND_URL', default=None)
SQLALCHEMY_DATABASE_URI = env.str("DATABASE_URL")
QUEUES = ["default"]
SECRET_KEY = env.str("SECRET_KEY")
@ -24,4 +33,11 @@ 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")
SENTRY_DSN = env.str("SENTRY_DSN", default=None)
SENTRY_RELEASE = env.str("SENTRY_RELEASE", default=git_revision_short_hash())
UPLOAD_DIRECTORY = env.str("UPLOAD_DIRECTORY")
UPLOAD_URL = env.str("UPLOAD_URL")
MAX_CONTENT_LENGTH = 5 * 1024 * 1024 # 5MB (limits file uploads, raises RequestEntityTooLarge)

View File

@ -1,8 +1,10 @@
from flask import Blueprint, g
from flask import Blueprint, g, request
from flask_yoloapi import endpoint, parameter
from grant.proposal.models import Proposal, proposal_team
from grant.utils.auth import requires_sm, requires_same_user_auth, verify_signed_auth, BadSignatureException
from grant.utils.upload import save_avatar, send_upload, remove_avatar
from grant.settings import UPLOAD_URL
from .models import User, SocialMedia, Avatar, users_schema, user_schema, db
blueprint = Blueprint('user', __name__, url_prefix='/api/v1/users')
@ -69,11 +71,11 @@ def create_user(
sig_address = verify_signed_auth(signed_message, raw_typed_data)
if sig_address.lower() != account_address.lower():
return {
"message": "Message signature address ({sig_address}) doesn't match account_address ({account_address})".format(
"message": "Message signature address ({sig_address}) doesn't match account_address ({account_address})".format(
sig_address=sig_address,
account_address=account_address
)
}, 400
)
}, 400
except BadSignatureException:
return {"message": "Invalid message signature"}, 400
@ -103,17 +105,49 @@ def auth_user(account_address, signed_message, raw_typed_data):
sig_address = verify_signed_auth(signed_message, raw_typed_data)
if sig_address.lower() != account_address.lower():
return {
"message": "Message signature address ({sig_address}) doesn't match account_address ({account_address})".format(
"message": "Message signature address ({sig_address}) doesn't match account_address ({account_address})".format(
sig_address=sig_address,
account_address=account_address
)
}, 400
)
}, 400
except BadSignatureException:
return {"message": "Invalid message signature"}, 400
return user_schema.dump(existing_user)
@blueprint.route("/avatar", methods=["POST"])
@requires_sm
@endpoint.api()
def upload_avatar():
user = g.current_user
if 'file' not in request.files:
return {"message": "No file in post"}, 400
file = request.files['file']
if file.filename == '':
return {"message": "No selected file"}, 400
try:
filename = save_avatar(file, user.id)
return {"url": "{0}/api/v1/users/avatar/{1}".format(UPLOAD_URL, filename)}
except Exception as e:
return {"message": str(e)}, 400
@blueprint.route("/avatar/<filename>", methods=["GET"])
def get_avatar(filename):
return send_upload(filename)
@blueprint.route("/avatar", methods=["DELETE"])
@requires_sm
@endpoint.api(
parameter('url', type=str, required=True)
)
def delete_avatar(url):
user = g.current_user
remove_avatar(url, user.id)
@blueprint.route("/<user_identity>", methods=["PUT"])
@requires_sm
@requires_same_user_auth
@ -140,6 +174,7 @@ def update_user(user_identity, display_name, title, social_medias, avatar):
else:
SocialMedia.query.filter_by(user_id=user.id).delete()
old_avatar = Avatar.query.filter_by(user_id=user.id).first()
if avatar is not None:
Avatar.query.filter_by(user_id=user.id).delete()
avatar_link = avatar.get('link')
@ -149,6 +184,11 @@ def update_user(user_identity, display_name, title, social_medias, avatar):
else:
Avatar.query.filter_by(user_id=user.id).delete()
old_avatar_url = old_avatar and old_avatar.image_url
new_avatar_url = avatar and avatar['link']
if old_avatar_url and old_avatar_url != new_avatar_url:
remove_avatar(old_avatar_url, user.id)
db.session.commit()
result = user_schema.dump(user)
return result

View File

@ -6,6 +6,7 @@ import requests
from flask import request, g, jsonify
from itsdangerous import SignatureExpired, BadSignature
from itsdangerous import TimedJSONWebSignatureSerializer as Serializer
import sentry_sdk
from grant.settings import SECRET_KEY, AUTH_URL
from ..proposal.models import Proposal
@ -72,6 +73,10 @@ def requires_sm(f):
return jsonify(message="No user exists with address: {}".format(auth_address)), 401
g.current_user = user
with sentry_sdk.configure_scope() as scope:
scope.user = {
"id": user.id,
}
return f(*args, **kwargs)
return jsonify(message="Authentication is required to access this resource"), 401

View File

@ -0,0 +1,54 @@
import os
import re
from hashlib import md5
from werkzeug.utils import secure_filename
from flask import send_from_directory
from grant.settings import UPLOAD_DIRECTORY
IMAGE_MIME_TYPES = set(['image/png', 'image/jpg', 'image/gif'])
AVATAR_MAX_SIZE = 2 * 1024 * 1024 # 2MB
class FileValidationException(Exception):
pass
def allowed_avatar_file(file):
if file.mimetype not in IMAGE_MIME_TYPES:
raise FileValidationException("Unacceptable file type: {0}".format(file.mimetype))
file.seek(0, os.SEEK_END)
size = file.tell()
file.seek(0)
if size > AVATAR_MAX_SIZE:
raise FileValidationException(
"File size is too large ({0}KB), max size is {1}KB".format(size / 1024, AVATAR_MAX_SIZE / 1024)
)
return True
def hash_file(file):
hasher = md5()
buf = file.read()
hasher.update(buf)
file.seek(0)
return hasher.hexdigest()
def save_avatar(file, user_id):
if file and allowed_avatar_file(file):
ext = file.mimetype.replace('image/', '')
filename = "{0}.{1}.{2}".format(user_id, hash_file(file), ext)
file.save(os.path.join(UPLOAD_DIRECTORY, filename))
return filename
def remove_avatar(url, user_id):
match = re.search(r'/api/v1/users/avatar/(\d+.\w+.\w+)', url)
if match:
filename = match.group(1)
if filename.startswith(str(user_id) + '.'):
os.remove(os.path.join(UPLOAD_DIRECTORY, filename))
def send_upload(filename):
return send_from_directory(UPLOAD_DIRECTORY, secure_filename(filename))

View File

@ -0,0 +1 @@
from . import dev_contracts

View File

@ -0,0 +1,19 @@
import json
from flask import Blueprint, jsonify
blueprint = Blueprint('dev-contracts', __name__, url_prefix='/dev-contracts')
@blueprint.route("/CrowdFundFactory.json", methods=["GET"])
def factory():
with open("../contract/build/contracts/CrowdFundFactory.json", "r") as read_file:
crowd_fund_factory_json = json.load(read_file)
return jsonify(crowd_fund_factory_json)
@blueprint.route("/CrowdFund.json", methods=["GET"])
def crowd_find():
with open("../contract/build/contracts/CrowdFund.json", "r") as read_file:
crowd_fund_json = json.load(read_file)
return jsonify(crowd_fund_json)

View File

@ -0,0 +1,145 @@
import time
import requests
from flask_web3 import current_web3
from grant.settings import CROWD_FUND_URL
from .util import batch_call, call_array, RpcError
crowd_fund_abi = None
def get_crowd_fund_abi():
global crowd_fund_abi
if crowd_fund_abi:
return crowd_fund_abi
crowd_fund_json = requests.get(CROWD_FUND_URL).json()
crowd_fund_abi = crowd_fund_json['abi']
return crowd_fund_abi
def read_proposal(address):
current_web3.eth.defaultAccount = '0x537680D921C000fC52Af9962ceEb4e359C50F424' if not current_web3.eth.accounts else \
current_web3.eth.accounts[0]
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))
try:
crowd_fund = batch_call(current_web3, address, crowd_fund_abi, calls, contract)
# catch dead contracts here
except RpcError:
return None
# 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
def validate_contribution_tx(tx_id, from_address, to_address, amount):
amount_wei = current_web3.toWei(amount, 'ether')
tx = current_web3.eth.getTransaction(tx_id)
if tx:
if from_address.lower() == tx.get("from").lower() and \
to_address == tx.get("to") and \
amount_wei == tx.get("value"):
return True
return False

View File

@ -0,0 +1,86 @@
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
class RpcError(Exception):
pass
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 not calls:
return []
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)
if 'error' in responses[0]:
message = responses[0]['error']['message'] if 'message' in responses[0]['error'] else 'No error message found.'
raise RpcError("rpc error: {0}".format(message))
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

@ -0,0 +1,38 @@
"""empty message
Revision ID: 1d06a5e43324
Revises: 312db8611967
Create Date: 2018-11-17 11:07:40.413141
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '1d06a5e43324'
down_revision = '312db8611967'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('proposal_contribution',
sa.Column('tx_id', sa.String(length=255), nullable=False),
sa.Column('date_created', sa.DateTime(), nullable=False),
sa.Column('proposal_id', sa.Integer(), nullable=False),
sa.Column('user_id', sa.Integer(), nullable=True),
sa.Column('from_address', sa.String(length=255), nullable=False),
sa.Column('amount', sa.String(length=255), nullable=False),
sa.ForeignKeyConstraint(['proposal_id'], ['proposal.id'], ),
sa.ForeignKeyConstraint(['user_id'], ['user.id'], ),
sa.PrimaryKeyConstraint('tx_id')
)
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.drop_table('proposal_contribution')
# ### end Alembic commands ###

View File

@ -1,7 +1,7 @@
"""empty message
Revision ID: 3699cb98fc2a
Revises: 312db8611967
Revises: 1d06a5e43324
Create Date: 2018-11-08 12:33:14.995080
"""
@ -11,7 +11,7 @@ import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '3699cb98fc2a'
down_revision = '312db8611967'
down_revision = '1d06a5e43324'
branch_labels = None
depends_on = None

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

@ -55,4 +55,11 @@ flask-sendgrid==0.6
sendgrid==5.3.0
# input validation
flask-yolo2API==0.2.4
flask-yolo2API==0.2.6
#web3
flask-web3==0.1.1
web3==4.8.1
#sentry
sentry-sdk[flask]==0.5.5

View File

@ -1,4 +1,5 @@
import json
from mock import patch
from grant.proposal.models import Proposal
from grant.user.models import SocialMedia, Avatar
@ -71,3 +72,110 @@ class TestAPI(BaseUserConfig):
)
self.assertEqual(proposal_res2.status_code, 409)
@patch('grant.proposal.views.validate_contribution_tx', return_value=True)
def test_create_proposal_contribution(self, mock_validate_contribution_tx):
proposal_res = self.app.post(
"/api/v1/proposals/",
data=json.dumps(test_proposal),
headers=self.headers,
content_type='application/json'
)
proposal_json = proposal_res.json
proposal_id = proposal_json["proposalId"]
contribution = {
"txId": "0x12345",
"fromAddress": "0x23456",
"amount": "1.2345"
}
contribution_res = self.app.post(
"/api/v1/proposals/{}/contributions".format(proposal_id),
data=json.dumps(contribution),
headers=self.headers,
content_type='application/json'
)
res = contribution_res.json
exp = contribution
def eq(k):
self.assertEqual(exp[k], res[k])
eq("txId")
eq("fromAddress")
eq("amount")
self.assertEqual(proposal_id, res["proposalId"])
@patch('grant.proposal.views.validate_contribution_tx', return_value=True)
def test_get_proposal_contribution(self, mock_validate_contribution_tx):
proposal_res = self.app.post(
"/api/v1/proposals/",
data=json.dumps(test_proposal),
headers=self.headers,
content_type='application/json'
)
proposal_json = proposal_res.json
proposal_id = proposal_json["proposalId"]
contribution = {
"txId": "0x12345",
"fromAddress": "0x23456",
"amount": "1.2345"
}
self.app.post(
"/api/v1/proposals/{}/contributions".format(proposal_id),
data=json.dumps(contribution),
headers=self.headers,
content_type='application/json'
)
contribution_res = self.app.get(
"/api/v1/proposals/{0}/contributions/{1}".format(proposal_id, contribution["txId"])
)
res = contribution_res.json
exp = contribution
def eq(k):
self.assertEqual(exp[k], res[k])
eq("txId")
eq("fromAddress")
eq("amount")
self.assertEqual(proposal_id, res["proposalId"])
@patch('grant.proposal.views.validate_contribution_tx', return_value=True)
def test_get_proposal_contributions(self, mock_validate_contribution_tx):
proposal_res = self.app.post(
"/api/v1/proposals/",
data=json.dumps(test_proposal),
headers=self.headers,
content_type='application/json'
)
proposal_json = proposal_res.json
proposal_id = proposal_json["proposalId"]
contribution = {
"txId": "0x12345",
"fromAddress": "0x23456",
"amount": "1.2345"
}
self.app.post(
"/api/v1/proposals/{}/contributions".format(proposal_id),
data=json.dumps(contribution),
headers=self.headers,
content_type='application/json'
)
contributions_res = self.app.get(
"/api/v1/proposals/{0}/contributions".format(proposal_id)
)
res = contributions_res.json[0]
exp = contribution
def eq(k):
self.assertEqual(exp[k], res[k])
eq("txId")
eq("fromAddress")
eq("amount")
self.assertEqual(proposal_id, res["proposalId"])

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

@ -2,10 +2,10 @@ import copy
import json
from animal_case import animalify
from mock import patch
from grant.proposal.models import Proposal
from grant.user.models import User, user_schema
from mock import patch
from ..config import BaseUserConfig
from ..test_data import test_team, test_proposal, test_user
@ -195,8 +195,8 @@ class TestAPI(BaseUserConfig):
def test_update_user_remove_social_and_avatar(self):
updated_user = animalify(copy.deepcopy(user_schema.dump(self.user)))
updated_user["displayName"] = 'new display name'
updated_user["avatar"] = None
updated_user["socialMedias"] = None
updated_user["avatar"] = {}
updated_user["socialMedias"] = []
user_update_resp = self.app.put(
"/api/v1/users/{}".format(self.user.account_address),
@ -211,3 +211,15 @@ class TestAPI(BaseUserConfig):
self.assertFalse(len(user_json["socialMedias"]))
self.assertEqual(user_json["displayName"], updated_user["displayName"])
self.assertEqual(user_json["title"], updated_user["title"])
def test_update_user_400_when_required_param_not_passed(self):
updated_user = animalify(copy.deepcopy(user_schema.dump(self.user)))
updated_user["displayName"] = 'new display name'
del updated_user["avatar"]
user_update_resp = self.app.put(
"/api/v1/users/{}".format(self.user.account_address),
data=json.dumps(updated_user),
headers=self.headers,
content_type='application/json'
)
self.assert400(user_update_resp)

View File

View File

@ -0,0 +1,127 @@
import time
import eth_tester.backends.pyevm.main as py_evm_main
import requests
from flask_web3 import current_web3
from grant.extensions import web3
from grant.settings import CROWD_FUND_URL, CROWD_FUND_FACTORY_URL
from grant.web3.proposal import read_proposal
from ..config import BaseTestConfig
# 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)
crowd_fund_factory_json = requests.get(CROWD_FUND_FACTORY_URL).json()
self.crowd_fund_json = requests.get(CROWD_FUND_URL).json()
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, 50000)
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)

3
contract/.gitignore vendored
View File

@ -2,5 +2,4 @@ node_modules
.idea/
yarn-error.log
.env
build/abi
build/typedefs
build

1
contract/.nvmrc Normal file
View File

@ -0,0 +1 @@
8.13.0

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -1 +1,3 @@
{}
{
"baseUrl": "http://localhost:3000"
}

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,14 @@
/// <reference types="cypress"/>
import {
loadWeb3,
increaseTime,
syncTimeWithEvm,
randomString
} from "../helpers";
import { createDemoProposal, authenticateUser } from "../parts";
describe("authenticate", () => {
it("authenticates and creates if necessary", () => {
authenticateUser(cy, 0);
});
});

View File

@ -3,13 +3,13 @@ import { loadWeb3, randomString, randomHex } from "../helpers";
describe("browse", () => {
it("should load and be able to browse pages", () => {
cy.visit("http://localhost:3000", { onBeforeLoad: loadWeb3 });
cy.visit("http://localhost:3000", { onBeforeLoad: loadWeb3(0) });
cy.title().should("include", "Grant.io - Home");
// test hero create link
cy.get('.Home-hero-buttons a[href="/create"]')
// {force: true} here overcomes a strange issue where the button moves up under the header
// this is likely a cypress-related problem
// this is likely a cypress scroll related problem
.click({ force: true });
cy.title().should("include", "Grant.io - Create a Proposal");

View File

@ -0,0 +1,47 @@
/// <reference types="cypress"/>
import {
loadWeb3,
increaseTime,
syncTimeWithEvm,
randomString
} from "../helpers";
import { createDemoProposal, authenticateUser } from "../parts";
describe("proposal", () => {
const id = randomString();
const title = `[${id}] e2e create cancel`;
const amount = "1";
afterEach(function() {
if (this.currentTest.state === "failed") {
(Cypress as any).runner.stop();
}
});
it("authenticates and creates if necessary", () => {
authenticateUser(cy, 0);
});
it("creates demo proposal", () => {
createDemoProposal(cy, title, amount);
});
it("cancels the proposal", () => {
cy.contains(".Proposal-top-main-menu > .ant-btn", "Actions").click();
cy.contains(".ant-dropdown-menu-item", "Cancel proposal").click();
cy.contains(".ant-modal-footer > div button", "Confirm").click();
cy.contains("body", "Proposal didnt get funded", { timeout: 20000 });
cy.get(".ant-modal-wrap").should("not.be.visible");
cy.contains(".Proposal-top-main-menu > .ant-btn", "Actions").click();
cy.contains(".ant-dropdown-menu-item", "Cancel proposal").should(
"have.attr",
"aria-disabled",
"true"
);
});
it("should appear unfunded to outsiders (account 9)", () => {
cy.url().then(url => cy.visit(url, { onBeforeLoad: loadWeb3(9) }));
cy.contains("body", "Proposal didnt get funded", { timeout: 20000 });
});
});

View File

@ -0,0 +1,173 @@
/// <reference types="cypress"/>
import { loadWeb3, randomString, randomHex, syncTimeWithEvm } from "../helpers";
import { authenticateUser } from "../parts";
describe("create.flow", () => {
const time = new Date().toLocaleString();
const id = randomString();
const randomEthHex = randomHex(32);
const nextYear = new Date().getUTCFullYear() + 1;
const proposal = {
title: `[${id}] e2e create flow`,
brief: "e2e brief",
category: "Community", // .anticon-team
targetAmount: 5,
body: `#### e2e Proposal ${id} {enter} **created** ${time} `,
team: [
{
name: "Alisha Endtoend",
title: "QA Robot0",
ethAddress: `0x0000${randomEthHex}0000`,
emailAddress: `qa.alisha.${id}@grant.io`
},
{
name: "Billy Endtoend",
title: "QA Robot1",
ethAddress: `0x1111${randomEthHex}1111`,
emailAddress: `qa.billy.${id}@grant.io`
}
],
milestones: [
{
title: `e2e Milestone ${id} 0`,
body: `e2e Milestone ${id} {enter} body 0`,
date: {
y: nextYear,
m: "Jan",
expect: "January " + nextYear
}
},
{
title: `e2e Milestone ${id} 1`,
body: `e2e Milestone ${id} {enter} body 1`,
date: {
y: nextYear,
m: "Feb",
expect: "February " + nextYear
}
}
]
};
afterEach(function() {
if (this.currentTest.state === "failed") {
(Cypress as any).runner.stop();
}
});
context("create flow wizard", () => {
it("authenticates and creates if necessary", () => {
authenticateUser(cy, 0);
});
it("create flow step 1", () => {
cy.get('[href="/create"]').click();
syncTimeWithEvm(cy);
cy.get('.CreateFlow input[name="title"]').type(proposal.title);
cy.get('.CreateFlow textarea[name="brief"]').type(proposal.brief);
cy.contains("Select a category").click();
cy.get(".ant-select-dropdown li .anticon-team").click();
cy.get('.CreateFlow input[name="amountToRaise"]').type(
"" + proposal.targetAmount
);
cy.wait(1000);
cy.contains(".CreateFlow-footer-button", "Continue").click();
});
it("create flow step 2", () => {
cy.get("button.TeamForm-add").click();
cy.get('.TeamMember-info input[name="name"]').type(proposal.team[1].name);
cy.get('.TeamMember-info input[name="title"]').type(
proposal.team[1].title
);
cy.get('.TeamMember-info input[name="ethAddress"]').type(
proposal.team[1].ethAddress
);
cy.get('.TeamMember-info input[name="emailAddress"]').type(
proposal.team[1].emailAddress
);
cy.get("button")
.contains("Save changes")
.click({ force: true });
cy.wait(1000);
cy.contains(".CreateFlow-footer-button", "Continue").click();
});
it("create flow step 3", () => {
cy.get(".DraftEditor-editorContainer > div").type(proposal.body);
cy.get(".mde-tabs > :nth-child(2)").click();
cy.wait(1000);
cy.contains(".CreateFlow-footer-button", "Continue").click();
});
it("create flow step 4", () => {
cy.get('input[name="title"]').type(proposal.milestones[0].title);
cy.get('textarea[name="body"]').type(proposal.milestones[0].body);
cy.get('input[placeholder="Expected completion date"]').click();
cy.get(".ant-calendar-month-panel-next-year-btn").click();
cy.get(".ant-calendar-month-panel-month")
.contains(proposal.milestones[0].date.m)
.click();
cy.get(".ant-calendar-picker-input").should(
"have.value",
proposal.milestones[0].date.expect
);
cy.get("button")
.contains("Add another milestone")
.click({ force: true });
cy.get('input[name="title"]')
.eq(1)
.type(proposal.milestones[1].title);
cy.get('textarea[name="body"]')
.eq(1)
.type(proposal.milestones[1].body);
cy.get('input[placeholder="Expected completion date"]')
.eq(1)
.click();
cy.get(".ant-calendar-month-panel-next-year-btn").click();
cy.get(".ant-calendar-month-panel-month")
.contains(proposal.milestones[1].date.m)
.click();
cy.get(".ant-calendar-picker-input")
.eq(1)
.should("have.value", proposal.milestones[1].date.expect);
cy.wait(1000);
cy.contains(".CreateFlow-footer-button", "Continue").click();
});
it("create flow step 5", () => {
cy.window()
.then(w => (w as any).web3.eth.getAccounts())
.then(accts => {
cy.get('input[name="payOutAddress"]').type(accts[0]);
cy.get("button")
.contains("Add another trustee")
.click({ force: true });
cy.get(
'input[placeholder="0x8B0B72F8bDE212991135668922fD5acE557DE6aB"]'
)
.eq(1)
.type(accts[1]);
cy.get('input[name="deadline"][value="2592000"]').click({
force: true
});
cy.get('input[name="milestoneDeadline"][value="259200"]').click({
force: true
});
});
cy.wait(1000);
cy.contains(".CreateFlow-footer-button", "Continue").click();
});
it("publishes the proposal", () => {
cy.get("button")
.contains("Publish")
.click();
cy.get(".CreateFinal-loader-text").contains("Deploying contract...");
cy.get(".CreateFinal-message-text a", { timeout: 20000 })
.contains("Click here")
.click();
cy.get(".Proposal-top-main-title").contains(proposal.title);
});
});
});

View File

@ -0,0 +1,61 @@
/// <reference types="cypress"/>
import {
loadWeb3,
increaseTime,
syncTimeWithEvm,
randomString
} from "../helpers";
import { createDemoProposal, authenticateUser } from "../parts";
describe("create.fund.cancel", () => {
const id = randomString();
const title = `[${id}] e2e create fund cancel`;
const amount = "1";
afterEach(function() {
if (this.currentTest.state === "failed") {
(Cypress as any).runner.stop();
}
});
it("authenticates and creates if necessary", () => {
authenticateUser(cy, 0);
});
it("create demo proposal", () => {
createDemoProposal(cy, title, amount);
});
it("funds the proposal with account 5", () => {
cy.url().then(url => cy.visit(url, { onBeforeLoad: loadWeb3(5) }));
cy.get(".ant-input", { timeout: 20000 }).type(amount);
cy.get(".ant-form > .ant-btn").click();
cy.get(".ProposalCampaignBlock-fundingOver", { timeout: 20000 }).contains(
"Proposal has been funded"
);
});
it("cancels the proposal (refund contributors)", () => {
cy.url().then(url => cy.visit(url, { onBeforeLoad: loadWeb3(0) }));
cy.contains(".Proposal-top-main-menu > .ant-btn", "Actions").click();
cy.contains(".ant-dropdown-menu-item", "Refund contributors").click();
cy.contains(".ant-modal-footer > div button", "Confirm").click();
cy.get(".ant-modal-wrap", { timeout: 20000 }).should("not.be.visible");
cy.contains(".Proposal-top-main-menu > .ant-btn", "Actions").click();
cy.contains(".ant-dropdown-menu-item", "Refund contributors").should(
"have.attr",
"aria-disabled",
"true"
);
});
it("refunds the contributor (account 5)", () => {
cy.url().then(url => cy.visit(url, { onBeforeLoad: loadWeb3(5) }));
cy.contains(".ant-tabs-nav > :nth-child(1) > :nth-child(4)", "Refund", {
timeout: 20000
}).click();
// force disables cypress' auto scrolling which messes up UI in this case
cy.contains(".ant-btn", "Get your refund").click({ force: true });
cy.contains("body", "Your refund has been processed", { timeout: 20000 });
});
});

View File

@ -0,0 +1,126 @@
/// <reference types="cypress"/>
import {
loadWeb3,
randomString,
syncTimeWithEvm,
increaseTime
} from "../helpers";
import { createDemoProposal, fundProposal, authenticateUser } from "../parts";
describe("create.fund.complete.minority-no-votes", () => {
const id = randomString();
const title = `[${id}] e2e minority no-votes complete`;
const amount = "1";
afterEach(function() {
if (this.currentTest.state === "failed") {
//(Cypress as any).runner.stop();
this.skip();
}
});
it("authenticates and creates if necessary", () => {
authenticateUser(cy, 0);
});
it("creates demo proposal", () => {
createDemoProposal(cy, title, amount);
});
it("funds the proposal from accounts 5, 6 & 7", () => {
fundProposal(cy, 5, 0.1);
fundProposal(cy, 6, 0.2);
fundProposal(cy, 7, 0.7);
cy.get(".ProposalCampaignBlock-fundingOver", { timeout: 20000 }).contains(
"Proposal has been funded"
);
});
it("receives initial payout", () => {
// MILESTONE 1
syncTimeWithEvm(cy);
cy.url().then(url => cy.visit(url, { onBeforeLoad: loadWeb3(0) }));
cy.get(".MilestoneAction-top > div > .ant-btn", { timeout: 20000 }).click();
cy.contains(
".MilestoneAction-top > div > .ant-btn",
"Receive initial payout",
{ timeout: 20000 }
).click();
});
it("requests milestone 2 payout", () => {
// MILESTONE 2
cy.contains(
".MilestoneAction-top > div > .ant-btn",
"Request milestone payout",
{ timeout: 20000 }
).click();
cy.contains(".MilestoneAction-progress-text", "voted against payout", {
timeout: 20000
});
});
it("minority funder (acct 5) votes no", () => {
// VOTE NO
syncTimeWithEvm(cy);
cy.url().then(url => cy.visit(url, { onBeforeLoad: loadWeb3(5) }));
cy.contains(".ant-btn", "Vote against payout", { timeout: 20000 })
.click()
.should("have.class", "ant-btn-loading");
cy.contains(".ant-btn", "Revert vote against payout", { timeout: 20000 });
});
it("expires milestone 2 voting period & receives payout", () => {
// EXPIRE
increaseTime(cy, 70000);
// RECEIVE PAYOUT
cy.url().then(url => cy.visit(url, { onBeforeLoad: loadWeb3(0) }));
cy.contains(
".MilestoneAction-top > div > .ant-btn",
"Receive milestone payout",
{ timeout: 20000 }
).click();
});
it("requests milestone 3 payout", () => {
// MILESTONE 3
cy.contains(
".MilestoneAction-top > div > .ant-btn",
"Request milestone payout",
{ timeout: 20000 }
).click();
cy.contains(".MilestoneAction-progress-text", "voted against payout", {
timeout: 20000
});
});
it("minority funder (acct 5) votes no", () => {
// VOTE NO
syncTimeWithEvm(cy);
cy.url().then(url => cy.visit(url, { onBeforeLoad: loadWeb3(5) }));
cy.contains(".ant-btn", "Vote against payout", { timeout: 20000 })
.click()
.should("have.class", "ant-btn-loading");
cy.contains(".ant-btn", "Revert vote against payout", { timeout: 20000 });
});
it("expires milestone 3 voting period & receives payout", () => {
// EXPIRE
increaseTime(cy, 70000);
// RECEIVE PAYOUT
cy.url().then(url => cy.visit(url, { onBeforeLoad: loadWeb3(0) }));
cy.contains(
".MilestoneAction-top > div > .ant-btn",
"Receive milestone payout",
{ timeout: 20000 }
).click();
});
it("should not have receive button", () => {
cy.contains(
".MilestoneAction-top > div > .ant-btn",
"Receive milestone payout",
{ timeout: 20000 }
).should("not.exist");
});
});

View File

@ -0,0 +1,103 @@
/// <reference types="cypress"/>
import {
loadWeb3,
increaseTime,
syncTimeWithEvm,
randomString
} from "../helpers";
import { createDemoProposal, authenticateUser } from "../parts";
describe("create.fund.complete", () => {
const id = randomString();
const title = `[${id}] e2e create fund complete`;
const amount = "1";
afterEach(function() {
if (this.currentTest.state === "failed") {
//(Cypress as any).runner.stop();
this.skip();
}
});
it("authenticates and creates if necessary", () => {
authenticateUser(cy, 0);
});
it("create demo proposal", () => {
createDemoProposal(cy, title, amount);
});
it("funds the proposal with account 5", () => {
cy.url().then(url => cy.visit(url, { onBeforeLoad: loadWeb3(5) }));
cy.get(".ant-input", { timeout: 20000 }).type(amount);
cy.get(".ant-form > .ant-btn").click();
cy.get(".ProposalCampaignBlock-fundingOver", { timeout: 20000 }).contains(
"Proposal has been funded"
);
});
it("receives initial payout", () => {
// MILESTONE 1
cy.url().then(url => cy.visit(url, { onBeforeLoad: loadWeb3(0) }));
cy.contains(
".MilestoneAction-top > div > .ant-btn",
"Request initial payout",
{ timeout: 20000 }
)
.as("RequestPayout")
.click();
cy.contains(
".MilestoneAction-top > div > .ant-btn",
"Receive initial payout",
{ timeout: 20000 }
).click();
});
it("requests and receives milestone 2 payout", () => {
// MILESTONE 2
cy.contains(
".MilestoneAction-top > div > .ant-btn",
"Request milestone payout",
{ timeout: 20000 }
).click();
cy.contains(".MilestoneAction-progress-text", "voted against payout", {
timeout: 20000
});
// EXPIRE
increaseTime(cy, 70000);
cy.url().then(url => cy.visit(url, { onBeforeLoad: loadWeb3(0) }));
cy.contains(
".MilestoneAction-top > div > .ant-btn",
"Receive milestone payout",
{ timeout: 20000 }
).click();
});
it("requests and receives milestone 3 payout", () => {
// MILESTONE 3
cy.contains(
".MilestoneAction-top > div > .ant-btn",
"Request milestone payout",
{ timeout: 20000 }
).click();
cy.contains(".MilestoneAction-progress-text", "voted against payout", {
timeout: 20000
});
// EXPIRE
increaseTime(cy, 70000);
cy.url().then(url => cy.visit(url, { onBeforeLoad: loadWeb3(0) }));
cy.contains(
".MilestoneAction-top > div > .ant-btn",
"Receive milestone payout",
{ timeout: 20000 }
).click();
});
it("should not have receive button", () => {
cy.contains(
".MilestoneAction-top > div > .ant-btn",
"Receive milestone payout",
{ timeout: 20000 }
).should("not.exist");
});
});

View File

@ -0,0 +1,60 @@
/// <reference types="cypress"/>
import { loadWeb3, randomString, syncTimeWithEvm } from "../helpers";
import { createDemoProposal, fundProposal, authenticateUser } from "../parts";
describe("create.fund.ms2.majority-no-vote", () => {
const id = randomString();
const title = `[${id}] e2e ms2 majority no-vote`;
const amount = "1";
it("authenticates and creates if necessary", () => {
authenticateUser(cy, 0);
});
it("creates demo proposal", () => {
createDemoProposal(cy, title, amount);
});
it("fund the proposal with 5th account", () => {
fundProposal(cy, 5, 1);
cy.get(".ProposalCampaignBlock-fundingOver", { timeout: 20000 }).contains(
"Proposal has been funded"
);
});
it("receives initial payout for milestone 1", () => {
// MILESTONE 1
syncTimeWithEvm(cy);
cy.url().then(url => cy.visit(url, { onBeforeLoad: loadWeb3(0) }));
cy.get(".MilestoneAction-top > div > .ant-btn", { timeout: 20000 }).click();
cy.contains(
".MilestoneAction-top > div > .ant-btn",
"Receive initial payout",
{ timeout: 20000 }
).click();
cy.contains(
".MilestoneAction-top > div > .ant-btn",
"Request milestone payout",
{ timeout: 20000 }
);
});
it("requests milestone 2 payout", () => {
// MILESTONE 2
cy.contains(
".MilestoneAction-top > div > .ant-btn",
"Request milestone payout",
{ timeout: 20000 }
).click();
cy.contains(".MilestoneAction-progress-text", "voted against payout", {
timeout: 20000
});
});
it("vote against milestone 2 payout as account 5", () => {
syncTimeWithEvm(cy);
cy.url().then(url => cy.visit(url, { onBeforeLoad: loadWeb3(5) }));
cy.contains(".ant-btn", "Vote against payout", { timeout: 20000 }).click();
cy.contains(".ant-btn", "Revert vote against payout", { timeout: 20000 });
});
});

View File

@ -0,0 +1,87 @@
/// <reference types="cypress"/>
import {
loadWeb3,
randomString,
syncTimeWithEvm,
increaseTime
} from "../helpers";
import { createDemoProposal, fundProposal, authenticateUser } from "../parts";
describe("create.fund.ms2.no-vote.re-vote", () => {
const id = randomString();
const title = `[${id}] e2e ms2 no-vote expire re-vote`;
const amount = "1";
afterEach(function() {
if (this.currentTest.state === "failed") {
//(Cypress as any).runner.stop();
this.skip();
}
});
it("authenticates and creates if necessary", () => {
authenticateUser(cy, 0);
});
it("creates demo proposal", () => {
createDemoProposal(cy, title, amount);
});
it("fund the proposal with 5th account", () => {
fundProposal(cy, 5, 1);
cy.get(".ProposalCampaignBlock-fundingOver", { timeout: 20000 }).contains(
"Proposal has been funded"
);
});
it("receives initial payout for milestone 1", () => {
// MILESTONE 1
syncTimeWithEvm(cy);
cy.url().then(url => cy.visit(url, { onBeforeLoad: loadWeb3(0) }));
cy.get(".MilestoneAction-top > div > .ant-btn", { timeout: 20000 }).click();
cy.contains(
".MilestoneAction-top > div > .ant-btn",
"Receive initial payout",
{ timeout: 20000 }
).click();
});
it("request milestone 2 payout", () => {
// MILESTONE 2
cy.contains(
".MilestoneAction-top > div > .ant-btn",
"Request milestone payout",
{ timeout: 20000 }
).click();
cy.contains(".MilestoneAction-progress-text", "voted against payout", {
timeout: 20000
});
});
it("vote against milestone 2 payout as 5th account", () => {
// reload page with 5th account
syncTimeWithEvm(cy);
cy.url().then(url => cy.visit(url, { onBeforeLoad: loadWeb3(5) }));
cy.contains(".ant-btn", "Vote against payout", { timeout: 20000 })
.click()
.should("have.class", "ant-btn-loading");
cy.contains(".ant-btn", "Revert vote against payout", { timeout: 20000 });
});
it("milestone 2 vote expires and payout is requested again", () => {
// EXPIRE
increaseTime(cy, 70000);
cy.url().then(url => cy.visit(url, { onBeforeLoad: loadWeb3(0) }));
cy.contains("Payout was voted against");
// RE-REQUEST PAYOUT
cy.contains(
".MilestoneAction-top > div > .ant-btn",
"Request milestone payout",
{ timeout: 20000 }
).click();
// TODO: fix this bug (the following fails)
cy.contains(".MilestoneAction-progress-text", "voted against payout", {
timeout: 20000
});
});
});

View File

@ -0,0 +1,69 @@
/// <reference types="cypress"/>
import {
loadWeb3,
randomString,
syncTimeWithEvm,
increaseTime
} from "../helpers";
import { createDemoProposal, fundProposal, authenticateUser } from "../parts";
describe("create.fund.ms2.refund-after-payout", () => {
const id = randomString();
const title = `[${id}] e2e ms2 refund after payout`;
const amount = "1";
afterEach(function() {
if (this.currentTest.state === "failed") {
//(Cypress as any).runner.stop();
this.skip();
}
});
it("authenticates and creates if necessary", () => {
authenticateUser(cy, 0);
});
it("creates demo proposal", () => {
createDemoProposal(cy, title, amount);
});
it("fund the proposal with account 5", () => {
fundProposal(cy, 5, 1);
cy.get(".ProposalCampaignBlock-fundingOver", { timeout: 20000 }).contains(
"Proposal has been funded"
);
});
it("receives initial payout for milestone 1", () => {
// MILESTONE 1
syncTimeWithEvm(cy);
cy.url().then(url => cy.visit(url, { onBeforeLoad: loadWeb3(0) }));
cy.get(".MilestoneAction-top > div > .ant-btn", { timeout: 20000 }).click();
cy.contains(
".MilestoneAction-top > div > .ant-btn",
"Receive initial payout",
{ timeout: 20000 }
).click();
cy.contains(
".MilestoneAction-top > div > .ant-btn",
"Request milestone payout",
{ timeout: 20000 }
);
});
it("majority refund vote and get refund (account 5)", () => {
// REFUND
cy.url().then(url => cy.visit(url, { onBeforeLoad: loadWeb3(5) }));
cy.contains(".ant-tabs-nav > :nth-child(1) > :nth-child(4)", "Refund", {
timeout: 20000
}).click();
// INCREASE TIME
increaseTime(cy, 70000);
// force disables cypress' auto scrolling which messes up UI in this case
cy.contains(".ant-btn", "Vote for refund").click({ force: true });
cy.contains(".ant-btn", "Get your refund", { timeout: 20000 }).click({
force: true
});
cy.contains("body", "Your refund has been processed", { timeout: 20000 });
});
});

View File

@ -0,0 +1,93 @@
/// <reference types="cypress"/>
import {
loadWeb3,
randomString,
syncTimeWithEvm,
increaseTime
} from "../helpers";
import { createDemoProposal, fundProposal, authenticateUser } from "../parts";
describe("create.fund.ms2.revert-no-vote", () => {
const id = randomString();
const title = `[${id}] e2e ms2 revert no-vote`;
const amount = "1";
afterEach(function() {
if (this.currentTest.state === "failed") {
//(Cypress as any).runner.stop();
this.skip();
}
});
it("authenticates and creates if necessary", () => {
authenticateUser(cy, 0);
});
it("creates demo proposal", () => {
createDemoProposal(cy, title, amount);
});
it("funds the proposal with 5th account", () => {
fundProposal(cy, 5, 1);
cy.get(".ProposalCampaignBlock-fundingOver", { timeout: 20000 }).contains(
"Proposal has been funded"
);
});
it("receives initial payout for milestone 1", () => {
// MILESTONE 1
syncTimeWithEvm(cy);
cy.url().then(url => cy.visit(url, { onBeforeLoad: loadWeb3(0) }));
cy.get(".MilestoneAction-top > div > .ant-btn", { timeout: 20000 }).click();
cy.contains(
".MilestoneAction-top > div > .ant-btn",
"Receive initial payout",
{ timeout: 20000 }
).click();
});
it("requests milestone 2 payout", () => {
// MILESTONE 2
cy.contains(
".MilestoneAction-top > div > .ant-btn",
"Request milestone payout",
{ timeout: 20000 }
).click();
cy.contains(".MilestoneAction-progress-text", "voted against payout", {
timeout: 20000
});
});
it("votes against milestone 2 payout as account 5 and then reverts the vote", () => {
// NO VOTE... REVERT
syncTimeWithEvm(cy);
cy.url().then(url => cy.visit(url, { onBeforeLoad: loadWeb3(5) }));
cy.contains(".ant-btn", "Vote against payout", { timeout: 20000 })
.click()
.should("have.class", "ant-btn-loading");
cy.contains(".ant-btn", "Revert vote against payout", { timeout: 20000 })
.click()
.should("have.class", "ant-btn-loading");
cy.contains(".ant-btn", "Vote against payout", { timeout: 20000 });
});
it("milestone 2 vote expires and payout is received", () => {
// EXPIRE
increaseTime(cy, 70000);
// PAYOUT
cy.url().then(url => cy.visit(url, { onBeforeLoad: loadWeb3(0) }));
cy.contains(
".MilestoneAction-top > div > .ant-btn",
"Receive milestone payout"
).click();
});
it("milestone 3 becomes active", () => {
// MILESTONE 3
cy.contains(
".MilestoneAction-top > div > .ant-btn",
"Request milestone payout",
{ timeout: 20000 }
);
});
});

View File

@ -1,192 +0,0 @@
/// <reference types="cypress"/>
import { loadWeb3, randomString, randomHex } from "../helpers";
describe("create proposal", () => {
it("should load and be able to browse pages", () => {
cy.visit("http://localhost:3000/create", { onBeforeLoad: loadWeb3 });
// populate ethAccounts
cy.wait(1000);
cy.window()
.then(w => (w as any).web3.eth.getAccounts())
.as("EthAccounts");
// demo proposal
// cy.get("button.CreateFlow-footer-example").click();
const time = new Date().toLocaleString();
const id = randomString();
const randomEthHex = randomHex(32);
const nextYear = new Date().getUTCFullYear() + 1;
const proposal = {
title: "e2e - smoke - create " + time,
brief: "e2e brief",
category: "Community", // .anticon-team
targetAmount: 5,
body: `#### e2e Proposal ${id} {enter} **created** ${time} `,
team: [
{
name: "Alisha Endtoend",
title: "QA Robot0",
ethAddress: `0x0000${randomEthHex}0000`,
emailAddress: `qa.alisha.${id}@grant.io`
},
{
name: "Billy Endtoend",
title: "QA Robot1",
ethAddress: `0x1111${randomEthHex}1111`,
emailAddress: `qa.billy.${id}@grant.io`
}
],
milestones: [
{
title: `e2e Milestone ${id} 0`,
body: `e2e Milestone ${id} {enter} body 0`,
date: {
y: nextYear,
m: "Jan",
expect: "January " + nextYear
}
},
{
title: `e2e Milestone ${id} 1`,
body: `e2e Milestone ${id} {enter} body 1`,
date: {
y: nextYear,
m: "Feb",
expect: "February " + nextYear
}
}
]
};
// step 1
cy.get('.CreateFlow input[name="title"]', { timeout: 20000 }).type(
proposal.title
);
cy.get('.CreateFlow textarea[name="brief"]').type(proposal.brief);
cy.contains("Select a category").click();
cy.get(".ant-select-dropdown li .anticon-team").click();
cy.get('.CreateFlow input[name="amountToRaise"]').type(
"" + proposal.targetAmount
);
cy.get(".CreateFlow-footer-button")
.contains("Continue")
.as("Continue")
.click();
// step 2
cy.get('.TeamMember-info input[name="name"]').type(proposal.team[0].name);
cy.get('.TeamMember-info input[name="title"]').type(proposal.team[0].title);
cy.get("@EthAccounts").then(accts => {
cy.get('.TeamMember-info input[name="ethAddress"]').type(
accts[0].toString()
);
});
cy.get('.TeamMember-info input[name="emailAddress"]').type(
proposal.team[0].emailAddress
);
cy.get("button")
.contains("Save changes")
.click({ force: true });
cy.get("button.TeamForm-add").click();
cy.get('.TeamMember-info input[name="name"]').type(proposal.team[1].name);
cy.get('.TeamMember-info input[name="title"]').type(proposal.team[1].title);
cy.get("@EthAccounts").then(accts => {
cy.get('.TeamMember-info input[name="ethAddress"]').type(
accts[1].toString()
);
});
cy.get('.TeamMember-info input[name="emailAddress"]').type(
proposal.team[1].emailAddress
);
cy.get("button")
.contains("Save changes")
.click({ force: true });
cy.wait(1000);
cy.get("@Continue").click();
// step 3
cy.get(".DraftEditor-editorContainer > div").type(proposal.body);
cy.get(".mde-tabs > :nth-child(2)").click();
cy.wait(1000);
cy.get("@Continue").click();
// step 4
cy.get('input[name="title"]').type(proposal.milestones[0].title);
cy.get('textarea[name="body"]').type(proposal.milestones[0].body);
cy.get('input[placeholder="Expected completion date"]').click();
cy.get(".ant-calendar-month-panel-next-year-btn").click();
cy.get(".ant-calendar-month-panel-month")
.contains(proposal.milestones[0].date.m)
.click();
cy.get(".ant-calendar-picker-input").should(
"have.value",
proposal.milestones[0].date.expect
);
cy.get("button")
.contains("Add another milestone")
.click({ force: true });
cy.get('input[name="title"]')
.eq(1)
.type(proposal.milestones[1].title);
cy.get('textarea[name="body"]')
.eq(1)
.type(proposal.milestones[1].body);
cy.get('input[placeholder="Expected completion date"]')
.eq(1)
.click();
cy.get(".ant-calendar-month-panel-next-year-btn").click();
cy.get(".ant-calendar-month-panel-month")
.contains(proposal.milestones[1].date.m)
.click();
cy.get(".ant-calendar-picker-input")
.eq(1)
.should("have.value", proposal.milestones[1].date.expect);
cy.wait(1000);
cy.get("@Continue").click();
// step 5
cy.window()
.then(w => (w as any).web3.eth.getAccounts())
.then(accts => {
cy.get('input[name="payOutAddress"]').type(accts[0]);
cy.get("button")
.contains("Add another trustee")
.click({ force: true });
cy.get(
'input[placeholder="0x8B0B72F8bDE212991135668922fD5acE557DE6aB"]'
)
.eq(1)
.type(accts[1]);
cy.get('input[name="deadline"][value="2592000"]').click({
force: true
});
cy.get('input[name="milestoneDeadline"][value="259200"]').click({
force: true
});
});
cy.wait(1000);
cy.get("@Continue").click();
// final
cy.get("button")
.contains("Publish")
.click();
cy.get(".CreateFinal-loader-text").contains("Deploying contract...");
cy.get(".CreateFinal-message-text a", { timeout: 20000 })
.contains("Click here")
.click();
// done
cy.get(".Proposal-top-main-title").contains(proposal.title);
});
});

View File

@ -0,0 +1,15 @@
/// <reference types="cypress"/>
import {
loadWeb3,
increaseTime,
randomString,
syncTimeWithEvm
} from "../helpers";
// describe("sandbox", () => {
// it("how to increase time", () => {
// cy.visit("http://localhost:3000", { onBeforeLoad: loadWeb3(0) });
// // increase time on browser and ganache
// increaseTime(cy, 60000);
// });
// });

107
e2e/cypress/parts.ts Normal file
View File

@ -0,0 +1,107 @@
/// <reference types="cypress"/>
import { syncTimeWithEvm, loadWeb3, testAccounts } from "./helpers";
export const authenticateUser = (
cy: Cypress.Chainable,
accountIndex: number
) => {
const name = `Qual Itty ${accountIndex}`;
const ethAccount = testAccounts[accountIndex][0];
const title = `QA Robot ${accountIndex}`;
const email = `qa.robot.${accountIndex}@grant.io`;
cy.visit("http://localhost:3000", { onBeforeLoad: loadWeb3(accountIndex) });
syncTimeWithEvm(cy);
cy.get(".AuthButton").click();
cy.request({
url: `http://localhost:5000/api/v1/users/${ethAccount}`,
method: "GET",
failOnStatusCode: false
})
.its("status")
.then(status => {
if (status === 200) {
cy.contains("button", "Prove identity").click();
} else {
cy.get("input[name='name']").type(name);
cy.get("input[name='title']").type(title);
cy.get("input[name='email']").type(email);
cy.contains("button", "Claim Identity").click();
}
cy.contains(".ProfileUser", email);
});
};
export const createDemoProposal = (
cy: Cypress.Chainable,
title: string,
amount: string
) => {
cy.get('[href="/create"]').click();
// expects to be @ /create
cy.url().should("contain", "/create");
cy.log("CREATE DEMO PROPOSAL", title, amount);
// demo proposal
cy.get("button.CreateFlow-footer-example").click();
// change name
cy.get(".ant-steps > :nth-child(1)").click();
cy.get('.CreateFlow input[name="title"]', { timeout: 20000 })
.clear()
.type(title)
.blur();
cy.get('.CreateFlow input[name="amountToRaise"]')
.clear()
.type(amount)
.blur();
cy.wait(1000);
// remove extra trustees
cy.get(".ant-steps > :nth-child(5)").click();
cy.get(
":nth-child(11) > .ant-form-item-control-wrapper div > button"
).click();
cy.get(
":nth-child(10) > .ant-form-item-control-wrapper div > button"
).click();
cy.get(":nth-child(9) > .ant-form-item-control-wrapper div > button").click();
cy.get(":nth-child(8) > .ant-form-item-control-wrapper div > button").click();
cy.get(":nth-child(7) > .ant-form-item-control-wrapper div > button").click();
cy.wait(1000);
cy.get(".CreateFlow-footer-button")
.contains("Continue")
.click();
// final
cy.get("button")
.contains("Publish")
.click();
cy.get(".CreateFinal-loader-text").contains("Deploying contract...");
cy.get(".CreateFinal-message-text a", { timeout: 30000 })
.contains("Click here")
.click();
// created
cy.get(".Proposal-top-main-title").contains(title);
};
export const fundProposal = (
cy: Cypress.Chainable,
accountIndex: number,
amount: number
) => {
// expects to be @ /proposals/<proposal>
cy.url().should("contain", "/proposals/");
// reload page with accountIndex account
syncTimeWithEvm(cy);
cy.url().then(url => cy.visit(url, { onBeforeLoad: loadWeb3(accountIndex) }));
// fund proposal
cy.get(".ant-input", { timeout: 20000 }).type(amount + "");
cy.contains(".ant-form > .ant-btn", "Fund this project", { timeout: 20000 })
.click()
.should("not.have.attr", "loading");
};

View File

@ -2,3 +2,7 @@ declare module "*.json" {
const value: any;
export default value;
}
declare module "eth-sig-util" {
export function signTypedData(k: any, d: any): string;
}

View File

@ -11,6 +11,7 @@
"@cypress/webpack-preprocessor": "^2.0.1",
"@types/web3": "^1.0.3",
"cypress": "^3.1.0",
"eth-sig-util": "^2.1.0",
"ts-loader": "^5.0.0",
"typescript": "^3.0.3",
"web3": "^1.0.0-beta.36",

View File

@ -1011,6 +1011,16 @@ binary-extensions@^1.0.0:
version "1.11.0"
resolved "https://registry.yarnpkg.com/binary-extensions/-/binary-extensions-1.11.0.tgz#46aa1751fb6a2f93ee5e689bb1087d4b14c6c205"
bindings@^1.2.1:
version "1.3.0"
resolved "https://registry.yarnpkg.com/bindings/-/bindings-1.3.0.tgz#b346f6ecf6a95f5a815c5839fc7cdb22502f1ed7"
bip66@^1.1.3:
version "1.1.5"
resolved "https://registry.yarnpkg.com/bip66/-/bip66-1.1.5.tgz#01fa8748785ca70955d5011217d1b3139969ca22"
dependencies:
safe-buffer "^5.0.1"
bl@^1.0.0:
version "1.2.2"
resolved "https://registry.yarnpkg.com/bl/-/bl-1.2.2.tgz#a160911717103c07410cef63ef51b397c025af9c"
@ -1040,7 +1050,7 @@ bn.js@4.11.6:
version "4.11.6"
resolved "https://registry.yarnpkg.com/bn.js/-/bn.js-4.11.6.tgz#53344adb14617a13f6e8dd2ce28905d1c0ba3215"
bn.js@^4.0.0, bn.js@^4.1.0, bn.js@^4.1.1, bn.js@^4.11.6, bn.js@^4.4.0:
bn.js@^4.0.0, bn.js@^4.1.0, bn.js@^4.1.1, bn.js@^4.10.0, bn.js@^4.11.0, bn.js@^4.11.3, bn.js@^4.11.6, bn.js@^4.4.0, bn.js@^4.8.0:
version "4.11.8"
resolved "https://registry.yarnpkg.com/bn.js/-/bn.js-4.11.8.tgz#2cde09eb5ee341f484746bb0309b3253b1b1442f"
@ -1100,7 +1110,7 @@ brorand@^1.0.1:
version "1.1.0"
resolved "https://registry.yarnpkg.com/brorand/-/brorand-1.1.0.tgz#12c25efe40a45e3c323eb8675a0a0ce57b22371f"
browserify-aes@^1.0.0, browserify-aes@^1.0.4:
browserify-aes@^1.0.0, browserify-aes@^1.0.4, browserify-aes@^1.0.6:
version "1.2.0"
resolved "https://registry.yarnpkg.com/browserify-aes/-/browserify-aes-1.2.0.tgz#326734642f403dabc3003209853bb70ad428ef48"
dependencies:
@ -1213,7 +1223,7 @@ buffer@^4.3.0:
ieee754 "^1.1.4"
isarray "^1.0.0"
buffer@^5.0.5:
buffer@^5.0.5, buffer@^5.2.1:
version "5.2.1"
resolved "https://registry.yarnpkg.com/buffer/-/buffer-5.2.1.tgz#dd57fa0f109ac59c602479044dca7b8b3d0b71d6"
dependencies:
@ -1779,6 +1789,14 @@ domain-browser@^1.1.1:
version "1.2.0"
resolved "https://registry.yarnpkg.com/domain-browser/-/domain-browser-1.2.0.tgz#3d31f50191a6749dd1375a7f522e823d42e54eda"
drbg.js@^1.0.1:
version "1.0.1"
resolved "https://registry.yarnpkg.com/drbg.js/-/drbg.js-1.0.1.tgz#3e36b6c42b37043823cdbc332d58f31e2445480b"
dependencies:
browserify-aes "^1.0.6"
create-hash "^1.1.2"
create-hmac "^1.1.4"
duplexer3@^0.1.4:
version "0.1.4"
resolved "https://registry.yarnpkg.com/duplexer3/-/duplexer3-0.1.4.tgz#ee01dd1cac0ed3cbc7fdbea37dc0a8f1ce002ce2"
@ -1820,7 +1838,7 @@ elliptic@6.3.3:
hash.js "^1.0.0"
inherits "^2.0.1"
elliptic@^6.0.0, elliptic@^6.4.0:
elliptic@^6.0.0, elliptic@^6.2.3, elliptic@^6.4.0:
version "6.4.1"
resolved "https://registry.yarnpkg.com/elliptic/-/elliptic-6.4.1.tgz#c2d0b7776911b86722c632c3c06c60f2f819939a"
dependencies:
@ -1920,6 +1938,46 @@ eth-lib@0.2.7:
elliptic "^6.4.0"
xhr-request-promise "^0.1.2"
eth-sig-util@^2.1.0:
version "2.1.0"
resolved "https://registry.yarnpkg.com/eth-sig-util/-/eth-sig-util-2.1.0.tgz#33e60e5486897a2ddeb4bf5a0993b2c6d5cc9e19"
dependencies:
buffer "^5.2.1"
elliptic "^6.4.0"
ethereumjs-abi "0.6.5"
ethereumjs-util "^5.1.1"
tweetnacl "^1.0.0"
tweetnacl-util "^0.15.0"
ethereumjs-abi@0.6.5:
version "0.6.5"
resolved "https://registry.yarnpkg.com/ethereumjs-abi/-/ethereumjs-abi-0.6.5.tgz#5a637ef16ab43473fa72a29ad90871405b3f5241"
dependencies:
bn.js "^4.10.0"
ethereumjs-util "^4.3.0"
ethereumjs-util@^4.3.0:
version "4.5.0"
resolved "http://registry.npmjs.org/ethereumjs-util/-/ethereumjs-util-4.5.0.tgz#3e9428b317eebda3d7260d854fddda954b1f1bc6"
dependencies:
bn.js "^4.8.0"
create-hash "^1.1.2"
keccakjs "^0.2.0"
rlp "^2.0.0"
secp256k1 "^3.0.1"
ethereumjs-util@^5.1.1:
version "5.2.0"
resolved "https://registry.yarnpkg.com/ethereumjs-util/-/ethereumjs-util-5.2.0.tgz#3e0c0d1741471acf1036052d048623dee54ad642"
dependencies:
bn.js "^4.11.0"
create-hash "^1.1.2"
ethjs-util "^0.1.3"
keccak "^1.0.2"
rlp "^2.0.0"
safe-buffer "^5.1.1"
secp256k1 "^3.0.1"
ethers@4.0.0-beta.1:
version "4.0.0-beta.1"
resolved "https://registry.yarnpkg.com/ethers/-/ethers-4.0.0-beta.1.tgz#0648268b83e0e91a961b1af971c662cdf8cbab6d"
@ -1942,6 +2000,13 @@ ethjs-unit@0.1.6:
bn.js "4.11.6"
number-to-bn "1.7.0"
ethjs-util@^0.1.3:
version "0.1.6"
resolved "https://registry.yarnpkg.com/ethjs-util/-/ethjs-util-0.1.6.tgz#f308b62f185f9fe6237132fb2a9818866a5cd536"
dependencies:
is-hex-prefixed "1.0.0"
strip-hex-prefix "1.0.0"
eventemitter3@1.1.1:
version "1.1.1"
resolved "https://registry.yarnpkg.com/eventemitter3/-/eventemitter3-1.1.1.tgz#47786bdaa087caf7b1b75e73abc5c7d540158cd0"
@ -2872,7 +2937,16 @@ jsprim@^1.2.2:
json-schema "0.2.3"
verror "1.10.0"
keccakjs@^0.2.1:
keccak@^1.0.2:
version "1.4.0"
resolved "https://registry.yarnpkg.com/keccak/-/keccak-1.4.0.tgz#572f8a6dbee8e7b3aa421550f9e6408ca2186f80"
dependencies:
bindings "^1.2.1"
inherits "^2.0.3"
nan "^2.2.1"
safe-buffer "^5.1.0"
keccakjs@^0.2.0, keccakjs@^0.2.1:
version "0.2.1"
resolved "https://registry.yarnpkg.com/keccakjs/-/keccakjs-0.2.1.tgz#1d633af907ef305bbf9f2fa616d56c44561dfa4d"
dependencies:
@ -3238,6 +3312,10 @@ nan@^2.0.8, nan@^2.3.3, nan@^2.9.2:
version "2.11.0"
resolved "https://registry.yarnpkg.com/nan/-/nan-2.11.0.tgz#574e360e4d954ab16966ec102c0c049fd961a099"
nan@^2.2.1:
version "2.11.1"
resolved "https://registry.yarnpkg.com/nan/-/nan-2.11.1.tgz#90e22bccb8ca57ea4cd37cc83d3819b52eea6766"
nano-json-stream-parser@^0.1.2:
version "0.1.2"
resolved "https://registry.yarnpkg.com/nano-json-stream-parser/-/nano-json-stream-parser-0.1.2.tgz#0cc8f6d0e2b622b479c40d499c46d64b755c6f5f"
@ -3926,6 +4004,12 @@ ripemd160@^2.0.0, ripemd160@^2.0.1:
hash-base "^3.0.0"
inherits "^2.0.1"
rlp@^2.0.0:
version "2.1.0"
resolved "https://registry.yarnpkg.com/rlp/-/rlp-2.1.0.tgz#e4f9886d5a982174f314543831e36e1a658460f9"
dependencies:
safe-buffer "^5.1.1"
run-queue@^1.0.0, run-queue@^1.0.3:
version "1.0.3"
resolved "https://registry.yarnpkg.com/run-queue/-/run-queue-1.0.3.tgz#e848396f057d223f24386924618e25694161ec47"
@ -3990,6 +4074,19 @@ scryptsy@^1.2.1:
dependencies:
pbkdf2 "^3.0.3"
secp256k1@^3.0.1:
version "3.5.2"
resolved "https://registry.yarnpkg.com/secp256k1/-/secp256k1-3.5.2.tgz#f95f952057310722184fe9c914e6b71281f2f2ae"
dependencies:
bindings "^1.2.1"
bip66 "^1.1.3"
bn.js "^4.11.3"
create-hash "^1.1.2"
drbg.js "^1.0.1"
elliptic "^6.2.3"
nan "^2.2.1"
safe-buffer "^5.1.0"
seek-bzip@^1.0.5:
version "1.0.5"
resolved "https://registry.yarnpkg.com/seek-bzip/-/seek-bzip-1.0.5.tgz#cfe917cb3d274bcffac792758af53173eb1fabdc"
@ -4526,10 +4623,18 @@ tunnel-agent@^0.6.0:
dependencies:
safe-buffer "^5.0.1"
tweetnacl-util@^0.15.0:
version "0.15.0"
resolved "https://registry.yarnpkg.com/tweetnacl-util/-/tweetnacl-util-0.15.0.tgz#4576c1cee5e2d63d207fee52f1ba02819480bc75"
tweetnacl@^0.14.3, tweetnacl@~0.14.0:
version "0.14.5"
resolved "https://registry.yarnpkg.com/tweetnacl/-/tweetnacl-0.14.5.tgz#5ae68177f192d4456269d108afa93ff8743f4f64"
tweetnacl@^1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/tweetnacl/-/tweetnacl-1.0.0.tgz#713d8b818da42068740bf68386d0479e66fc8a7b"
type-is@~1.6.15, type-is@~1.6.16:
version "1.6.16"
resolved "https://registry.yarnpkg.com/type-is/-/type-is-1.6.16.tgz#f89ce341541c672b25ee7ae3c73dee3b2be50194"

View File

@ -4,5 +4,19 @@ FUND_ETH_ADDRESSES=0x4bbeEB066eD09B7AEd07bF39EEe0460DFa261520,0xDECAF9CD2367cdbb
# Disable typescript checking for dev building (reduce build time & resource usage)
NO_DEV_TS_CHECK=true
NODE_ENV=development
# Set the public host url (no trailing slash)
PUBLIC_HOST_URL=https://demo.grant.io
PUBLIC_HOST_URL=https://demo.grant.io
BACKEND_URL=http://localhost:5000
# sentry
SENTRY_DSN=https://PUBLICKEY@sentry.io/PROJECTID
SENTRY_RELEASE="optional, overrides git hash"
# CROWD_FUND_URL=https://eip-712.herokuapp.com/contract/crowd-fund
# CROWD_FUND_FACTORY_URL=https://eip-712.herokuapp.com/contract/factory
CROWD_FUND_URL=http://localhost:5000/dev-contracts/CrowdFund.json
CROWD_FUND_FACTORY_URL=http://localhost:5000/dev-contracts/CrowdFundFactory.json

4
frontend/.nowignore Normal file
View File

@ -0,0 +1,4 @@
node_modules
stories
.storybook
dist

View File

@ -1 +1 @@
8.11.4
8.13.0

1
frontend/Procfile Normal file
View File

@ -0,0 +1 @@
web: yarn start

View File

@ -12,16 +12,30 @@ require('../config/env');
module.exports = {};
const CHECK_CONTRACT_IDS = ['CrowdFundFactory.json']
const clean = (module.exports.clean = () => {
rimraf.sync(paths.contractsBuild);
});
const compile = (module.exports.compile = () => {
childProcess.execSync('yarn build', { cwd: paths.contractsBase });
logMessage('truffle compile, please wait...', 'info');
try {
childProcess.execSync('yarn build', { cwd: paths.contractsBase });
} catch (e) {
logMessage(e.stdout.toString('utf8'), 'error');
process.exit(1);
}
});
const migrate = (module.exports.migrate = () => {
childProcess.execSync('truffle migrate', { cwd: paths.contractsBase });
logMessage('truffle migrate, please wait...', 'info');
try {
childProcess.execSync('truffle migrate', { cwd: paths.contractsBase });
} catch (e) {
logMessage(e.stdout.toString('utf8'), 'error');
process.exit(1);
}
});
const makeWeb3Conn = () => {
@ -62,20 +76,41 @@ const getGanacheNetworkId = (module.exports.getGanacheNetworkId = () => {
.catch(() => -1);
});
const checkContractsNetworkIds = (module.exports.checkContractsNetworkIds = id =>
const checkContractsNetworkIds = (module.exports.checkContractsNetworkIds = (
id,
retry = false,
) =>
new Promise((res, rej) => {
const buildDir = paths.contractsBuild;
fs.readdir(buildDir, (err, names) => {
fs.readdir(buildDir, (err) => {
if (err) {
logMessage(`No contracts build directory @ ${buildDir}`, 'error');
res(false);
} else {
const allHaveId = names.reduce((ok, name) => {
const allHaveId = CHECK_CONTRACT_IDS.reduce((ok, name) => {
const contract = require(path.join(buildDir, name));
if (Object.keys(contract.networks).length > 0 && !contract.networks[id]) {
const actual = Object.keys(contract.networks).join(', ');
logMessage(`${name} should have networks[${id}], it has ${actual}`, 'error');
return false;
const contractHasKeys = Object.keys(contract.networks).length > 0;
if (!contractHasKeys) {
if (retry) {
logMessage(
'Contract does not contain network keys after retry. Exiting. Please manually debug Contract JSON.',
'error',
);
process.exit(1);
} else {
logMessage('Contract does not contain any keys. Will migrate.');
migrate();
return checkContractsNetworkIds(id, true);
}
} else {
if (contractHasKeys && !contract.networks[id]) {
const actual = Object.keys(contract.networks).join(', ');
logMessage(
`${name} should have networks[${id}], it has ${actual}`,
'error',
);
return false;
}
}
return true && ok;
}, true);
@ -128,9 +163,7 @@ module.exports.ethereumCheck = () =>
if (!allHaveId) {
logMessage('Contract problems, will compile & migrate.', 'warning');
clean();
logMessage('truffle compile, please wait...', 'info');
compile();
logMessage('truffle migrate, please wait...', 'info');
migrate();
fundWeb3v1();
} else {

View File

@ -75,7 +75,7 @@ const routeConfigs: RouteConfig[] = [
},
template: {
title: 'Browse proposals',
requiresWeb3: true,
requiresWeb3: false,
},
},
{
@ -86,7 +86,7 @@ const routeConfigs: RouteConfig[] = [
},
template: {
title: 'Proposal',
requiresWeb3: true,
requiresWeb3: false,
},
},
{

View File

@ -1,27 +1,22 @@
import axios from './axios';
import { Proposal, TeamMember, Update } from 'types';
import { Proposal, TeamMember, Update, Contribution } from 'types';
import {
formatProposalFromGet,
formatTeamMemberForPost,
formatTeamMemberFromGet,
generateProposalUrl,
} from 'utils/api';
import { PROPOSAL_CATEGORY } from './constants';
export function getProposals(): Promise<{ data: Proposal[] }> {
return axios.get('/api/v1/proposals/').then(res => {
res.data = res.data.map((proposal: any) => {
proposal.team = proposal.team.map(formatTeamMemberFromGet);
proposal.proposalUrlId = generateProposalUrl(proposal.proposalId, proposal.title);
return proposal;
});
res.data = res.data.map(formatProposalFromGet);
return res;
});
}
export function getProposal(proposalId: number | string): Promise<{ data: Proposal }> {
return axios.get(`/api/v1/proposals/${proposalId}`).then(res => {
res.data.team = res.data.team.map(formatTeamMemberFromGet);
res.data.proposalUrlId = generateProposalUrl(res.data.proposalId, res.data.title);
res.data = formatProposalFromGet(res.data);
return res;
});
}
@ -96,6 +91,16 @@ export function verifyEmail(code: string): Promise<any> {
return axios.post(`/api/v1/email/${code}/verify`);
}
export async function fetchCrowdFundFactoryJSON(): Promise<any> {
const res = await axios.get(process.env.CROWD_FUND_FACTORY_URL as string);
return res.data;
}
export async function fetchCrowdFundJSON(): Promise<any> {
const res = await axios.get(process.env.CROWD_FUND_URL as string);
return res.data;
}
export function postProposalUpdate(
proposalId: number,
title: string,
@ -107,6 +112,19 @@ export function postProposalUpdate(
});
}
export function postProposalContribution(
proposalId: number,
txId: string,
fromAddress: string,
amount: string,
): Promise<{ data: Contribution }> {
return axios.post(`/api/v1/proposals/${proposalId}/contributions`, {
txId,
fromAddress,
amount,
});
}
export function postProposalComment(payload: {
proposalId: number;
parentCommentId?: number;

View File

@ -0,0 +1,32 @@
.MetaMaskRequiredButton {
display: block;
text-align: center;
padding: 0;
height: 3rem;
line-height: 3rem;
font-size: 1.2rem;
color: #fff;
background: #f88500;
border-radius: 4px;
&:hover {
color: #fff;
opacity: 0.8;
}
&-logo {
display: inline-block;
vertical-align: middle;
height: 2rem;
width: 2rem;
padding: 0.4rem;
border-radius: 1.25rem;
background: rgba(255, 255, 255, 0.6);
margin: 0 1rem 0 0;
& > img {
display: block;
height: 1.2rem;
}
}
}

View File

@ -0,0 +1,82 @@
import React from 'react';
import { connect } from 'react-redux';
import { AppState } from 'store/reducers';
import { web3Actions } from 'modules/web3';
import { Alert } from 'antd';
import metaMaskImgSrc from 'static/images/metamask.png';
import './index.less';
interface OwnProps {
message: React.ReactNode;
}
interface StateProps {
isMissingWeb3: boolean;
isWeb3Locked: boolean;
isWrongNetwork: boolean;
}
interface DispatchProps {
setAccounts: typeof web3Actions['setAccounts'];
}
type Props = OwnProps & StateProps & DispatchProps;
class MetaMaskRequiredButton extends React.PureComponent<Props> {
render() {
const { isMissingWeb3, isWeb3Locked, isWrongNetwork, children, message } = this.props;
const displayMessage =
((isMissingWeb3 || isWeb3Locked || isWrongNetwork) && message) || null;
return (
<>
{displayMessage}
{isMissingWeb3 ? (
<a
className="MetaMaskRequiredButton"
href="https://metamask.io/"
target="_blank"
rel="noopener nofollow"
>
<div className="MetaMaskRequiredButton-logo">
<img src={metaMaskImgSrc} />
</div>
MetaMask required
</a>
) : isWeb3Locked ? (
<Alert
type="warning"
message={
<>
It looks like your MetaMask account is locked. Please unlock it and{' '}
<a onClick={this.props.setAccounts}>click here to continue</a>.
</>
}
/>
) : isWrongNetwork ? (
<Alert
type="warning"
message={
<>
The Grant.io smart contract is currently only supported on the{' '}
<strong>Ropsten</strong> network. Please change your network to continue.
</>
}
/>
) : (
children
)}
</>
);
}
}
export default connect<StateProps, DispatchProps, OwnProps, AppState>(
state => ({
isMissingWeb3: state.web3.isMissingWeb3,
isWeb3Locked: state.web3.isWeb3Locked,
isWrongNetwork: state.web3.isWrongNetwork,
}),
{
setAccounts: web3Actions.setAccounts,
},
)(MetaMaskRequiredButton);

View File

@ -0,0 +1,74 @@
@small-query: ~'(max-width: 500px)';
.AvatarEdit {
&-avatar {
position: relative;
height: 10.5rem;
width: 10.5rem;
margin-right: 1.25rem;
align-self: start;
@media @small-query {
margin-bottom: 1rem;
}
&-img {
height: 100%;
width: 100%;
border-radius: 1rem;
}
&-change {
position: absolute;
display: flex;
flex-flow: column;
justify-content: center;
align-items: center;
color: #ffffff;
font-size: 1.2rem;
font-weight: 600;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.4);
border-radius: 1rem;
border: none;
cursor: pointer;
&:hover,
&:hover:focus {
background: rgba(0, 0, 0, 0.6);
color: #ffffff;
border: none;
}
&:focus {
background: rgba(0, 0, 0, 0.4);
color: #ffffff;
border: none;
}
&-icon {
font-size: 2rem;
}
}
&-delete {
position: absolute;
top: 0.2rem;
right: 0.2rem;
cursor: pointer;
color: #ffffff;
background: transparent;
border: none;
&:hover,
&:hover:focus {
background: rgba(0, 0, 0, 0.4);
color: #ffffff;
border: none;
}
}
}
}

View File

@ -0,0 +1,176 @@
import React from 'react';
import axios from 'api/axios';
import { Upload, Icon, Modal, Button, Alert } from 'antd';
import Cropper from 'react-cropper';
import 'cropperjs/dist/cropper.css';
import { UploadFile } from 'antd/lib/upload/interface';
import { TeamMember } from 'types';
import { getBase64 } from 'utils/blob';
import UserAvatar from 'components/UserAvatar';
import './AvatarEdit.less';
const FILE_TYPES = ['image/jpeg', 'image/png', 'image/gif'];
const FILE_MAX_LOAD_MB = 10;
interface OwnProps {
user: TeamMember;
onDelete(): void;
onDone(url: string): void;
}
const initialState = {
isUploading: false,
showModal: false,
newAvatarUrl: '',
loadError: '',
uploadError: '',
};
type State = typeof initialState;
type Props = OwnProps;
export default class AvatarEdit extends React.PureComponent<Props, State> {
state = initialState;
cropperRef: React.RefObject<any>;
constructor(props: Props) {
super(props);
this.cropperRef = React.createRef();
}
render() {
const { newAvatarUrl, showModal, loadError, uploadError, isUploading } = this.state;
const {
user,
user: { avatarUrl },
} = this.props;
return (
<>
{' '}
<div className="AvatarEdit-avatar">
<UserAvatar className="AvatarEdit-avatar-img" user={user} />
<Upload
name="avatar"
showUploadList={false}
action={this.handleLoad}
beforeUpload={this.beforeLoad}
onChange={this.handleLoadChange}
>
<Button className="AvatarEdit-avatar-change">
<Icon
className="AvatarEdit-avatar-change-icon"
type={avatarUrl ? 'picture' : 'plus-circle'}
/>
<div>{avatarUrl ? 'Change photo' : 'Add photo'}</div>
</Button>
</Upload>
{avatarUrl && (
<Button
className="AvatarEdit-avatar-delete"
icon="delete"
shape="circle"
onClick={this.props.onDelete}
/>
)}
{loadError && (
<Alert message={loadError} type="error" style={{ margin: '0.5rem 0 0 0' }} />
)}
</div>
<Modal
title="Prepare your avatar"
visible={showModal}
footer={[
<Button key="back" onClick={this.handleClose}>
Cancel
</Button>,
<Button
key="submit"
type="primary"
loading={isUploading}
onClick={this.handleUpload}
>
Upload
</Button>,
]}
>
<Cropper
ref={this.cropperRef}
src={newAvatarUrl}
style={{ height: 300 }}
aspectRatio={1}
guides={false}
viewMode={1}
/>
{uploadError && (
<Alert
message={uploadError}
type="error"
style={{ margin: '0.5rem 0 0 0' }}
/>
)}
</Modal>
</>
);
}
private handleClose = () => {
this.setState({
isUploading: false,
showModal: false,
newAvatarUrl: '',
uploadError: '',
});
};
private handleLoadChange = (info: any) => {
if (info.file.status === 'done') {
getBase64(info.file.originFileObj, newAvatarUrl =>
this.setState({
newAvatarUrl,
}),
);
}
};
private beforeLoad = (file: UploadFile) => {
this.setState({ loadError: '' });
const isTypeOk = !!FILE_TYPES.find(t => t === file.type);
if (!isTypeOk) {
this.setState({ loadError: 'File must be a jpg, png or gif' });
}
const isSizeOk = file.size / 1024 / 1024 < FILE_MAX_LOAD_MB;
if (!isSizeOk) {
this.setState({
loadError: `File size must be less than ${FILE_MAX_LOAD_MB}MB`,
});
}
return isTypeOk && isSizeOk;
};
private handleLoad = () => {
this.setState({ showModal: true });
return Promise.resolve();
};
private handleUpload = () => {
this.cropperRef.current
.getCroppedCanvas({ width: 400, height: 400 })
.toBlob((blob: Blob) => {
const formData = new FormData();
formData.append('file', blob);
this.setState({ isUploading: true });
axios
.post('/api/v1/users/avatar', formData, {
headers: {
'Content-Type': 'multipart/form-data',
},
})
.then(res => {
this.props.onDone(res.data.url);
this.handleClose();
})
.catch(err => {
this.setState({ isUploading: false, uploadError: err.message });
});
});
};
}

View File

@ -7,12 +7,12 @@
bottom: 0;
left: 0;
background: rgba(0, 0, 0, 0.3);
z-index: 1000;
z-index: 900;
}
.ProfileEdit {
position: relative;
z-index: 1001;
z-index: 901;
display: flex;
align-items: center;
padding: 1rem;
@ -28,77 +28,6 @@
align-items: flex-start;
}
&-avatar {
position: relative;
height: 10.5rem;
width: 10.5rem;
margin-right: 1.25rem;
align-self: start;
@media @small-query {
margin-bottom: 1rem;
}
&-img {
height: 100%;
width: 100%;
border-radius: 1rem;
}
&-change {
position: absolute;
display: flex;
flex-flow: column;
justify-content: center;
align-items: center;
color: #ffffff;
font-size: 1.2rem;
font-weight: 600;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.4);
border-radius: 1rem;
border: none;
cursor: pointer;
&:hover,
&:hover:focus {
background: rgba(0, 0, 0, 0.6);
color: #ffffff;
border: none;
}
&:focus {
background: rgba(0, 0, 0, 0.4);
color: #ffffff;
border: none;
}
&-icon {
font-size: 2rem;
}
}
&-delete {
position: absolute;
top: 0.2rem;
right: 0.2rem;
cursor: pointer;
color: #ffffff;
background: transparent;
border: none;
&:hover,
&:hover:focus {
background: rgba(0, 0, 0, 0.4);
color: #ffffff;
border: none;
}
}
}
&-info {
flex: 1;

View File

@ -1,11 +1,12 @@
import React from 'react';
import lodash from 'lodash';
import { Input, Form, Col, Row, Button, Icon, Alert } from 'antd';
import axios from 'api/axios';
import { Input, Form, Col, Row, Button, Alert } from 'antd';
import { SOCIAL_INFO } from 'utils/social';
import { SOCIAL_TYPE, TeamMember } from 'types';
import { UserState } from 'modules/users/reducers';
import { getCreateTeamMemberError } from 'modules/create/utils';
import UserAvatar from 'components/UserAvatar';
import AvatarEdit from './AvatarEdit';
import './ProfileEdit.less';
interface Props {
@ -54,27 +55,12 @@ export default class ProfileEdit extends React.PureComponent<Props, State> {
return (
<>
<div className="ProfileEdit">
<div className="ProfileEdit-avatar">
<UserAvatar className="ProfileEdit-avatar-img" user={fields} />
<Button
className="ProfileEdit-avatar-change"
onClick={this.handleChangePhoto}
>
<Icon
className="ProfileEdit-avatar-change-icon"
type={fields.avatarUrl ? 'picture' : 'plus-circle'}
/>
<div>{fields.avatarUrl ? 'Change photo' : 'Add photo'}</div>
</Button>
{fields.avatarUrl && (
<Button
className="ProfileEdit-avatar-delete"
icon="delete"
shape="circle"
onClick={this.handleDeletePhoto}
/>
)}
</div>
<AvatarEdit
user={fields}
onDone={this.handleChangePhoto}
onDelete={this.handleDeletePhoto}
/>
<div className="ProfileEdit-info">
<Form
className="ProfileEdit-info-form"
@ -187,6 +173,13 @@ export default class ProfileEdit extends React.PureComponent<Props, State> {
};
private handleCancel = () => {
const { avatarUrl } = this.state.fields;
// cleanup uploaded file if we cancel
if (this.props.user.avatarUrl !== avatarUrl && avatarUrl) {
axios.delete('/api/v1/users/avatar', {
params: { url: avatarUrl },
});
}
this.props.onDone();
};
@ -226,13 +219,10 @@ export default class ProfileEdit extends React.PureComponent<Props, State> {
});
};
private handleChangePhoto = () => {
// TODO: Actual file uploading
const gender = ['men', 'women'][Math.floor(Math.random() * 2)];
const num = Math.floor(Math.random() * 80);
private handleChangePhoto = (url: string) => {
const fields = {
...this.state.fields,
avatarUrl: `https://randomuser.me/api/portraits/${gender}/${num}.jpg`,
avatarUrl: url,
};
const isChanged = this.isChangedCheck(fields);
this.setState({

View File

@ -4,17 +4,17 @@ import { Spin, Form, Input, Button, Icon } from 'antd';
import { ProposalWithCrowdFund } from 'types';
import './style.less';
import classnames from 'classnames';
import { fromWei } from 'utils/units';
import { connect } from 'react-redux';
import { compose } from 'recompose';
import { AppState } from 'store/reducers';
import { web3Actions } from 'modules/web3';
import { withRouter } from 'react-router';
import Web3Container, { Web3RenderProps } from 'lib/Web3Container';
import ShortAddress from 'components/ShortAddress';
import UnitDisplay from 'components/UnitDisplay';
import { getAmountError } from 'utils/validators';
import { CATEGORY_UI } from 'api/constants';
import MetaMaskRequiredButton from 'components/MetaMaskRequiredButton';
interface OwnProps {
proposal: ProposalWithCrowdFund;
@ -29,11 +29,7 @@ interface ActionProps {
fundCrowdFund: typeof web3Actions['fundCrowdFund'];
}
interface Web3Props {
web3: Web3RenderProps['web3'];
}
type Props = OwnProps & StateProps & ActionProps & Web3Props;
type Props = OwnProps & StateProps & ActionProps;
interface State {
amountToRaise: string;
@ -58,7 +54,7 @@ export class ProposalCampaignBlock extends React.Component<Props, State> {
return;
}
const { proposal, web3 } = this.props;
const { proposal } = this.props;
const { crowdFund } = proposal;
const remainingTarget = crowdFund.target.sub(crowdFund.funded);
const amount = parseFloat(value);
@ -67,7 +63,7 @@ export class ProposalCampaignBlock extends React.Component<Props, State> {
if (Number.isNaN(amount)) {
// They're entering some garbage, theyll work it out
} else {
const remainingEthNum = parseFloat(web3.utils.fromWei(remainingTarget, 'ether'));
const remainingEthNum = parseFloat(fromWei(remainingTarget, 'ether'));
amountError = getAmountError(amount, remainingEthNum);
}
@ -82,7 +78,7 @@ export class ProposalCampaignBlock extends React.Component<Props, State> {
};
render() {
const { proposal, sendLoading, web3, isPreview } = this.props;
const { proposal, sendLoading, isPreview } = this.props;
const { amountToRaise, amountError } = this.state;
const amountFloat = parseFloat(amountToRaise) || 0;
let content;
@ -94,7 +90,7 @@ export class ProposalCampaignBlock extends React.Component<Props, State> {
crowdFund.isFrozen;
const isDisabled = isFundingOver || !!amountError || !amountFloat || isPreview;
const remainingEthNum = parseFloat(
web3.utils.fromWei(crowdFund.target.sub(crowdFund.funded), 'ether'),
fromWei(crowdFund.target.sub(crowdFund.funded), 'ether'),
);
content = (
@ -166,37 +162,51 @@ export class ProposalCampaignBlock extends React.Component<Props, State> {
}}
/>
</div>
<Form layout="vertical">
<Form.Item
validateStatus={amountError ? 'error' : undefined}
help={amountError}
style={{ marginBottom: '0.5rem', paddingBottom: 0 }}
>
<Input
size="large"
name="amountToRaise"
type="number"
value={amountToRaise}
placeholder="0.5"
min={0}
max={remainingEthNum}
step={0.1}
onChange={this.handleAmountChange}
addonAfter="ETH"
disabled={isPreview}
/>
</Form.Item>
<Button
onClick={this.sendTransaction}
size="large"
type="primary"
disabled={isDisabled}
loading={sendLoading}
block
<Form layout="vertical">
<MetaMaskRequiredButton
message={
<Form.Item style={{ marginBottom: '0.5rem', paddingBottom: 0 }}>
<Input
size="large"
type="number"
placeholder="0.5"
addonAfter="ETH"
disabled={true}
/>
</Form.Item>
}
>
Fund this project
</Button>
<Form.Item
validateStatus={amountError ? 'error' : undefined}
help={amountError}
style={{ marginBottom: '0.5rem', paddingBottom: 0 }}
>
<Input
size="large"
name="amountToRaise"
type="number"
value={amountToRaise}
placeholder="0.5"
min={0}
max={remainingEthNum}
step={0.1}
onChange={this.handleAmountChange}
addonAfter="ETH"
disabled={isPreview}
/>
</Form.Item>
<Button
onClick={this.sendTransaction}
size="large"
type="primary"
disabled={isDisabled}
loading={sendLoading}
block
>
Fund this project
</Button>
</MetaMaskRequiredButton>
</Form>
</>
)}
@ -226,21 +236,9 @@ const withConnect = connect(
{ fundCrowdFund: web3Actions.fundCrowdFund },
);
const ConnectedProposalCampaignBlock = compose<Props, OwnProps & Web3Props>(
const ConnectedProposalCampaignBlock = compose<Props, OwnProps>(
withRouter,
withConnect,
)(ProposalCampaignBlock);
export default (props: OwnProps) => (
<Web3Container
renderLoading={() => (
<div className="ProposalCampaignBlock Proposal-top-side-block">
<h1 className="Proposal-top-main-block-title">Campaign</h1>
<div className="Proposal-top-main-block">
<Spin />
</div>
</div>
)}
render={({ web3 }) => <ConnectedProposalCampaignBlock {...props} web3={web3} />}
/>
);
export default ConnectedProposalCampaignBlock;

View File

@ -18,12 +18,11 @@ import ContributorsTab from './Contributors';
// import CommunityTab from './Community';
import UpdateModal from './UpdateModal';
import CancelModal from './CancelModal';
import './style.less';
import classnames from 'classnames';
import { withRouter } from 'react-router';
import Web3Container from 'lib/Web3Container';
import { web3Actions } from 'modules/web3';
import SocialShare from 'components/SocialShare';
import './style.less';
interface OwnProps {
proposalId: number;
@ -32,17 +31,14 @@ interface OwnProps {
interface StateProps {
proposal: ProposalWithCrowdFund | null;
account: string | null;
}
interface DispatchProps {
fetchProposal: proposalActions.TFetchProposal;
}
interface Web3Props {
account: string;
}
type Props = StateProps & DispatchProps & Web3Props & OwnProps;
type Props = StateProps & DispatchProps & OwnProps;
interface State {
isBodyExpanded: boolean;
@ -67,11 +63,15 @@ export class ProposalDetail extends React.Component<Props, State> {
} else {
this.checkBodyOverflow();
}
window.addEventListener('resize', this.checkBodyOverflow);
if (typeof window !== 'undefined') {
window.addEventListener('resize', this.checkBodyOverflow);
}
}
componentWillUnmount() {
window.removeEventListener('resize', this.checkBodyOverflow);
if (typeof window !== 'undefined') {
window.removeEventListener('resize', this.checkBodyOverflow);
}
}
componentDidUpdate() {
@ -95,7 +95,7 @@ export class ProposalDetail extends React.Component<Props, State> {
return <Spin />;
} else {
const { crowdFund } = proposal;
const isTrustee = crowdFund.trustees.includes(account);
const isTrustee = !!account && crowdFund.trustees.includes(account);
const isContributor = !!crowdFund.contributors.find(c => c.address === account);
const hasBeenFunded = crowdFund.isRaiseGoalReached;
const isProposalActive = !hasBeenFunded && crowdFund.deadline > Date.now();
@ -125,7 +125,7 @@ export class ProposalDetail extends React.Component<Props, State> {
<div className="Proposal-top">
<div className="Proposal-top-social">
<SocialShare
url={window.location.href}
url={(typeof window !== 'undefined' && window.location.href) || ''}
title={`${proposal.title} needs funding on Grant-io!`}
text={`${
proposal.title
@ -251,6 +251,7 @@ export class ProposalDetail extends React.Component<Props, State> {
function mapStateToProps(state: AppState, ownProps: OwnProps) {
return {
proposal: getProposal(state, ownProps.proposalId),
account: (state.web3.accounts.length && state.web3.accounts[0]) || null,
};
}
@ -263,22 +264,9 @@ const withConnect = connect<StateProps, DispatchProps, OwnProps, AppState>(
mapDispatchToProps,
);
const ConnectedProposal = compose<Props, OwnProps & Web3Props>(
const ConnectedProposal = compose<Props, OwnProps>(
withRouter,
withConnect,
)(ProposalDetail);
export default (props: OwnProps) => (
<Web3Container
renderLoading={() => (
<div className="Proposal">
<div className="Proposal-top">
<div className="Proposal-top-main">
<Spin />
</div>
</div>
</div>
)}
render={({ accounts }) => <ConnectedProposal account={accounts[0]} {...props} />}
/>
);
export default ConnectedProposal;

View File

@ -1,23 +1,15 @@
import React from 'react';
import classnames from 'classnames';
import { Progress, Icon, Spin } from 'antd';
import { Progress, Icon } from 'antd';
import moment from 'moment';
import { Redirect } from 'react-router-dom';
import { CATEGORY_UI } from 'api/constants';
import { ProposalWithCrowdFund } from 'types';
import './style.less';
import { Dispatch, bindActionCreators } from 'redux';
import * as web3Actions from 'modules/web3/actions';
import { AppState } from 'store/reducers';
import { connect } from 'react-redux';
import UserAvatar from 'components/UserAvatar';
import UnitDisplay from 'components/UnitDisplay';
import './style.less';
interface Props extends ProposalWithCrowdFund {
web3: AppState['web3']['web3'];
}
export class ProposalCard extends React.Component<Props> {
export class ProposalCard extends React.Component<ProposalWithCrowdFund> {
state = { redirect: '' };
render() {
if (this.state.redirect) {
@ -29,84 +21,66 @@ export class ProposalCard extends React.Component<Props> {
proposalUrlId,
category,
dateCreated,
web3,
crowdFund,
team,
} = this.props;
if (!web3) {
return <Spin />;
} else {
return (
<div
className="ProposalCard"
onClick={() => this.setState({ redirect: `/proposals/${proposalUrlId}` })}
>
<h3 className="ProposalCard-title">{title}</h3>
<div className="ProposalCard-funding">
<div className="ProposalCard-funding-raised">
<UnitDisplay value={crowdFund.funded} symbol="ETH" /> <small>raised</small>{' '}
of <UnitDisplay value={crowdFund.target} symbol="ETH" /> goal
</div>
<div
className={classnames({
['ProposalCard-funding-percent']: true,
['is-funded']: crowdFund.percentFunded >= 100,
})}
>
{crowdFund.percentFunded}%
</div>
return (
<div
className="ProposalCard"
onClick={() => this.setState({ redirect: `/proposals/${proposalUrlId}` })}
>
<h3 className="ProposalCard-title">{title}</h3>
<div className="ProposalCard-funding">
<div className="ProposalCard-funding-raised">
<UnitDisplay value={crowdFund.funded} symbol="ETH" /> <small>raised</small> of{' '}
<UnitDisplay value={crowdFund.target} symbol="ETH" /> goal
</div>
<Progress
percent={crowdFund.percentFunded}
status={crowdFund.percentFunded >= 100 ? 'success' : 'active'}
showInfo={false}
/>
<div className="ProposalCard-team">
<div className="ProposalCard-team-name">
{team[0].name} {team.length > 1 && <small>+{team.length - 1} other</small>}
</div>
<div className="ProposalCard-team-avatars">
{[...team].reverse().map((u, idx) => (
<UserAvatar
key={idx}
className="ProposalCard-team-avatars-avatar"
user={u}
/>
))}
</div>
</div>
<div className="ProposalCard-address">{proposalAddress}</div>
<div className="ProposalCard-info">
<div
className="ProposalCard-info-category"
style={{ color: CATEGORY_UI[category].color }}
>
<Icon type={CATEGORY_UI[category].icon} /> {CATEGORY_UI[category].label}
</div>
<div className="ProposalCard-info-created">
{moment(dateCreated * 1000).fromNow()}
</div>
<div
className={classnames({
['ProposalCard-funding-percent']: true,
['is-funded']: crowdFund.percentFunded >= 100,
})}
>
{crowdFund.percentFunded}%
</div>
</div>
);
}
<Progress
percent={crowdFund.percentFunded}
status={crowdFund.percentFunded >= 100 ? 'success' : 'active'}
showInfo={false}
/>
<div className="ProposalCard-team">
<div className="ProposalCard-team-name">
{team[0].name} {team.length > 1 && <small>+{team.length - 1} other</small>}
</div>
<div className="ProposalCard-team-avatars">
{[...team].reverse().map((u, idx) => (
<UserAvatar
key={idx}
className="ProposalCard-team-avatars-avatar"
user={u}
/>
))}
</div>
</div>
<div className="ProposalCard-address">{proposalAddress}</div>
<div className="ProposalCard-info">
<div
className="ProposalCard-info-category"
style={{ color: CATEGORY_UI[category].color }}
>
<Icon type={CATEGORY_UI[category].icon} /> {CATEGORY_UI[category].label}
</div>
<div className="ProposalCard-info-created">
{moment(dateCreated * 1000).fromNow()}
</div>
</div>
</div>
);
}
}
function mapDispatchToProps(dispatch: Dispatch) {
return bindActionCreators(web3Actions, dispatch);
}
function mapStateToProps(state: AppState) {
return {
web3: state.web3.web3,
};
}
export default connect(
mapStateToProps,
mapDispatchToProps,
)(ProposalCard);
export default ProposalCard;

View File

@ -5,11 +5,10 @@ import { getProposals } from 'modules/proposals/selectors';
import { ProposalWithCrowdFund } from 'types';
import { bindActionCreators, Dispatch } from 'redux';
import { AppState } from 'store/reducers';
import { Input, Divider, Spin, Drawer, Icon, Button } from 'antd';
import { Input, Divider, Drawer, Icon, Button } from 'antd';
import ProposalResults from './Results';
import ProposalFilters, { Filters } from './Filters';
import { PROPOSAL_SORT } from 'api/constants';
import Web3Container from 'lib/Web3Container';
import './style.less';
type ProposalSortFn = (p1: ProposalWithCrowdFund, p2: ProposalWithCrowdFund) => number;
@ -246,6 +245,4 @@ const ConnectedProposals = connect(
mapDispatchToProps,
)(Proposals);
export default () => (
<Web3Container renderLoading={() => <Spin />} render={() => <ConnectedProposals />} />
);
export default ConnectedProposals;

View File

@ -6,12 +6,20 @@ import { loadComponents } from 'loadable-components';
import { Provider } from 'react-redux';
import { BrowserRouter as Router } from 'react-router-dom';
import { PersistGate } from 'redux-persist/integration/react';
import * as Sentry from '@sentry/browser';
import { I18nextProvider } from 'react-i18next';
import { configureStore } from 'store/configure';
import { massageSerializedState } from 'utils/api';
import Routes from './Routes';
import i18n from './i18n';
const initialState = window && (window as any).__PRELOADED_STATE__;
Sentry.init({
dsn: process.env.SENTRY_DSN,
release: process.env.SENTRY_RELEASE,
environment: process.env.NODE_ENV,
});
const initialState =
window && massageSerializedState((window as any).__PRELOADED_STATE__);
const { store, persistor } = configureStore(initialState);
const i18nLanguage = window && (window as any).__PRELOADED_I18N__;
i18n.changeLanguage(i18nLanguage.locale);

View File

@ -0,0 +1,26 @@
import Web3 from 'web3';
import getContractInstance from './getContract';
import { fetchCrowdFundJSON } from 'api/api';
const contractCache = {} as { [key: string]: any };
export async function getCrowdFundContract(web3: Web3 | null, deployedAddress: string) {
if (!web3) {
throw new Error('getCrowdFundAddress: web3 was null but is required!');
}
if (!contractCache[deployedAddress]) {
let CrowdFund;
CrowdFund = await fetchCrowdFundJSON();
try {
contractCache[deployedAddress] = await getContractInstance(
web3,
CrowdFund,
deployedAddress,
);
} catch (e) {
console.error(`Could not lookup crowdFund contract @ ${deployedAddress}: `, e);
return null;
}
}
return contractCache[deployedAddress];
}

View File

@ -10,13 +10,23 @@ const getContractInstance = async (
// get network ID and the deployed address
const networkId = await web3.eth.net.getId();
if (!deployedAddress && !contractDefinition.networks[networkId]) {
throw new WrongNetworkError('Wrong web3 network configured');
throw new WrongNetworkError(
`Wrong web3 network configured. Deployed address: ${deployedAddress}; networkId: ${networkId}, contractDefinitionNetworks: ${JSON.stringify(
contractDefinition.networks,
)}`,
);
}
deployedAddress = deployedAddress || contractDefinition.networks[networkId].address;
// create the instance
return new web3.eth.Contract(contractDefinition.abi, deployedAddress);
const contract = new web3.eth.Contract(contractDefinition.abi, deployedAddress);
// use gas from e2e injected window.web3.provider
if ((web3.currentProvider as any)._e2eContractGas) {
contract.options.gas = (web3.currentProvider as any)._e2eContractGas;
}
return contract;
};
export default getContractInstance;

View File

@ -10,7 +10,6 @@ const resolveWeb3 = (resolve: (web3: Web3) => void, reject: (err: Error) => void
}
let { web3 } = window as Web3Window;
const localProvider = `http://localhost:8545`;
// To test what it's like to not have web3, uncomment the reject. Otherwise
// localProvider will always kick in.
@ -19,10 +18,6 @@ const resolveWeb3 = (resolve: (web3: Web3) => void, reject: (err: Error) => void
if (typeof web3 !== 'undefined') {
console.info(`Injected web3 detected.`);
web3 = new Web3(web3.currentProvider);
} else if (process.env.NODE_ENV !== 'production') {
console.info(`No web3 instance injected, using Local web3.`);
const provider = new Web3.providers.HttpProvider(localProvider);
web3 = new Web3(provider);
} else {
return reject(new Error('No web3 instance available'));
}

View File

@ -1,5 +1,6 @@
import types from './types';
import { Dispatch } from 'redux';
import * as Sentry from '@sentry/browser';
import { sleep } from 'utils/helpers';
import { generateAuthSignatureData } from 'utils/auth';
import { AppState } from 'store/reducers';
@ -37,7 +38,13 @@ export function authUser(address: string, authSignature?: Falsy | AuthSignatureD
signedMessage: authSignature.signedMessage,
rawTypedData: JSON.stringify(authSignature.rawTypedData),
});
// sentry user scope
Sentry.configureScope(scope => {
scope.setUser({
email: res.data.emailAddress,
accountAddress: res.data.ethAddress,
});
});
dispatch({
type: types.AUTH_USER_FULFILLED,
payload: {

View File

@ -1,4 +1,5 @@
import types from './types';
import usersTypes from 'modules/users/types';
// TODO: Use a common User type instead of this
import { TeamMember, AuthSignatureData } from 'types';
@ -56,6 +57,14 @@ export default function createReducer(
authSignatureAddress: action.payload.user.ethAddress,
isAuthingUser: false,
};
case usersTypes.UPDATE_USER_FULFILLED:
return {
...state,
user:
state.user && state.user.ethAddress === action.payload.user.ethAddress
? action.payload.user
: state.user,
};
case types.AUTH_USER_REJECTED:
return {
...state,

View File

@ -254,6 +254,5 @@ export function makeProposalPreviewFromForm(
isFrozen: false,
isRaiseGoalReached: false,
},
crowdFundContract: null,
};
}

View File

@ -4,67 +4,20 @@ import {
getProposal,
getProposalComments,
getProposalUpdates,
postProposalContribution as apiPostProposalContribution,
postProposalComment as apiPostProposalComment,
} from 'api/api';
import { Dispatch } from 'redux';
import Web3 from 'web3';
import { ProposalWithCrowdFund, Proposal, Comment, AuthSignatureData } from 'types';
import { ProposalWithCrowdFund, Comment, AuthSignatureData } from 'types';
import { signData } from 'modules/web3/actions';
import getContract from 'lib/getContract';
import CrowdFund from 'lib/contracts/CrowdFund.json';
import { getCrowdFundState } from 'web3interact/crowdFund';
async function getMergedCrowdFundProposal(
proposal: Proposal,
web3: Web3,
account: string,
) {
const crowdFundContract = await getContract(web3, CrowdFund, proposal.proposalAddress);
const crowdFundData = {
crowdFundContract,
crowdFund: await getCrowdFundState(crowdFundContract, account, web3),
};
for (let i = 0; i < crowdFundData.crowdFund.milestones.length; i++) {
proposal.milestones[i] = {
...proposal.milestones[i],
...crowdFundData.crowdFund.milestones[i],
};
}
return {
...crowdFundData,
...proposal,
};
}
// valid as defined by crowdFund contract existing on current network
export async function getValidProposals(
proposals: { data: Proposal[] },
web3: Web3,
account: string,
) {
return (await Promise.all(
proposals.data.map(async (proposal: Proposal) => {
try {
return await getMergedCrowdFundProposal(proposal, web3, account);
} catch (e) {
console.error('Could not lookup crowdFund contract', e);
}
}),
// remove proposals that except since they cannot be retrieved via getContract
)).filter(Boolean);
}
export type TFetchProposals = typeof fetchProposals;
export function fetchProposals() {
return (dispatch: Dispatch<any>, getState: any) => {
const state = getState();
return (dispatch: Dispatch<any>) => {
return dispatch({
type: types.PROPOSALS_DATA,
payload: async () => {
const proposals = await getProposals();
return getValidProposals(proposals, state.web3.web3, state.web3.accounts[0]);
return (await getProposals()).data;
},
});
};
@ -72,17 +25,11 @@ export function fetchProposals() {
export type TFetchProposal = typeof fetchProposal;
export function fetchProposal(proposalId: ProposalWithCrowdFund['proposalId']) {
return (dispatch: Dispatch<any>, getState: any) => {
const state = getState();
dispatch({
return async (dispatch: Dispatch<any>) => {
return dispatch({
type: types.PROPOSAL_DATA,
payload: async () => {
const proposal = await getProposal(proposalId);
return await getMergedCrowdFundProposal(
proposal.data,
state.web3.web3,
state.web3.accounts[0],
);
return (await getProposal(proposalId)).data;
},
});
};
@ -158,3 +105,17 @@ export function postProposalComment(
}
};
}
export function postProposalContribution(
proposalId: number,
txId: string,
account: string,
amount: string,
) {
return async (dispatch: Dispatch<any>) => {
await dispatch({
type: types.POST_PROPOSAL_CONTRIBUTION,
payload: apiPostProposalContribution(proposalId, txId, account, amount),
});
};
}

View File

@ -1,4 +1,4 @@
enum clockTypes {
enum proposalTypes {
PROPOSALS_DATA = 'PROPOSALS_DATA',
PROPOSALS_DATA_FULFILLED = 'PROPOSALS_DATA_FULFILLED',
PROPOSALS_DATA_REJECTED = 'PROPOSALS_DATA_REJECTED',
@ -23,6 +23,8 @@ enum clockTypes {
POST_PROPOSAL_COMMENT_FULFILLED = 'POST_PROPOSAL_COMMENT_FULFILLED',
POST_PROPOSAL_COMMENT_REJECTED = 'POST_PROPOSAL_COMMENT_REJECTED',
POST_PROPOSAL_COMMENT_PENDING = 'POST_PROPOSAL_COMMENT_PENDING',
POST_PROPOSAL_CONTRIBUTION = 'POST_PROPOSAL_CONTRIBUTION',
}
export default clockTypes;
export default proposalTypes;

View File

@ -5,10 +5,15 @@ import { postProposal } from 'api/api';
import getContract, { WrongNetworkError } from 'lib/getContract';
import { sleep } from 'utils/helpers';
import { web3ErrorToString } from 'utils/web3';
import { fetchProposal, fetchProposals } from 'modules/proposals/actions';
import {
fetchProposal,
fetchProposals,
postProposalContribution,
} from 'modules/proposals/actions';
import { PROPOSAL_CATEGORY } from 'api/constants';
import { AppState } from 'store/reducers';
import { Wei } from 'utils/units';
import { getCrowdFundContract } from 'lib/crowdFundContracts';
import { TeamMember, AuthSignatureData, ProposalWithCrowdFund } from 'types';
type GetState = () => AppState;
@ -201,7 +206,12 @@ export function requestMilestonePayout(proposal: ProposalWithCrowdFund, index: n
});
const state = getState();
const account = state.web3.accounts[0];
const { crowdFundContract, proposalId } = proposal;
const { proposalAddress, proposalId } = proposal;
const crowdFundContract = await getCrowdFundContract(
state.web3.web3,
proposalAddress,
);
try {
await crowdFundContract.methods
.requestMilestonePayout(index)
@ -231,7 +241,11 @@ export function payMilestonePayout(proposal: ProposalWithCrowdFund, index: numbe
});
const state = getState();
const account = state.web3.accounts[0];
const { crowdFundContract, proposalId } = proposal;
const { proposalAddress, proposalId } = proposal;
const crowdFundContract = await getCrowdFundContract(
state.web3.web3,
proposalAddress,
);
try {
await crowdFundContract.methods
@ -265,7 +279,16 @@ export function fundCrowdFund(proposal: ProposalWithCrowdFund, value: number | s
const state = getState();
const web3 = state.web3.web3;
const account = state.web3.accounts[0];
const { crowdFundContract, proposalId } = proposal;
const { proposalAddress, proposalId } = proposal;
const crowdFundContract = await getCrowdFundContract(web3, proposalAddress);
const handleErr = (err: Error) => {
dispatch({
type: types.SEND_REJECTED,
payload: err.message || err.toString(),
error: true,
});
};
try {
if (!web3) {
@ -274,20 +297,27 @@ export function fundCrowdFund(proposal: ProposalWithCrowdFund, value: number | s
await crowdFundContract.methods
.contribute()
.send({ from: account, value: web3.utils.toWei(String(value), 'ether') })
.once('confirmation', async () => {
await sleep(5000);
await dispatch(fetchProposal(proposalId));
dispatch({
type: types.SEND_FULFILLED,
});
.once('confirmation', async (_: number, receipt: any) => {
try {
await sleep(5000);
await dispatch(
postProposalContribution(
proposalId,
receipt.transactionHash,
account,
String(value),
),
);
await dispatch(fetchProposal(proposalId));
dispatch({
type: types.SEND_FULFILLED,
});
} catch (err) {
handleErr(err);
}
});
} catch (err) {
console.log(err);
dispatch({
type: types.SEND_REJECTED,
payload: err.message || err.toString(),
error: true,
});
handleErr(err);
}
};
}
@ -301,7 +331,11 @@ export function voteMilestonePayout(
dispatch({ type: types.VOTE_AGAINST_MILESTONE_PAYOUT_PENDING });
const state = getState();
const account = state.web3.accounts[0];
const { crowdFundContract, proposalId } = proposal;
const { proposalAddress, proposalId } = proposal;
const crowdFundContract = await getCrowdFundContract(
state.web3.web3,
proposalAddress,
);
try {
await crowdFundContract.methods
@ -328,7 +362,11 @@ export function voteRefund(proposal: ProposalWithCrowdFund, vote: boolean) {
dispatch({ type: types.VOTE_REFUND_PENDING });
const state = getState();
const account = state.web3.accounts[0];
const { crowdFundContract, proposalId } = proposal;
const { proposalAddress, proposalId } = proposal;
const crowdFundContract = await getCrowdFundContract(
state.web3.web3,
proposalAddress,
);
try {
await crowdFundContract.methods
@ -377,7 +415,11 @@ export function triggerRefund(proposal: ProposalWithCrowdFund) {
dispatch({ type: types.WITHDRAW_REFUND_PENDING });
const state = getState();
const account = state.web3.accounts[0];
const { crowdFundContract, proposalId } = proposal;
const { proposalAddress, proposalId } = proposal;
const crowdFundContract = await getCrowdFundContract(
state.web3.web3,
proposalAddress,
);
try {
await freezeContract(crowdFundContract, account);
@ -398,7 +440,11 @@ export function withdrawRefund(proposal: ProposalWithCrowdFund, address: string)
dispatch({ type: types.WITHDRAW_REFUND_PENDING });
const state = getState();
const account = state.web3.accounts[0];
const { crowdFundContract, proposalId } = proposal;
const { proposalAddress, proposalId } = proposal;
const crowdFundContract = await getCrowdFundContract(
state.web3.web3,
proposalAddress,
);
try {
await freezeContract(crowdFundContract, account);
@ -462,7 +508,7 @@ export function signData(data: object, dataTypes: object, primaryType: string) {
primaryType,
};
(web3.currentProvider as any).sendAsync(
(web3.currentProvider as any).send(
{
method: 'eth_signTypedData_v3',
params: [accounts[0], JSON.stringify(rawTypedData)],

View File

@ -1,19 +1,17 @@
import { SagaIterator } from 'redux-saga';
import { put, all, fork, take, takeLatest, select, call } from 'redux-saga/effects';
import { setWeb3, setAccounts, setContract } from './actions';
import { all, call, fork, put, select, take, takeLatest } from 'redux-saga/effects';
import { setAccounts, setContract, setWeb3 } from './actions';
import { selectWeb3 } from './selectors';
import { safeEnable } from 'utils/web3';
import types from './types';
/* tslint:disable no-var-requires --- TODO: find a better way to import contract */
const CrowdFundFactory = require('lib/contracts/CrowdFundFactory.json');
import { fetchCrowdFundFactoryJSON } from 'api/api';
export function* bootstrapWeb3(): SagaIterator {
// Don't attempt to bootstrap web3 on SSR
if (process.env.SERVER_SIDE_RENDER) {
return;
}
const CrowdFundFactory = yield call(fetchCrowdFundFactoryJSON);
yield put<any>(setWeb3());
yield take(types.WEB3_FULFILLED);

View File

@ -13,7 +13,7 @@ const sagaMiddleware = createSagaMiddleware();
type MiddleWare = ThunkMiddleware | SagaMiddleware<any> | any;
const bindMiddleware = (middleware: MiddleWare[]) => {
if (process.env.NODE_ENV !== 'production') {
if (process.env.NODE_ENV !== 'production' && typeof window !== 'undefined') {
const { createLogger } = require('redux-logger');
const logger = createLogger({
collapsed: true,

View File

@ -1,5 +1,7 @@
import { TeamMember } from 'types';
import BN from 'bn.js';
import { TeamMember, CrowdFund, ProposalWithCrowdFund, UserProposal } from 'types';
import { socialAccountsToUrls, socialUrlsToAccounts } from 'utils/social';
import { AppState } from 'store/reducers';
export function formatTeamMemberForPost(user: TeamMember) {
return {
@ -27,6 +29,38 @@ export function formatTeamMemberFromGet(user: any): TeamMember {
};
}
export function formatCrowdFundFromGet(crowdFund: CrowdFund, base = 10): CrowdFund {
const bnKeys = ['amountVotingForRefund', 'balance', 'funded', 'target'] as Array<
keyof CrowdFund
>;
bnKeys.forEach(k => {
crowdFund[k] = new BN(crowdFund[k] as string, base);
});
crowdFund.milestones = crowdFund.milestones.map(ms => {
ms.amount = new BN(ms.amount, base);
ms.amountAgainstPayout = new BN(ms.amountAgainstPayout, base);
return ms;
});
crowdFund.contributors = crowdFund.contributors.map(c => {
c.contributionAmount = new BN(c.contributionAmount, base);
return c;
});
return crowdFund;
}
export function formatProposalFromGet(proposal: ProposalWithCrowdFund) {
proposal.team = proposal.team.map(formatTeamMemberFromGet);
proposal.proposalUrlId = generateProposalUrl(proposal.proposalId, proposal.title);
proposal.crowdFund = formatCrowdFundFromGet(proposal.crowdFund);
for (let i = 0; i < proposal.crowdFund.milestones.length; i++) {
proposal.milestones[i] = {
...proposal.milestones[i],
...proposal.crowdFund.milestones[i],
};
}
return proposal;
}
// TODO: i18n on case-by-case basis
export function generateProposalUrl(id: number, title: string) {
const slug = title
@ -46,3 +80,32 @@ export function extractProposalIdFromUrl(slug: string) {
}
return proposalId;
}
// pre-hydration massage (BNify JSONed BNs)
export function massageSerializedState(state: AppState) {
// proposals
state.proposal.proposals.forEach(p => {
formatCrowdFundFromGet(p.crowdFund, 16);
for (let i = 0; i < p.crowdFund.milestones.length; i++) {
p.milestones[i] = {
...p.milestones[i],
...p.crowdFund.milestones[i],
};
}
});
// users
const bnUserProp = (p: UserProposal) => {
p.funded = new BN(p.funded, 16);
p.target = new BN(p.target, 16);
return p;
};
Object.values(state.users.map).forEach(user => {
user.createdProposals.forEach(bnUserProp);
user.fundedProposals.forEach(bnUserProp);
user.comments.forEach(c => {
c.proposal = bnUserProp(c.proposal);
});
});
return state;
}

View File

@ -0,0 +1,35 @@
export function getBase64(img: Blob, callback: (base64: string) => void) {
const reader = new FileReader();
reader.addEventListener('load', () => callback(reader.result as string));
reader.readAsDataURL(img);
}
export function dataUrlToBlob(dataUrl: string) {
const base64ImageContent = dataUrl.replace(/^data:[a-z]+\/[a-z]+;base64,/, '');
const mimeSearch = dataUrl.match(/^data:([a-z]+\/[a-z]+)(;base64,)/);
if (!mimeSearch || mimeSearch.length !== 3) {
throw new Error(
'dataUrlToBlob could not find mime type, or base64 was missing: ' +
dataUrl.substring(0, 200),
);
} else {
return base64ToBlob(base64ImageContent, mimeSearch[1]);
}
}
export function base64ToBlob(base64: string, mime: string) {
mime = mime || '';
const sliceSize = 1024;
const byteChars = window.atob(base64);
const byteArrays = [];
for (let offset = 0, len = byteChars.length; offset < len; offset += sliceSize) {
const slice = byteChars.slice(offset, offset + sliceSize);
const byteNumbers = new Array(slice.length);
for (let i = 0; i < slice.length; i++) {
byteNumbers[i] = slice.charCodeAt(i);
}
const byteArray = new Uint8Array(byteNumbers);
byteArrays.push(byteArray);
}
return new Blob(byteArrays, { type: mime });
}

View File

@ -1,6 +1,9 @@
const fs = require('fs');
const path = require('path');
const paths = require('./paths');
const childProcess = require('child_process');
const dotenv = require('dotenv');
const { logMessage } = require('../bin/utils');
delete require.cache[require.resolve('./paths')];
@ -10,29 +13,51 @@ if (!process.env.NODE_ENV) {
);
}
// https://github.com/bkeepers/dotenv#what-other-env-files-can-i-use
const dotenvFiles = [
`${paths.dotenv}.${process.env.NODE_ENV}.local`,
`${paths.dotenv}.${process.env.NODE_ENV}`,
process.env.NODE_ENV !== 'test' && `${paths.dotenv}.local`,
paths.dotenv,
].filter(Boolean);
dotenvFiles.forEach(dotenvFile => {
if (fs.existsSync(dotenvFile)) {
require('dotenv').config({
path: dotenvFile,
});
// Override local ENV variables with .env
if (fs.existsSync(paths.dotenv)) {
const envConfig = dotenv.parse(fs.readFileSync(paths.dotenv));
// tslint:disable-next-line
for (const k in envConfig) {
if (process.env[k]) {
logMessage(`Warning! Over-writing existing ENV Variable ${k}`);
}
process.env[k] = envConfig[k];
}
});
}
if (!process.env.PUBLIC_HOST_URL) {
if (process.env.NODE_ENV === 'production') {
throw new Error(
'The process.env.PUBLIC_HOST_URL environment variable is required but was not specified.',
);
const envProductionRequiredHandler = (envVariable, fallbackValue) => {
if (!process.env[envVariable]) {
if (process.env.NODE_ENV === 'production') {
throw new Error(
`The process.env.${envVariable} environment variable is required but was not specified.`,
);
}
process.env[envVariable] = fallbackValue;
}
process.env.PUBLIC_HOST_URL = 'http://localhost:' + (process.env.PORT || 3000);
};
envProductionRequiredHandler(
'PUBLIC_HOST_URL',
'http://localhost:' + (process.env.PORT || 3000),
);
envProductionRequiredHandler(
'CROWD_FUND_URL',
'https://eip-712.herokuapp.com/contract/crowd-fund',
);
envProductionRequiredHandler(
'CROWD_FUND_FACTORY_URL',
'https://eip-712.herokuapp.com/contract/factory',
);
if (!process.env.BACKEND_URL) {
process.env.BACKEND_URL = 'http://localhost:5000';
}
if (!process.env.SENTRY_RELEASE) {
process.env.SENTRY_RELEASE = childProcess
.execSync('git rev-parse --short HEAD')
.toString()
.trim();
}
const appDirectory = fs.realpathSync(process.cwd());
@ -44,10 +69,14 @@ process.env.NODE_PATH = (process.env.NODE_PATH || '')
module.exports = () => {
const raw = {
PORT: process.env.PORT || 3000,
BACKEND_URL: process.env.BACKEND_URL,
CROWD_FUND_FACTORY_URL: process.env.CROWD_FUND_FACTORY_URL,
CROWD_FUND_URL: process.env.CROWD_FUND_URL,
NODE_ENV: process.env.NODE_ENV || 'development',
BACKEND_URL: process.env.BACKEND_URL || 'http://localhost:5000',
PORT: process.env.PORT || 3000,
PUBLIC_HOST_URL: process.env.PUBLIC_HOST_URL,
SENTRY_DSN: process.env.SENTRY_DSN || null,
SENTRY_RELEASE: process.env.SENTRY_RELEASE,
};
// Stringify all values so we can feed into Webpack DefinePlugin

View File

@ -1,6 +1,6 @@
const ModuleDependencyWarning = require('webpack/lib/ModuleDependencyWarning');
// supress unfortunate warnings due to transpileOnly=true and certain ts export patterns
// suppress unfortunate warnings due to transpileOnly=true and certain ts export patterns
// https://github.com/TypeStrong/ts-loader/issues/653#issuecomment-390889335
// https://github.com/TypeStrong/ts-loader/issues/751

View File

@ -1,20 +1,24 @@
{
"name": "grant",
"version": "1.0.1",
"version": "1.0.2",
"main": "index.js",
"license": "MIT",
"scripts": {
"analyze": "NODE_ENV=production ANALYZE=true next build ./client",
"build": "cross-env NODE_ENV=production node bin/build.js",
"dev": "rm -rf ./node_modules/.cache && cross-env NODE_ENV=development BACKEND_URL=http://localhost:5000 node bin/dev.js",
"lint": "tslint --project ./tsconfig.json --config ./tslint.json -e \"**/build/**\"",
"build": "node bin/build.js",
"dev": "rm -rf ./node_modules/.cache && node bin/dev.js",
"lint": "tslint --project ./tsconfig.json --config ./tslint.json -e \"**/build/**\" -e \"**/bin/**\"",
"start": "NODE_ENV=production node ./build/server/server.js",
"now": "npm run build && now -e BACKEND_URL=https://grant-stage.herokuapp.com",
"heroku-postbuild": "yarn build",
"tsc": "tsc",
"link-contracts": "cd client/lib && ln -s ../../build/contracts contracts",
"ganache": "ganache-cli -b 5",
"ganache": "ganache-cli -b 5 -s testGrantIo -e 1000",
"truffle": "truffle exec ./bin/init-truffle.js && cd client/lib/contracts && truffle console",
"storybook": "start-storybook -p 9001 -c .storybook"
},
"engines": {
"node": "8.13.0"
},
"husky": {
"hooks": {
"pre-commit": "lint-staged",
@ -40,6 +44,8 @@
"@babel/register": "^7.0.0",
"@ledgerhq/hw-app-eth": "4.23.0",
"@ledgerhq/hw-transport-u2f": "4.21.0",
"@sentry/browser": "^4.3.2",
"@sentry/node": "^4.3.2",
"@svgr/webpack": "^2.4.0",
"@types/classnames": "^2.2.6",
"@types/cors": "^2.8.4",
@ -54,6 +60,7 @@
"@types/node": "^10.3.1",
"@types/numeral": "^0.0.25",
"@types/react": "16.4.18",
"@types/react-cropper": "^0.10.3",
"@types/react-dom": "16.0.9",
"@types/react-helmet": "^5.0.7",
"@types/react-redux": "^6.0.2",
@ -82,7 +89,6 @@
"copy-webpack-plugin": "^4.6.0",
"core-js": "^2.5.7",
"cors": "^2.8.4",
"cross-env": "^5.2.0",
"css-loader": "^1.0.0",
"dotenv": "^6.0.0",
"ethereum-blockies-base64": "1.0.2",
@ -117,6 +123,7 @@
"prettier-package-json": "^1.6.0",
"query-string": "6.1.0",
"react": "16.5.2",
"react-cropper": "^1.0.1",
"react-dev-utils": "^5.0.2",
"react-dom": "16.5.2",
"react-helmet": "^5.2.0",

View File

@ -4,10 +4,11 @@ import * as path from 'path';
import chalk from 'chalk';
import manifestHelpers from 'express-manifest-helpers';
import * as bodyParser from 'body-parser';
import dotenv from 'dotenv';
import expressWinston from 'express-winston';
import i18nMiddleware from 'i18next-express-middleware';
import * as Sentry from '@sentry/node';
import '../config/env';
// @ts-ignore
import * as paths from '../config/paths';
import log from './log';
@ -17,10 +18,17 @@ import i18n from './i18n';
process.env.SERVER_SIDE_RENDER = 'true';
const isDev = process.env.NODE_ENV === 'development';
dotenv.config();
Sentry.init({
dsn: process.env.SENTRY_DSN,
release: process.env.SENTRY_RELEASE,
environment: process.env.NODE_ENV,
});
const app = express();
// sentry
app.use(Sentry.Handlers.requestHandler());
// log requests
app.use(expressWinston.logger({ winstonInstance: log }));
@ -61,6 +69,7 @@ app.use(
app.use(serverRender());
app.use(Sentry.Handlers.errorHandler());
app.use(expressWinston.errorLogger({ winstonInstance: log }));
app.listen(process.env.PORT || 3000, () => {

View File

@ -18,6 +18,7 @@ import i18n from './i18n';
// @ts-ignore
import * as paths from '../config/paths';
import { storeActionsForPath } from './ssrAsync';
const isDev = process.env.NODE_ENV === 'development';
let cachedStats: any;
@ -78,6 +79,7 @@ const chunkExtractFromLoadables = (loadableState: any) =>
const serverRenderer = () => async (req: Request, res: Response) => {
const { store } = configureStore();
await storeActionsForPath(req.url, store);
// i18n
const locale = (req as any).language;

View File

@ -0,0 +1,46 @@
import { Store } from 'redux';
import { fetchProposal } from 'modules/proposals/actions';
import {
fetchUser,
fetchUserCreated,
fetchUserFunded,
fetchUserComments,
} from 'modules/users/actions';
import { extractProposalIdFromUrl } from 'utils/api';
const pathActions = [
{
matcher: /^\/proposals\/(.+)$/,
action: (match: RegExpMatchArray, store: Store) => {
const proposalId = extractProposalIdFromUrl(match[1]);
if (proposalId) {
return store.dispatch<any>(fetchProposal(proposalId));
}
},
},
{
matcher: /^\/profile\/(.+)$/,
action: (match: RegExpMatchArray, store: Store) => {
const userId = match[1];
if (userId) {
return Promise.all([
store.dispatch<any>(fetchUser(userId)),
store.dispatch<any>(fetchUserCreated(userId)),
store.dispatch<any>(fetchUserFunded(userId)),
store.dispatch<any>(fetchUserComments(userId)),
]);
}
},
},
];
export function storeActionsForPath(path: string, store: Store) {
const pathAction = pathActions.find(pa => !!path.match(pa.matcher));
if (pathAction) {
const matches = path.match(pathAction.matcher);
if (matches) {
return pathAction.action(matches, store);
}
}
return Promise.resolve();
}

View File

@ -212,13 +212,13 @@ export function getProposalWithCrowdFund({
amountVotingForRefund: new BN(0),
percentVotingForRefund: 0,
},
crowdFundContract: {},
};
const props = {
sendLoading: false,
fundCrowdFund,
web3: new Web3(),
isMissingWeb3: false,
proposal,
...proposal, // yeah...
};

View File

@ -0,0 +1,9 @@
export interface Contribution {
id: string;
txId: string;
proposalId: number;
userId: number;
fromAddress: string;
amount: string;
dateCreated: number;
}

View File

@ -2,6 +2,7 @@ export * from './user';
export * from './social';
export * from './create';
export * from './comment';
export * from './contribution';
export * from './milestone';
export * from './update';
export * from './proposal';

View File

@ -46,7 +46,6 @@ export interface Proposal {
export interface ProposalWithCrowdFund extends Proposal {
crowdFund: CrowdFund;
crowdFundContract: any;
}
export interface ProposalComments {

View File

@ -1612,6 +1612,60 @@
dependencies:
any-observable "^0.3.0"
"@sentry/browser@^4.3.2":
version "4.3.2"
resolved "https://registry.yarnpkg.com/@sentry/browser/-/browser-4.3.2.tgz#430b83583c5c25d33041dd80bf6ed19216086f70"
dependencies:
"@sentry/core" "4.3.2"
"@sentry/types" "4.3.2"
"@sentry/utils" "4.3.2"
"@sentry/core@4.3.2":
version "4.3.2"
resolved "https://registry.yarnpkg.com/@sentry/core/-/core-4.3.2.tgz#e8a2850a11316d865ed7d3030ee2c4a1608bb1d8"
dependencies:
"@sentry/hub" "4.3.2"
"@sentry/minimal" "4.3.2"
"@sentry/types" "4.3.2"
"@sentry/utils" "4.3.2"
"@sentry/hub@4.3.2":
version "4.3.2"
resolved "https://registry.yarnpkg.com/@sentry/hub/-/hub-4.3.2.tgz#1ea10038e2080035d2bc09f5f26829cd106a1516"
dependencies:
"@sentry/types" "4.3.2"
"@sentry/utils" "4.3.2"
"@sentry/minimal@4.3.2":
version "4.3.2"
resolved "https://registry.yarnpkg.com/@sentry/minimal/-/minimal-4.3.2.tgz#c0958e5858b2105a6a0b523787e459a03af603cc"
dependencies:
"@sentry/hub" "4.3.2"
"@sentry/types" "4.3.2"
"@sentry/node@^4.3.2":
version "4.3.2"
resolved "https://registry.yarnpkg.com/@sentry/node/-/node-4.3.2.tgz#3c7cd3aff238f3b1eb3252147b963cbdf520aa45"
dependencies:
"@sentry/core" "4.3.2"
"@sentry/hub" "4.3.2"
"@sentry/types" "4.3.2"
"@sentry/utils" "4.3.2"
cookie "0.3.1"
lsmod "1.0.0"
md5 "2.2.1"
stack-trace "0.0.10"
"@sentry/types@4.3.2":
version "4.3.2"
resolved "https://registry.yarnpkg.com/@sentry/types/-/types-4.3.2.tgz#28b143979482fcbc9f9e520250482dde015b13fa"
"@sentry/utils@4.3.2":
version "4.3.2"
resolved "https://registry.yarnpkg.com/@sentry/utils/-/utils-4.3.2.tgz#de14046eba972af9d62508f78cd998b0352d634a"
dependencies:
"@sentry/types" "4.3.2"
"@storybook/addons@4.0.0-alpha.22":
version "4.0.0-alpha.22"
resolved "https://registry.yarnpkg.com/@storybook/addons/-/addons-4.0.0-alpha.22.tgz#08d89396fff216c0d5aa305f7ac851b6bc34b6cf"
@ -1858,6 +1912,10 @@
dependencies:
"@types/express" "*"
"@types/cropperjs@*":
version "1.1.3"
resolved "https://registry.yarnpkg.com/@types/cropperjs/-/cropperjs-1.1.3.tgz#8b2264fb45e933c3eda149cbd08a4f1926dfd8e2"
"@types/dotenv@^4.0.3":
version "4.0.3"
resolved "https://registry.yarnpkg.com/@types/dotenv/-/dotenv-4.0.3.tgz#ebcfc40da7bc0728b705945b7db48485ec5b4b67"
@ -1967,6 +2025,13 @@
version "1.2.2"
resolved "https://registry.yarnpkg.com/@types/range-parser/-/range-parser-1.2.2.tgz#fa8e1ad1d474688a757140c91de6dace6f4abc8d"
"@types/react-cropper@^0.10.3":
version "0.10.3"
resolved "https://registry.yarnpkg.com/@types/react-cropper/-/react-cropper-0.10.3.tgz#d7ca18667d9cdad9469d3de6469104924d8217d5"
dependencies:
"@types/cropperjs" "*"
"@types/react" "*"
"@types/react-dom@16.0.9":
version "16.0.9"
resolved "https://registry.yarnpkg.com/@types/react-dom/-/react-dom-16.0.9.tgz#73ceb7abe6703822eab6600e65c5c52efd07fb91"
@ -4094,6 +4159,10 @@ chardet@^0.7.0:
version "0.7.0"
resolved "https://registry.yarnpkg.com/chardet/-/chardet-0.7.0.tgz#90094849f0937f2eedc2425d0d28a9e5f0cbad9e"
charenc@~0.0.1:
version "0.0.2"
resolved "https://registry.yarnpkg.com/charenc/-/charenc-0.0.2.tgz#c0a1d2f3a7092e03774bfa83f14c0fc5790a8667"
check-error@^1.0.1:
version "1.0.2"
resolved "https://registry.yarnpkg.com/check-error/-/check-error-1.0.2.tgz#574d312edd88bb5dd8912e9286dd6c0aed4aac82"
@ -4765,12 +4834,9 @@ create-react-context@0.2.3:
fbjs "^0.8.0"
gud "^1.0.0"
cross-env@^5.2.0:
version "5.2.0"
resolved "https://registry.yarnpkg.com/cross-env/-/cross-env-5.2.0.tgz#6ecd4c015d5773e614039ee529076669b9d126f2"
dependencies:
cross-spawn "^6.0.5"
is-windows "^1.0.0"
cropperjs@v1.0.0-rc.3:
version "1.0.0-rc.3"
resolved "https://registry.yarnpkg.com/cropperjs/-/cropperjs-1.0.0-rc.3.tgz#50a7c7611befc442702f845ede77d7df4572e82b"
cross-spawn@5.1.0, cross-spawn@^5.0.1:
version "5.1.0"
@ -4797,6 +4863,10 @@ cross-spawn@^3.0.0:
lru-cache "^4.0.1"
which "^1.2.9"
crypt@~0.0.1:
version "0.0.2"
resolved "https://registry.yarnpkg.com/crypt/-/crypt-0.0.2.tgz#88d7ff7ec0dfb86f713dc87bbb42d044d3e6c41b"
cryptiles@3.x.x:
version "3.1.2"
resolved "https://registry.yarnpkg.com/cryptiles/-/cryptiles-3.1.2.tgz#a89fbb220f5ce25ec56e8c4aa8a4fd7b5b0d29fe"
@ -8067,7 +8137,7 @@ is-boolean-object@^1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/is-boolean-object/-/is-boolean-object-1.0.0.tgz#98f8b28030684219a95f375cfbd88ce3405dff93"
is-buffer@^1.0.2, is-buffer@^1.1.5:
is-buffer@^1.0.2, is-buffer@^1.1.5, is-buffer@~1.1.1:
version "1.1.6"
resolved "https://registry.yarnpkg.com/is-buffer/-/is-buffer-1.1.6.tgz#efaa2ea9daa0d7ab2ea13a97b2b8ad51fefbe8be"
@ -8382,7 +8452,7 @@ is-utf8@^0.2.0:
version "0.2.1"
resolved "https://registry.yarnpkg.com/is-utf8/-/is-utf8-0.2.1.tgz#4b0da1442104d1b336340e80797e865cf39f7d72"
is-windows@^1.0.0, is-windows@^1.0.1, is-windows@^1.0.2:
is-windows@^1.0.1, is-windows@^1.0.2:
version "1.0.2"
resolved "https://registry.yarnpkg.com/is-windows/-/is-windows-1.0.2.tgz#d1850eb9791ecd18e6182ce12a30f396634bb19d"
@ -9643,6 +9713,10 @@ lru-cache@^4.1.1, lru-cache@^4.1.2, lru-cache@^4.1.3:
pseudomap "^1.0.2"
yallist "^2.1.2"
lsmod@1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/lsmod/-/lsmod-1.0.0.tgz#9a00f76dca36eb23fa05350afe1b585d4299e64b"
make-dir@^1.0.0:
version "1.1.0"
resolved "https://registry.yarnpkg.com/make-dir/-/make-dir-1.1.0.tgz#19b4369fe48c116f53c2af95ad102c0e39e85d51"
@ -9736,6 +9810,14 @@ md5.js@^1.3.4:
hash-base "^3.0.0"
inherits "^2.0.1"
md5@2.2.1:
version "2.2.1"
resolved "https://registry.yarnpkg.com/md5/-/md5-2.2.1.tgz#53ab38d5fe3c8891ba465329ea23fac0540126f9"
dependencies:
charenc "~0.0.1"
crypt "~0.0.1"
is-buffer "~1.1.1"
mdn-data@~1.1.0:
version "1.1.4"
resolved "https://registry.yarnpkg.com/mdn-data/-/mdn-data-1.1.4.tgz#50b5d4ffc4575276573c4eedb8780812a8419f01"
@ -12512,6 +12594,13 @@ react-copy-to-clipboard@^5.0.1:
copy-to-clipboard "^3"
prop-types "^15.5.8"
react-cropper@^1.0.1:
version "1.0.1"
resolved "https://registry.yarnpkg.com/react-cropper/-/react-cropper-1.0.1.tgz#6e5595abb8576088ab3e51ecfdcfe5f865d0340f"
dependencies:
cropperjs v1.0.0-rc.3
prop-types "^15.5.8"
react-dev-utils@6.0.0-next.3e165448:
version "6.0.0-next.3e165448"
resolved "https://registry.yarnpkg.com/react-dev-utils/-/react-dev-utils-6.0.0-next.3e165448.tgz#d573ed0ba692f6cee23166f99204e5761df0897c"
@ -14208,7 +14297,7 @@ stable@~0.1.6:
version "0.1.8"
resolved "https://registry.yarnpkg.com/stable/-/stable-0.1.8.tgz#836eb3c8382fe2936feaf544631017ce7d47a3cf"
stack-trace@0.0.x:
stack-trace@0.0.10, stack-trace@0.0.x:
version "0.0.10"
resolved "https://registry.yarnpkg.com/stack-trace/-/stack-trace-0.0.10.tgz#547c70b347e8d32b4e108ea1a2a159e5fdde19c0"