initial commit

This commit is contained in:
Daniel Ternyak 2018-09-10 11:55:26 -05:00
commit 2f513d0ce6
No known key found for this signature in database
GPG Key ID: DF212D2DC5D0E245
208 changed files with 26134 additions and 0 deletions

1
.gitignore vendored Normal file
View File

@ -0,0 +1 @@
.idea

40
README.md Normal file
View File

@ -0,0 +1,40 @@
# (ALPHA) Grant.io Mono Repo
This is a collection of the various services and components that make up [Grant.io](http://grant.io).
[Grant.io](http://grant.io) is under heavy development, and is not considered stable. Use at your own risk!
### Setup
__________________
##### Docker
To get setup quickly, simply use docker-compose to spin up the necessary services
TBD
##### Locally
Alternatively, run the backend and front-end services locally.
Instructions for each respective component can be found in:
- `/backend/README.md`
- `/frontend/README.md`
We currently only offer instructions for unix based systems. Windows may or may not be compatible.
### Testing
To run tests across all components simultaneously, use the following command
TBD
### Deployment
TBD

6
backend/.env.example Normal file
View File

@ -0,0 +1,6 @@
# Environment variable overrides for local development
FLASK_APP=app.py
FLASK_ENV=development
DATABASE_URL="sqlite:////tmp/dev.db"
REDISTOGO_URL="redis://localhost:6379"
SECRET_KEY="not-so-secret"

70
backend/.gitignore vendored Normal file
View File

@ -0,0 +1,70 @@
*.py[cod]
# C extensions
*.so
# Packages
*.egg
*.egg-info
build
eggs
parts
bin
var
sdist
develop-eggs
.installed.cfg
lib
lib64
# Installer logs
pip-log.txt
# Unit test / coverage reports
.coverage
.tox
nosetests.xml
# Translations
*.mo
# Mr Developer
.mr.developer.cfg
.project
.pydevproject
# Complexity
output/*.html
output/*/index.html
# Sphinx
docs/_build
.webassets-cache
# Virtualenvs
env/
venv/
# npm
/node_modules/
# webpack-built files
/devdao/static/build/
/devdao/webpack/manifest.json
# Configuration
.env
# Development database
*.db
# redis
dump.rdb
# pytest
.pytest_cache
# jetbrains
.idea/

2
backend/.isort.cfg Normal file
View File

@ -0,0 +1,2 @@
[settings]
line_length=120

View File

@ -0,0 +1,6 @@
repos:
- repo: https://github.com/ambv/black
rev: stable
hooks:
- id: black
language_version: python3.6

20
backend/.travis.yml Normal file
View File

@ -0,0 +1,20 @@
# 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

12
backend/LICENSE Normal file
View File

@ -0,0 +1,12 @@
Copyright (c) 2018, DevDAO
All rights reserved.
Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met:
* Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer.
* Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution.
* Neither the name of DevDAO nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.

1
backend/Procfile Normal file
View File

@ -0,0 +1 @@
web: gunicorn grant.app:create_app\(\) -b 0.0.0.0:$PORT -w 1

84
backend/README.md Normal file
View File

@ -0,0 +1,84 @@
# Grant.io Backend
This is the backend component of [Grant.io](http://grant.io).
## Database Setup
Run the following commands to bootstrap your environment.
Note: db setup is configured in .env when running locally. SQLLite is used by default in /tmp/
# Get python in a virtual environment
virtualenv -p python3 venv
source venv/bin/activate
# Install python requirements
pip install -r requirements/dev.txt
# Create environment variables file, edit as needed
cp .env.example .env
Once you have installed your DBMS, run the following to create your app's
database tables and perform the initial migration
flask db migrate
flask db upgrade
## Running the App
Depending on what you need to run, there are several servies that need to be started
If you just need the API, you can run
flask run
## Deployment
To deploy
export FLASK_ENV=production
export FLASK_DEBUG=0
export DATABASE_URL="<YOUR DATABASE URL>"
flask run # start the flask server
In your production environment, make sure the ``FLASK_DEBUG`` environment
variable is unset or is set to ``0``.
## Shell
To open the interactive shell, run
flask shell
By default, you will have access to the flask ``app``.
## Running Tests
To run all tests, run
flask test
## Migrations
Whenever a database migration needs to be made. Run the following commands
flask db migrate
This will generate a new migration script. Then run
flask db upgrade
To apply the migration.
For a full migration command reference, run ``flask db --help``.
## Commands
To create a proposal, run
flask create_proposal "FUNDING_REQUIRED" 1 123 "My Awesome Proposal" "### Hi! I have a great proposal"

5
backend/app.py Executable file
View File

@ -0,0 +1,5 @@
# -*- coding: utf-8 -*-
"""Create an application instance."""
from grant.app import create_app
app = create_app()

72
backend/grant/__init__.py Normal file
View File

@ -0,0 +1,72 @@
import copy
import re
from flask import jsonify
def _camel_dict(dict_obj, deep=True):
converted_dict_obj = {}
for snake_case_k in dict_obj:
camel_case_k = re.sub('_([a-z])', lambda match: match.group(1).upper(), snake_case_k)
value = dict_obj[snake_case_k]
if type(value) == dict and deep:
converted_dict_obj[camel_case_k] = camel(**value)
elif type(value) == list and deep:
converted_list_items = []
for item in value:
converted_list_items.append(camel(**item))
converted_dict_obj[camel_case_k] = converted_list_items
else:
converted_dict_obj[camel_case_k] = dict_obj[snake_case_k]
return converted_dict_obj
def camel(dict_or_list_obj=None, **kwargs):
dict_or_list_obj = kwargs if kwargs else dict_or_list_obj
deep = True
if type(dict_or_list_obj) == dict:
return _camel_dict(dict_obj=dict_or_list_obj, deep=deep)
elif type(dict_or_list_obj) == list or type(dict_or_list_obj) == tuple or type(dict_or_list_obj) == map:
return list(map(_camel_dict, list(dict_or_list_obj)))
else:
raise ValueError("type {} is not supported!".format(type(dict_or_list_obj)))
"""
JSONResponse allows several argument formats:
1. JSONResponse([{"userId": 1, "name": "John" }, {"userId": 2, "name": "Dave" }])
2. JSONResponse(result=[my_results])
JSONResponse does not accept the following:
1. Intermixed positional and keyword arguments: JSONResponse(some_data, wow=True)
1a. The exception to this is _statusCode, which is allowed to be mixed.
An HTTP Status code should be set here by the caller, or 200 will be used.
1. Multiple positional arguments: JSONResponse(some_data, other_data)
"""
# TODO - use something standard. Insane that it's so hard to camelCase JSON output
def JSONResponse(*args, **kwargs):
if args:
if len(args) > 1:
raise ValueError("Only one positional arg supported")
if kwargs.get("_statusCode"):
status = copy.copy(kwargs["_statusCode"])
del kwargs["_statusCode"]
else:
status = 200
if args and kwargs:
raise ValueError("Only positional args or keyword args supported, not both")
if not kwargs and not args:
# TODO add log. This should never happen
return jsonify({}), 500
if kwargs:
return jsonify(camel(**kwargs)), status
else:
return jsonify(camel(args[0])), status

59
backend/grant/app.py Normal file
View File

@ -0,0 +1,59 @@
# -*- coding: utf-8 -*-
"""The app module, containing the app factory function."""
from flask import Flask
from flask_cors import CORS
from grant import commands, proposal, author, comment, milestone
from grant.extensions import bcrypt, migrate, db, ma
def create_app(config_object="grant.settings"):
app = Flask(__name__.split(".")[0])
app.config.from_object(config_object)
register_extensions(app)
register_blueprints(app)
register_shellcontext(app)
register_commands(app)
return app
def register_extensions(app):
"""Register Flask extensions."""
bcrypt.init_app(app)
db.init_app(app)
migrate.init_app(app, db)
ma.init_app(app)
CORS(app)
return None
def register_blueprints(app):
"""Register Flask blueprints."""
app.register_blueprint(comment.views.blueprint)
app.register_blueprint(proposal.views.blueprint)
app.register_blueprint(author.views.blueprint)
app.register_blueprint(milestone.views.blueprint)
return None
def register_shellcontext(app):
"""Register shell context objects."""
def shell_context():
"""Shell context objects."""
return {"db": db}
app.shell_context_processor(shell_context)
def register_commands(app):
"""Register Click commands."""
app.cli.add_command(commands.test)
app.cli.add_command(commands.lint)
app.cli.add_command(commands.clean)
app.cli.add_command(commands.urls)
app.cli.add_command(author.commands.create_author)
app.cli.add_command(proposal.commands.create_proposal)

View File

@ -0,0 +1,3 @@
from . import views
from . import models
from . import commands

View File

@ -0,0 +1,13 @@
import click
from flask.cli import with_appcontext
from .models import Author, db
@click.command()
@click.argument('account_address')
@with_appcontext
def create_author(account_address):
author = Author(account_address)
db.session.add(author)
db.session.commit()

View File

@ -0,0 +1,43 @@
from grant.comment.models import Comment
from grant.extensions import ma, db
from grant.proposal.models import Proposal
class Author(db.Model):
__tablename__ = "author"
id = db.Column(db.Integer(), primary_key=True)
account_address = db.Column(db.String(255), unique=True)
proposals = db.relationship(Proposal, backref="author", lazy=True)
comments = db.relationship(Comment, backref="author", lazy=True)
avatar = db.Column(db.String(255), unique=False, nullable=True)
# TODO - add create and validate methods
def __init__(self, account_address, avatar=None):
self.account_address = account_address
self.avatar = avatar
class AuthorSchema(ma.Schema):
class Meta:
model = Author
# Fields to expose
fields = ("account_address", "userid", "title", "avatar")
userid = ma.Method("get_userid")
title = ma.Method("get_title")
avatar = ma.Method("get_avatar")
def get_userid(self, obj):
return obj.id
def get_title(self, obj):
return ""
def get_avatar(self, obj):
return "https://forum.getmonero.org/uploads/profile/small_no_picture.jpg"
author_schema = AuthorSchema()
authors_schema = AuthorSchema(many=True)

View File

@ -0,0 +1,13 @@
from flask import Blueprint
from .models import Author, authors_schema
from grant import JSONResponse
blueprint = Blueprint('author', __name__, url_prefix='/api/authors')
@blueprint.route("/", methods=["GET"])
def get_authors():
all_authors = Author.query.all()
result = authors_schema.dump(all_authors)
return JSONResponse(result)

131
backend/grant/commands.py Normal file
View File

@ -0,0 +1,131 @@
# -*- coding: utf-8 -*-
"""Click commands."""
import os
from glob import glob
from subprocess import call
import click
from flask import current_app
from flask.cli import with_appcontext
from werkzeug.exceptions import MethodNotAllowed, NotFound
HERE = os.path.abspath(os.path.dirname(__file__))
PROJECT_ROOT = os.path.join(HERE, os.pardir)
TEST_PATH = os.path.join(PROJECT_ROOT, "tests")
@click.command()
def test():
"""Run the tests."""
import pytest
rv = pytest.main([TEST_PATH, "--verbose"])
exit(rv)
@click.command()
@click.option(
"-f",
"--fix-imports",
default=False,
is_flag=True,
help="Fix imports using isort, before linting",
)
def lint(fix_imports):
"""Lint and check code style with flake8 and isort."""
skip = ["node_modules", "requirements"]
root_files = glob("*.py")
root_directories = [
name for name in next(os.walk("."))[1] if not name.startswith(".")
]
files_and_directories = [
arg for arg in root_files + root_directories if arg not in skip
]
def execute_tool(description, *args):
"""Execute a checking tool with its arguments."""
command_line = list(args) + files_and_directories
click.echo("{}: {}".format(description, " ".join(command_line)))
rv = call(command_line)
if rv != 0:
exit(rv)
if fix_imports:
execute_tool("Fixing import order", "isort", "-rc")
execute_tool("Checking code style", "flake8")
@click.command()
def clean():
"""Remove *.pyc and *.pyo files recursively starting at current directory.
Borrowed from Flask-Script, converted to use Click.
"""
for dirpath, dirnames, filenames in os.walk("."):
for filename in filenames:
if filename.endswith(".pyc") or filename.endswith(".pyo"):
full_pathname = os.path.join(dirpath, filename)
click.echo("Removing {}".format(full_pathname))
os.remove(full_pathname)
@click.command()
@click.option("--url", default=None, help="Url to test (ex. /static/image.png)")
@click.option(
"--order", default="rule", help="Property on Rule to order by (default: rule)"
)
@with_appcontext
def urls(url, order):
"""Display all of the url matching routes for the project.
Borrowed from Flask-Script, converted to use Click.
"""
rows = []
column_length = 0
column_headers = ("Rule", "Endpoint", "Arguments")
if url:
try:
rule, arguments = current_app.url_map.bind("localhost").match(
url, return_rule=True
)
rows.append((rule.rule, rule.endpoint, arguments))
column_length = 3
except (NotFound, MethodNotAllowed) as e:
rows.append(("<{}>".format(e), None, None))
column_length = 1
else:
rules = sorted(
current_app.url_map.iter_rules(), key=lambda rule: getattr(rule, order)
)
for rule in rules:
rows.append((rule.rule, rule.endpoint, None))
column_length = 2
str_template = ""
table_width = 0
if column_length >= 1:
max_rule_length = max(len(r[0]) for r in rows)
max_rule_length = max_rule_length if max_rule_length > 4 else 4
str_template += "{:" + str(max_rule_length) + "}"
table_width += max_rule_length
if column_length >= 2:
max_endpoint_length = max(len(str(r[1])) for r in rows)
# max_endpoint_length = max(rows, key=len)
max_endpoint_length = max_endpoint_length if max_endpoint_length > 8 else 8
str_template += " {:" + str(max_endpoint_length) + "}"
table_width += 2 + max_endpoint_length
if column_length >= 3:
max_arguments_length = max(len(str(r[2])) for r in rows)
max_arguments_length = max_arguments_length if max_arguments_length > 9 else 9
str_template += " {:" + str(max_arguments_length) + "}"
table_width += 2 + max_arguments_length
click.echo(str_template.format(*column_headers[:column_length]))
click.echo("-" * table_width)
for row in rows:
click.echo(str_template.format(*row[:column_length]))

View File

@ -0,0 +1,2 @@
from . import views
from . import models

View File

@ -0,0 +1,49 @@
import datetime
from grant.extensions import ma, db
from grant.utils.misc import dt_to_unix
class Comment(db.Model):
__tablename__ = "comment"
id = db.Column(db.Integer(), primary_key=True)
date_created = db.Column(db.DateTime)
content = db.Column(db.Text, nullable=False)
proposal_id = db.Column(db.Integer, db.ForeignKey("proposal.id"), nullable=False)
author_id = db.Column(db.Integer, db.ForeignKey("author.id"), nullable=False)
def __init__(self, proposal_id, author_id, content):
self.proposal_id = proposal_id
self.author_id = author_id
self.content = content
self.date_created = datetime.datetime.now()
class CommentSchema(ma.Schema):
class Meta:
model = Comment
# Fields to expose
fields = (
"author_id",
"content",
"proposal_id",
"date_created",
"body",
)
body = ma.Method("get_body")
date_created = ma.Method("get_date_created")
def get_body(self, obj):
return obj.content
def get_date_created(self, obj):
return dt_to_unix(obj.date_created)
comment_schema = CommentSchema()
comments_schema = CommentSchema(many=True)

View File

@ -0,0 +1,14 @@
from flask import Blueprint
from grant import JSONResponse
from .models import Comment, comments_schema
blueprint = Blueprint("comment", __name__, url_prefix="/api/comment")
@blueprint.route("/", methods=["GET"])
def get_comments():
all_comments = Comment.query.all()
result = comments_schema.dump(all_comments)
return JSONResponse(result)

18
backend/grant/compat.py Normal file
View File

@ -0,0 +1,18 @@
# -*- coding: utf-8 -*-
"""Python 2/3 compatibility module."""
import sys
PY2 = int(sys.version[0]) == 2
if PY2:
text_type = unicode # noqa
binary_type = str
string_types = (str, unicode) # noqa
unicode = unicode # noqa
basestring = basestring # noqa
else:
text_type = str
binary_type = bytes
string_types = (str,)
unicode = str
basestring = (str, bytes)

View File

@ -0,0 +1,11 @@
# -*- coding: utf-8 -*-
"""Extensions module. Each extension is initialized in the app factory located in app.py."""
from flask_bcrypt import Bcrypt
from flask_marshmallow import Marshmallow
from flask_migrate import Migrate
from flask_sqlalchemy import SQLAlchemy
bcrypt = Bcrypt()
db = SQLAlchemy()
migrate = Migrate()
ma = Marshmallow()

View File

@ -0,0 +1,3 @@
from . import views
from . import models
from . import commands

View File

@ -0,0 +1,13 @@
# import click
# from flask.cli import with_appcontext
#
# from .models import Author, db
#
#
# @click.command()
# @click.argument('account_address')
# @with_appcontext
# def create_author(account_address):
# author = Author(account_address)
# db.session.add(author)
# db.session.commit()

View File

@ -0,0 +1,81 @@
import datetime
from grant.extensions import ma, db
FUNDING_REQUIRED = 'FUNDING_REQUIRED'
COMPLETED = 'COMPLETED'
PROPOSAL_STAGES = [FUNDING_REQUIRED, COMPLETED]
NOT_REQUESTED = 'NOT_REQUESTED'
ONGOING_VOTE = 'ONGOING_VOTE'
PAID = 'PAID'
MILESTONE_STAGES = [NOT_REQUESTED, ONGOING_VOTE, PAID]
DAPP = "DAPP"
DEV_TOOL = "DEV_TOOL"
CORE_DEV = "CORE_DEV"
COMMUNITY = "COMMUNITY"
DOCUMENTATION = "DOCUMENTATION"
ACCESSIBILITY = "ACCESSIBILITY"
CATEGORIES = [DAPP, DEV_TOOL, CORE_DEV, COMMUNITY, DOCUMENTATION, ACCESSIBILITY]
class Milestone(db.Model):
__tablename__ = "milestone"
id = db.Column(db.Integer(), primary_key=True)
date_created = db.Column(db.DateTime, nullable=False)
title = db.Column(db.String(255), nullable=False)
content = db.Column(db.Text, nullable=False)
stage = db.Column(db.String(255), nullable=False)
payout_percent = db.Column(db.String(255), nullable=False)
immediate_payout = db.Column(db.Boolean)
date_estimated = db.Column(db.DateTime, nullable=False)
proposal_id = db.Column(db.Integer, db.ForeignKey("proposal.id"), nullable=False)
def __init__(
self,
title: str,
content: str,
date_estimated: datetime,
payout_percent: str,
immediate_payout: bool,
stage: str = NOT_REQUESTED,
proposal_id=int
):
self.title = title
self.content = content
self.stage = stage
self.date_estimated = date_estimated
self.payout_percent = payout_percent
self.immediate_payout = immediate_payout
self.proposal_id = proposal_id
self.date_created = datetime.datetime.now()
class MilestoneSchema(ma.Schema):
class Meta:
model = Milestone
# Fields to expose
fields = (
"title",
"body",
"content",
"stage",
"date_estimated",
"payout_percent",
"immediate_payout",
"date_created",
)
body = ma.Method("get_body")
def get_body(self, obj):
return obj.content
milestone_schema = MilestoneSchema()
milestones_schema = MilestoneSchema(many=True)

View File

@ -0,0 +1,13 @@
from flask import Blueprint
from grant import JSONResponse
from .models import Milestone, milestones_schema
blueprint = Blueprint('milestone', __name__, url_prefix='/api/milestones')
@blueprint.route("/", methods=["GET"])
def get_authors():
milestones = Milestone.query.all()
result = milestones_schema.dump(milestones)
return JSONResponse(result)

View File

@ -0,0 +1,3 @@
from . import commands
from . import models
from . import views

View File

@ -0,0 +1,21 @@
import click
from flask.cli import with_appcontext
from .models import Proposal, db
@click.command()
@click.argument('stage')
@click.argument('author_id')
@click.argument('proposal_id')
@click.argument('title')
@click.argument('content')
@with_appcontext
def create_proposal(stage, author_id, proposal_id, title, content):
proposal = Proposal.create(stage=stage,
author_id=author_id,
proposal_id=proposal_id,
title=title,
content=content)
db.session.add(proposal)
db.session.commit()

View File

@ -0,0 +1,113 @@
import datetime
from grant.comment.models import Comment
from grant.extensions import ma, db
from grant.utils.misc import dt_to_unix
FUNDING_REQUIRED = 'FUNDING_REQUIRED'
COMPLETED = 'COMPLETED'
PROPOSAL_STAGES = [FUNDING_REQUIRED, COMPLETED]
DAPP = "DAPP"
DEV_TOOL = "DEV_TOOL"
CORE_DEV = "CORE_DEV"
COMMUNITY = "COMMUNITY"
DOCUMENTATION = "DOCUMENTATION"
ACCESSIBILITY = "ACCESSIBILITY"
CATEGORIES = [DAPP, DEV_TOOL, CORE_DEV, COMMUNITY, DOCUMENTATION, ACCESSIBILITY]
class ValidationException(Exception):
pass
class Proposal(db.Model):
__tablename__ = "proposal"
id = db.Column(db.Integer(), primary_key=True)
date_created = db.Column(db.DateTime)
title = db.Column(db.String(255), nullable=False)
proposal_id = db.Column(db.String(255), unique=True, nullable=False)
stage = db.Column(db.String(255), nullable=False)
content = db.Column(db.Text, nullable=False)
category = db.Column(db.String(255), nullable=False)
author_id = db.Column(db.Integer, db.ForeignKey("author.id"), nullable=False)
comments = db.relationship(Comment, backref="proposal", lazy=True)
milestones = db.relationship("Milestone", backref="proposal", lazy=True)
def __init__(
self,
stage: str,
author_id: int,
proposal_id: str,
title: str,
content: str,
category: str
):
self.stage = stage
self.author_id = author_id
self.proposal_id = proposal_id
self.title = title
self.content = content
self.category = category
self.date_created = datetime.datetime.now()
@staticmethod
def validate(
stage: str,
author_id: int,
proposal_id: str,
title: str,
content: str,
category: str):
if stage not in PROPOSAL_STAGES:
raise ValidationException("{} not in {}".format(stage, PROPOSAL_STAGES))
if category not in CATEGORIES:
raise ValidationException("{} not in {}".format(category, CATEGORIES))
@staticmethod
def create(**kwargs):
Proposal.validate(**kwargs)
return Proposal(
**kwargs
)
class ProposalSchema(ma.Schema):
class Meta:
model = Proposal
# Fields to expose
fields = (
"stage",
"date_created",
"title",
"proposal_id",
"body",
"comments",
"author",
"milestones",
"category"
)
date_created = ma.Method("get_date_created")
proposal_id = ma.Method("get_proposal_id")
body = ma.Method("get_body")
comments = ma.Nested("CommentSchema", many=True)
author = ma.Nested("AuthorSchema")
milestones = ma.Nested("MilestoneSchema", many=True)
def get_body(self, obj):
return obj.content
def get_proposal_id(self, obj):
return obj.proposal_id
def get_date_created(self, obj):
return dt_to_unix(obj.date_created)
proposal_schema = ProposalSchema()
proposals_schema = ProposalSchema(many=True)

View File

@ -0,0 +1,108 @@
from datetime import datetime
from flask import Blueprint, request
from sqlalchemy.exc import IntegrityError
from grant import JSONResponse
from .models import Proposal, proposals_schema, proposal_schema, db
from grant.milestone.models import Milestone
blueprint = Blueprint("proposal", __name__, url_prefix="/api/proposals")
def __adjust_dumped_proposal(proposal):
cur_author = proposal["author"]
proposal["team"] = [cur_author]
return proposal
@blueprint.route("/<proposal_id>", methods=["GET"])
def get_proposal(proposal_id):
proposal = Proposal.query.filter_by(proposal_id=proposal_id).first()
if proposal:
dumped_proposal = proposal_schema.dump(proposal)
return JSONResponse(__adjust_dumped_proposal(dumped_proposal))
else:
return JSONResponse(message="No proposal matching id", _statusCode=404)
@blueprint.route("/<proposal_id>/comments", methods=["GET"])
def get_proposal_comments(proposal_id):
proposals = Proposal.query.filter_by(proposal_id=proposal_id).first()
if proposals:
results = proposal_schema.dump(proposals)
return JSONResponse(
proposal_id=proposal_id,
total_comments=len(results["comments"]),
comments=results["comments"]
)
else:
return JSONResponse(message="No proposal matching id", _statusCode=404)
@blueprint.route("/", methods=["GET"])
def get_proposals():
stage = request.args.get("stage")
if stage:
proposals = (
Proposal.query.filter_by(stage=stage)
.order_by(Proposal.date_created.desc())
.all()
)
else:
proposals = Proposal.query.order_by(Proposal.date_created.desc()).all()
results = map(__adjust_dumped_proposal, proposals_schema.dump(proposals))
return JSONResponse(results)
@blueprint.route("/create", methods=["POST"])
def make_proposal():
from grant.author.models import Author
incoming = request.get_json()
account_address = incoming["accountAddress"]
proposal_id = incoming["crowdFundContractAddress"]
content = incoming["content"]
title = incoming["title"]
milestones = incoming["milestones"]
category = incoming["category"]
author = Author.query.filter_by(account_address=account_address).first()
if not author:
author = Author(account_address=account_address)
db.session.add(author)
db.session.commit()
proposal = Proposal.create(
stage="FUNDING_REQUIRED",
proposal_id=proposal_id,
content=content,
title=title,
author_id=author.id,
category=category
)
db.session.add(proposal)
db.session.commit()
for each_milestone in milestones:
m = Milestone(
title=each_milestone["title"],
content=each_milestone["description"],
date_estimated=datetime.strptime(each_milestone["date"], '%B %Y'),
payout_percent=str(each_milestone["payoutPercent"]),
immediate_payout=each_milestone["immediatePayout"],
proposal_id=proposal.id
)
db.session.add(m)
try:
db.session.commit()
except IntegrityError as e:
print(e)
return JSONResponse(message="Proposal with that hash already exists", _statusCode=409)
results = proposal_schema.dump(proposal)
return JSONResponse(results, _statusCode=204)

23
backend/grant/settings.py Normal file
View File

@ -0,0 +1,23 @@
# -*- coding: utf-8 -*-
"""Application configuration.
Most configuration is set via environment variables.
For local development, use a .env file to set
environment variables.
"""
from environs import Env
env = Env()
env.read_env()
ENV = env.str("FLASK_ENV", default="production")
DEBUG = ENV == "development"
SQLALCHEMY_DATABASE_URI = env.str("DATABASE_URL")
QUEUES = ["default"]
SECRET_KEY = env.str("SECRET_KEY")
BCRYPT_LOG_ROUNDS = env.int("BCRYPT_LOG_ROUNDS", default=13)
DEBUG_TB_ENABLED = DEBUG
DEBUG_TB_INTERCEPT_REDIRECTS = False
CACHE_TYPE = "simple" # Can be "memcached", "redis", etc.
SQLALCHEMY_TRACK_MODIFICATIONS = False

View File

View File

@ -0,0 +1,43 @@
from functools import wraps
from flask import request, g, jsonify
from itsdangerous import SignatureExpired, BadSignature
from itsdangerous import TimedJSONWebSignatureSerializer as Serializer
from grant.settings import SECRET_KEY
TWO_WEEKS = 1209600
def generate_token(user, expiration=TWO_WEEKS):
s = Serializer(SECRET_KEY, expires_in=expiration)
token = s.dumps({
'id': user.id,
'email': user.email,
}).decode('utf-8')
return token
def verify_token(token):
s = Serializer(SECRET_KEY)
try:
data = s.loads(token)
except (BadSignature, SignatureExpired):
return None
return data
def requires_auth(f):
@wraps(f)
def decorated(*args, **kwargs):
token = request.headers.get('Authorization', None)
if token:
string_token = token.encode('ascii', 'ignore')
user = verify_token(string_token)
if user:
g.current_user = user
return f(*args, **kwargs)
return jsonify(message="Authentication is required to access this resource"), 401
return decorated

View File

@ -0,0 +1,4 @@
import math
def get_quarter_formatted(date):
return "Q" + str(math.ceil(date.date_created.month / 3.)) + " " + str(date.date_created.year)

View File

@ -0,0 +1,89 @@
import datetime
import time
from contextlib import closing
from requests import get
from requests.exceptions import RequestException
def simple_get(url):
"""
Attempts to get the content at `url` by making an HTTP GET request.
If the content-type of response is some kind of HTML/XML, return the
text content, otherwise return None.
"""
try:
with closing(get(url, stream=True)) as resp:
if is_good_response(resp):
return resp.content
else:
return None
except RequestException as e:
log_error("Error during requests to {0} : {1}".format(url, str(e)))
return None
def is_good_response(resp):
"""
Returns True if the response seems to be HTML, False otherwise.
"""
content_type = resp.headers["Content-Type"].lower()
return (
resp.status_code == 200
and content_type is not None
and content_type.find("html") > -1
)
def log_error(e):
"""
It is always a good idea to log errors.
This function just prints them, but you can
make it do anything.
"""
print(e)
def strip_number_formatting_from_string(string: str) -> str:
return string.replace(",", "").replace(".", "").replace(" ", "").strip()
def convert_monero_to_piconero(monero_string: str) -> int:
monero_string = strip_number_formatting_from_string(monero_string)
for _ in range(11):
monero_string += "0"
return int(monero_string)
def convert_piconero_to_monero(piconero_string: str) -> str:
reversed_piconero = piconero_string[::-1]
added_decimal = reversed_piconero[:13] + "." + reversed_piconero[13:]
unreversed_piconero = added_decimal[::-1]
return unreversed_piconero
def convert_string_money_to_float(money_string: str) -> float:
reversed_money_string = money_string[::-1]
added_decimal = reversed_money_string[:2] + "." + reversed_money_string[2:]
unreversed_money_string = added_decimal[::-1]
return float(unreversed_money_string)
epoch = datetime.datetime.utcfromtimestamp(0)
def dt_from_ms(ms):
return datetime.datetime.utcfromtimestamp(ms / 1000.0)
def dt_to_ms(dt):
delta = dt - epoch
return int(delta.total_seconds() * 1000)
def dt_to_unix(dt):
return int(time.mktime(dt.timetuple()))

View File

@ -0,0 +1 @@
Generic single-database configuration.

View File

@ -0,0 +1,45 @@
# A generic, single database configuration.
[alembic]
# template used to generate migration files
# file_template = %%(rev)s_%%(slug)s
# set to 'true' to run the environment during
# the 'revision' command, regardless of autogenerate
# revision_environment = false
# Logging configuration
[loggers]
keys = root,sqlalchemy,alembic
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARN
handlers = console
qualname =
[logger_sqlalchemy]
level = WARN
handlers =
qualname = sqlalchemy.engine
[logger_alembic]
level = INFO
handlers =
qualname = alembic
[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic
[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
datefmt = %H:%M:%S

87
backend/migrations/env.py Normal file
View File

@ -0,0 +1,87 @@
from __future__ import with_statement
from alembic import context
from sqlalchemy import engine_from_config, pool
from logging.config import fileConfig
import logging
# this is the Alembic Config object, which provides
# access to the values within the .ini file in use.
config = context.config
# Interpret the config file for Python logging.
# This line sets up loggers basically.
fileConfig(config.config_file_name)
logger = logging.getLogger('alembic.env')
# add your model's MetaData object here
# for 'autogenerate' support
# from myapp import mymodel
# target_metadata = mymodel.Base.metadata
from flask import current_app
config.set_main_option('sqlalchemy.url',
current_app.config.get('SQLALCHEMY_DATABASE_URI'))
target_metadata = current_app.extensions['migrate'].db.metadata
# other values from the config, defined by the needs of env.py,
# can be acquired:
# my_important_option = config.get_main_option("my_important_option")
# ... etc.
def run_migrations_offline():
"""Run migrations in 'offline' mode.
This configures the context with just a URL
and not an Engine, though an Engine is acceptable
here as well. By skipping the Engine creation
we don't even need a DBAPI to be available.
Calls to context.execute() here emit the given string to the
script output.
"""
url = config.get_main_option("sqlalchemy.url")
context.configure(url=url)
with context.begin_transaction():
context.run_migrations()
def run_migrations_online():
"""Run migrations in 'online' mode.
In this scenario we need to create an Engine
and associate a connection with the context.
"""
# this callback is used to prevent an auto-migration from being generated
# when there are no changes to the schema
# reference: http://alembic.zzzcomputing.com/en/latest/cookbook.html
def process_revision_directives(context, revision, directives):
if getattr(config.cmd_opts, 'autogenerate', False):
script = directives[0]
if script.upgrade_ops.is_empty():
directives[:] = []
logger.info('No changes in schema detected.')
engine = engine_from_config(config.get_section(config.config_ini_section),
prefix='sqlalchemy.',
poolclass=pool.NullPool)
connection = engine.connect()
context.configure(connection=connection,
target_metadata=target_metadata,
process_revision_directives=process_revision_directives,
**current_app.extensions['migrate'].configure_args)
try:
with context.begin_transaction():
context.run_migrations()
finally:
connection.close()
if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()

View File

@ -0,0 +1,24 @@
"""${message}
Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Create Date: ${create_date}
"""
from alembic import op
import sqlalchemy as sa
${imports if imports else ""}
# revision identifiers, used by Alembic.
revision = ${repr(up_revision)}
down_revision = ${repr(down_revision)}
branch_labels = ${repr(branch_labels)}
depends_on = ${repr(depends_on)}
def upgrade():
${upgrades if upgrades else "pass"}
def downgrade():
${downgrades if downgrades else "pass"}

View File

@ -0,0 +1,73 @@
"""empty message
Revision ID: 755ffe44f145
Revises:
Create Date: 2018-09-07 23:29:13.564329
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '755ffe44f145'
down_revision = None
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('author',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('account_address', sa.String(length=255), nullable=True),
sa.Column('avatar', sa.String(length=255), nullable=True),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('account_address')
)
op.create_table('proposal',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('date_created', sa.DateTime(), nullable=True),
sa.Column('title', sa.String(length=255), nullable=False),
sa.Column('proposal_id', sa.String(length=255), nullable=False),
sa.Column('stage', sa.String(length=255), nullable=False),
sa.Column('content', sa.Text(), nullable=False),
sa.Column('category', sa.String(length=255), nullable=False),
sa.Column('author_id', sa.Integer(), nullable=False),
sa.ForeignKeyConstraint(['author_id'], ['author.id'], ),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('proposal_id')
)
op.create_table('comment',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('date_created', sa.DateTime(), nullable=True),
sa.Column('content', sa.Text(), nullable=False),
sa.Column('proposal_id', sa.Integer(), nullable=False),
sa.Column('author_id', sa.Integer(), nullable=False),
sa.ForeignKeyConstraint(['author_id'], ['author.id'], ),
sa.ForeignKeyConstraint(['proposal_id'], ['proposal.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_table('milestone',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('date_created', sa.DateTime(), nullable=False),
sa.Column('title', sa.String(length=255), nullable=False),
sa.Column('content', sa.Text(), nullable=False),
sa.Column('stage', sa.String(length=255), nullable=False),
sa.Column('payout_percent', sa.String(length=255), nullable=False),
sa.Column('immediate_payout', sa.Boolean(), nullable=True),
sa.Column('date_estimated', sa.DateTime(), nullable=False),
sa.Column('proposal_id', sa.Integer(), nullable=False),
sa.ForeignKeyConstraint(['proposal_id'], ['proposal.id'], ),
sa.PrimaryKeyConstraint('id')
)
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.drop_table('milestone')
op.drop_table('comment')
op.drop_table('proposal')
op.drop_table('author')
# ### end Alembic commands ###

3
backend/requirements.txt Normal file
View File

@ -0,0 +1,3 @@
# Included because many Paas's require a requirements.txt file in the project root
# Just installs the production requirements.
-r requirements/prod.txt

View File

@ -0,0 +1,18 @@
# Everything the developer needs in addition to the production requirements
-r prod.txt
# Testing
pytest==3.7.1
WebTest==2.0.30
factory-boy==2.11.1
# Lint and code style
flake8==3.5.0
flake8-blind-except==0.1.1
flake8-debugger==3.1.0
flake8-docstrings==1.3.0
flake8-isort==2.5
flake8-quotes==1.0.0
isort==4.3.4
pep8-naming==0.7.0
pre-commit

View File

@ -0,0 +1,53 @@
# Everything needed in production
# Flask
Flask==1.0.2
MarkupSafe==1.0
Werkzeug==0.14.1
Jinja2==2.10
itsdangerous==0.24
click>=5.0
# Database
Flask-SQLAlchemy==2.3.2
psycopg2==2.7.5
SQLAlchemy==1.2.10
# Migrations
Flask-Migrate==2.2.1
# Forms
Flask-WTF==0.14.2
WTForms==2.2.1
# Serialization
marshmallow==3.0.0b13
flask-marshmallow==0.9.0
marshmallow-sqlalchemy
# CORS
Flask-Cors==3.0.6
# Deployment
gunicorn>=19.1.1
# Auth
Flask-Bcrypt==0.7.1
# Caching
Flask-Caching>=1.0.0
# Environment variable parsing
environs==4.0.0
# HTTP
requests
beautifulsoup4==4.6.1
# task queue
redis==2.10.6
# md
markdownify

3
backend/setup.cfg Normal file
View File

@ -0,0 +1,3 @@
[flake8]
ignore = D401
max-line-length=120

View File

@ -0,0 +1 @@
"""Tests for the app."""

50
backend/tests/conftest.py Normal file
View File

@ -0,0 +1,50 @@
# -*- coding: utf-8 -*-
"""Defines fixtures available to all tests."""
import pytest
from webtest import TestApp
from grant.app import create_app
from grant.app import db as _db
from .factories import UserFactory
@pytest.fixture
def app():
"""An application for the tests."""
_app = create_app('tests.settings')
ctx = _app.test_request_context()
ctx.push()
yield _app
ctx.pop()
@pytest.fixture
def testapp(app):
"""A Webtest app."""
return TestApp(app)
@pytest.fixture
def db(app):
"""A database for the tests."""
_db.app = app
with app.app_context():
_db.create_all()
yield _db
# Explicitly close DB connection
_db.session.close()
_db.drop_all()
@pytest.fixture
def user(db):
"""A user for the tests."""
user = UserFactory(password='myprecious')
db.session.commit()
return user

View File

@ -0,0 +1,29 @@
# -*- coding: utf-8 -*-
"""Factories to help in tests."""
from factory import PostGenerationMethodCall, Sequence
from factory.alchemy import SQLAlchemyModelFactory
from grant.app import db
class BaseFactory(SQLAlchemyModelFactory):
"""Base factory."""
class Meta:
"""Factory configuration."""
abstract = True
sqlalchemy_session = db.session
class UserFactory(BaseFactory):
"""User factory."""
username = Sequence(lambda n: 'user{0}'.format(n))
email = Sequence(lambda n: 'user{0}@example.com'.format(n))
password = PostGenerationMethodCall('set_password', 'example')
active = True
class Meta:
"""Factory configuration."""

10
backend/tests/settings.py Normal file
View File

@ -0,0 +1,10 @@
"""Settings module for test app."""
ENV = 'development'
TESTING = True
SQLALCHEMY_DATABASE_URI = 'sqlite://'
SECRET_KEY = 'not-so-secret-in-tests'
BCRYPT_LOG_ROUNDS = 4 # For faster tests; needs at least 4 to avoid "ValueError: Invalid rounds"
DEBUG_TB_ENABLED = False
CACHE_TYPE = 'simple' # Can be "memcached", "redis", etc.
SQLALCHEMY_TRACK_MODIFICATIONS = False
WTF_CSRF_ENABLED = False # Allows form testing

View File

@ -0,0 +1,67 @@
# # -*- coding: utf-8 -*-
# """Model unit tests."""
# import datetime as dt
#
# import pytest
#
# from grant.user.models import Role, User
#
# from .factories import UserFactory
#
#
# @pytest.mark.usefixtures('db')
# class TestUser:
# """User tests."""
#
# def test_get_by_id(self):
# """Get user by ID."""
# user = User('foo', 'foo@bar.com')
# user.save()
#
# retrieved = User.get_by_id(user.id)
# assert retrieved == user
#
# def test_created_at_defaults_to_datetime(self):
# """Test creation date."""
# user = User(username='foo', email='foo@bar.com')
# user.save()
# assert bool(user.created_at)
# assert isinstance(user.created_at, dt.datetime)
#
# def test_password_is_nullable(self):
# """Test null password."""
# user = User(username='foo', email='foo@bar.com')
# user.save()
# assert user.password is None
#
# def test_factory(self, db):
# """Test user factory."""
# user = UserFactory(password='myprecious')
# db.session.commit()
# assert bool(user.username)
# assert bool(user.email)
# assert bool(user.created_at)
# assert user.is_admin is False
# assert user.active is True
# assert user.check_password('myprecious')
#
# def test_check_password(self):
# """Check password."""
# user = User.create(username='foo', email='foo@bar.com',
# password='foobarbaz123')
# assert user.check_password('foobarbaz123') is True
# assert user.check_password('barfoobaz') is False
#
# def test_full_name(self):
# """User full name."""
# user = UserFactory(first_name='Foo', last_name='Bar')
# assert user.full_name == 'Foo Bar'
#
# def test_roles(self):
# """Add a role to a user."""
# role = Role(name='admin')
# role.save()
# user = UserFactory()
# user.roles.append(role)
# user.save()
# assert role in user.roles

2
contract/.envexample Normal file
View File

@ -0,0 +1,2 @@
INFURA_KEY=key
MNEMONIC=mnemonic

5
contract/.gitignore vendored Normal file
View File

@ -0,0 +1,5 @@
node_modules
build
.idea/
yarn-error.log
.env

42
contract/README.md Normal file
View File

@ -0,0 +1,42 @@
# Grant.io Smart Contracts
This is a collection of the smart contracts and associated testing and build
process used for the [Grant.io](http://grant.io) dApp.
## API
This repo provides Truffle build artifacts, ABI json, and type definitions
for all contracts. You can import them like so:
```ts
import {
EscrowContract, // Truffle build artifacts
EscrowABI, // Contract ABI
Escrow, // Contract type defintion
} from 'grant-contracts';
```
## Commands
To run any commands, you must install node dependencies, and have `truffle`
installed globally.
### Testing
```bash
yarn run test
```
Runs the truffle test suite
### Building
```bash
yarn run build
```
Builds the contract artifact JSON files, ABI JSON files, and type definitions
### Publishing
TBD

31
contract/bin/build-abi.js Normal file
View File

@ -0,0 +1,31 @@
const fs = require('fs');
const path = require('path');
const contractsPath = path.resolve(__dirname, '../build/contracts');
const abiPath = path.resolve(__dirname, '../build/abi');
fs.readdir(contractsPath, (err, files) => {
if (err) {
console.error(err);
process.exit(1);
}
if (!fs.existsSync(abiPath)) {
fs.mkdirSync(abiPath);
}
files.forEach(file => {
fs.readFile(
path.join(contractsPath, file),
{ encoding: 'utf8'},
(err, data) => {
if (err) {
console.error(err);
process.exit(1);
}
const json = JSON.parse(data);
fs.writeFileSync(path.join(abiPath, file), JSON.stringify(json.abi, null, 2));
}
);
});
});

View File

@ -0,0 +1,10 @@
const path = require('path');
const { generateTypeChainWrappers } = require('typechain');
process.env.DEBUG = 'typechain';
generateTypeChainWrappers({
cwd: path.resolve(__dirname, '..'),
glob: path.resolve(__dirname, '../build/abi/*.json'),
outDir: path.resolve(__dirname, '../build/typedefs'),
force: true,
});

View File

@ -0,0 +1,278 @@
pragma solidity ^0.4.24;
import "openzeppelin-solidity/contracts/math/SafeMath.sol";
contract CrowdFund {
using SafeMath for uint256;
struct Milestone {
uint amount;
uint amountVotingAgainstPayout;
uint payoutRequestVoteDeadline;
bool paid;
}
struct Contributor {
uint contributionAmount;
// array index bool reflect milestone index vote
bool[] milestoneNoVotes;
bool refundVote;
bool refunded;
}
event Deposited(address indexed payee, uint256 weiAmount);
event Withdrawn(address indexed payee, uint256 weiAmount);
bool public frozen;
bool public isRaiseGoalReached;
bool public immediateFirstMilestonePayout;
uint public milestoneVotingPeriod;
uint public deadline;
uint public raiseGoal;
uint public amountRaised;
uint public minimumContributionAmount;
uint public amountVotingForRefund;
address public beneficiary;
mapping(address => Contributor) public contributors;
address[] public contributorList;
// authorized addresses to ask for milestone payouts
address[] public trustees;
// constructor ensures that all values combined equal raiseGoal
Milestone[] public milestones;
constructor(
uint _raiseGoal,
address _beneficiary,
address[] _trustees,
uint[] _milestones,
uint _deadline,
uint _milestoneVotingPeriod,
bool _immediateFirstMilestonePayout)
public {
require(_raiseGoal >= 1 ether, "Raise goal is smaller than 1 ether");
require(_trustees.length >= 1 && _trustees.length <= 10, "Trustee addresses must be at least 1 and not more than 10");
require(_milestones.length >= 1 && _milestones.length <= 10, "Milestones must be at least 1 and not more than 10");
// TODO - require minimum duration
// TODO - require minimum milestone voting period
// ensure that cumalative milestone payouts equal raiseGoalAmount
uint milestoneTotal = 0;
for (uint i = 0; i < _milestones.length; i++) {
uint milestoneAmount = _milestones[i];
require(milestoneAmount > 0, "Milestone amount must be greater than 0");
milestoneTotal = milestoneTotal.add(milestoneAmount);
milestones.push(Milestone({
amount: milestoneAmount,
payoutRequestVoteDeadline: 0,
amountVotingAgainstPayout: 0,
paid: false
}));
}
require(milestoneTotal == _raiseGoal, "Milestone total must equal raise goal");
// TODO - increase minimum contribution amount is 0.1% of raise goal
minimumContributionAmount = 1;
raiseGoal = _raiseGoal;
beneficiary = _beneficiary;
trustees = _trustees;
deadline = now + _deadline;
milestoneVotingPeriod = _milestoneVotingPeriod;
immediateFirstMilestonePayout = _immediateFirstMilestonePayout;
isRaiseGoalReached = false;
amountVotingForRefund = 0;
frozen = false;
// assumes no ether contributed as part of contract deployment
amountRaised = 0;
}
function contribute() public payable onlyOnGoing onlyUnfrozen {
// don't allow overfunding
uint newAmountRaised = amountRaised.add(msg.value);
require(newAmountRaised <= raiseGoal, "Contribution exceeds the raise goal.");
// require minimumContributionAmount (set during construction)
// there may be a special case where just enough has been raised so that the remaining raise amount is just smaller than the minimumContributionAmount
// in this case, allow that the msg.value + amountRaised will equal the raiseGoal.
// This makes sure that we don't enter a scenario where a proposal can never be fully funded
bool greaterThanMinimum = msg.value >= minimumContributionAmount;
bool exactlyRaiseGoal = newAmountRaised == raiseGoal;
require(greaterThanMinimum || exactlyRaiseGoal, "msg.value greater than minimum, or msg.value == remaining amount to be raised");
// in cases where an address pays > 1 times
if (contributors[msg.sender].contributionAmount == 0) {
contributors[msg.sender] = Contributor({
contributionAmount: msg.value,
milestoneNoVotes: new bool[](milestones.length),
refundVote: false,
refunded: false
});
contributorList.push(msg.sender);
}
else {
contributors[msg.sender].contributionAmount = contributors[msg.sender].contributionAmount.add(msg.value);
}
amountRaised = newAmountRaised;
if (amountRaised == raiseGoal) {
isRaiseGoalReached = true;
}
emit Deposited(msg.sender, msg.value);
}
function requestMilestonePayout (uint index) public onlyTrustee onlyRaised onlyUnfrozen {
bool milestoneAlreadyPaid = milestones[index].paid;
bool voteDeadlineHasPassed = milestones[index].payoutRequestVoteDeadline > now;
bool majorityAgainstPayout = isMajorityVoting(milestones[index].amountVotingAgainstPayout);
// prevent requesting paid milestones
require(!milestoneAlreadyPaid, "Milestone already paid");
int lowestIndexPaid = -1;
for (uint i = 0; i < milestones.length; i++) {
if (milestones[i].paid) {
lowestIndexPaid = int(i);
}
}
require(index == uint(lowestIndexPaid + 1), "Milestone request must be for first unpaid milestone");
// begin grace period for contributors to vote no on milestone payout
if (milestones[index].payoutRequestVoteDeadline == 0) {
if (index == 0 && immediateFirstMilestonePayout) {
// make milestone payouts immediately avtheailable for the first milestone if immediateFirstMilestonePayout is set during consutrction
milestones[index].payoutRequestVoteDeadline = 1;
} else {
milestones[index].payoutRequestVoteDeadline = now.add(milestoneVotingPeriod);
}
}
// if the payoutRequestVoteDealine has passed and majority voted against it previously, begin the grace period with 2 times the deadline
else if (voteDeadlineHasPassed && majorityAgainstPayout) {
milestones[index].payoutRequestVoteDeadline = now.add(milestoneVotingPeriod.mul(2));
}
}
function voteMilestonePayout(uint index, bool vote) public onlyContributor onlyRaised onlyUnfrozen {
bool existingMilestoneNoVote = contributors[msg.sender].milestoneNoVotes[index];
require(existingMilestoneNoVote != vote, "Vote value must be different than existing vote state");
bool milestoneVotingStarted = milestones[index].payoutRequestVoteDeadline > 0;
bool votePeriodHasEnded = milestones[index].payoutRequestVoteDeadline <= now;
bool onGoingVote = milestoneVotingStarted && !votePeriodHasEnded;
require(onGoingVote, "Milestone voting must be open");
contributors[msg.sender].milestoneNoVotes[index] = vote;
if (!vote) {
milestones[index].amountVotingAgainstPayout = milestones[index].amountVotingAgainstPayout.sub(contributors[msg.sender].contributionAmount);
} else if (vote) {
milestones[index].amountVotingAgainstPayout = milestones[index].amountVotingAgainstPayout.add(contributors[msg.sender].contributionAmount);
}
}
function payMilestonePayout(uint index) public onlyRaised onlyUnfrozen {
bool voteDeadlineHasPassed = milestones[index].payoutRequestVoteDeadline < now;
bool majorityVotedNo = isMajorityVoting(milestones[index].amountVotingAgainstPayout);
bool milestoneAlreadyPaid = milestones[index].paid;
if (voteDeadlineHasPassed && !majorityVotedNo && !milestoneAlreadyPaid) {
milestones[index].paid = true;
uint amount = milestones[index].amount;
beneficiary.transfer(amount);
emit Withdrawn(beneficiary, amount);
// if the final milestone just got paid
if (milestones.length.sub(index) == 1) {
// useful to selfdestruct in case funds were forcefully deposited into contract. otherwise they are lost.
selfdestruct(beneficiary);
}
} else {
revert("required conditions were not satisfied");
}
}
function voteRefund(bool vote) public onlyContributor onlyRaised onlyUnfrozen {
bool refundVote = contributors[msg.sender].refundVote;
require(vote != refundVote, "Existing vote state is identical to vote value");
contributors[msg.sender].refundVote = vote;
if (!vote) {
amountVotingForRefund = amountVotingForRefund.sub(contributors[msg.sender].contributionAmount);
}
else if (vote) {
amountVotingForRefund = amountVotingForRefund.add(contributors[msg.sender].contributionAmount);
}
}
function refund() public onlyUnfrozen {
bool callerIsTrustee = isCallerTrustee();
bool crowdFundFailed = isFailed();
bool majorityVotingToRefund = isMajorityVoting(amountVotingForRefund);
require(callerIsTrustee || crowdFundFailed || majorityVotingToRefund, "Required conditions for refund are not met");
frozen = true;
}
// anyone can refund a contributor if a crowdfund has been frozen
function withdraw(address refundAddress) public onlyFrozen {
require(frozen, "CrowdFund is not frozen");
bool isRefunded = contributors[refundAddress].refunded;
require(!isRefunded, "Specified address is already refunded");
contributors[refundAddress].refunded = true;
uint contributionAmount = contributors[refundAddress].contributionAmount;
// TODO - maybe don't use address(this).balance
uint amountToRefund = contributionAmount.mul(address(this).balance).div(raiseGoal);
refundAddress.transfer(amountToRefund);
emit Withdrawn(refundAddress, amountToRefund);
}
// it may be useful to selfdestruct in case funds were force deposited to the contract
function destroy() public onlyTrustee onlyFrozen {
for (uint i = 0; i < contributorList.length; i++) {
address contributorAddress = contributorList[i];
if (!contributors[contributorAddress].refunded) {
revert("At least one contributor has not yet refunded");
}
}
selfdestruct(beneficiary);
}
function isMajorityVoting(uint valueVoting) public view returns (bool) {
return valueVoting.mul(2) > amountRaised;
}
function isCallerTrustee() public view returns (bool) {
for (uint i = 0; i < trustees.length; i++) {
if (msg.sender == trustees[i]) {
return true;
}
}
return false;
}
function isFailed() public view returns (bool) {
return now >= deadline && !isRaiseGoalReached;
}
function getContributorMilestoneVote(address contributorAddress, uint milestoneIndex) public view returns (bool) {
return contributors[contributorAddress].milestoneNoVotes[milestoneIndex];
}
modifier onlyFrozen() {
require(frozen, "CrowdFund is not frozen");
_;
}
modifier onlyUnfrozen() {
require(!frozen, "CrowdFund is frozen");
_;
}
modifier onlyRaised() {
require(isRaiseGoalReached, "Raise goal is not reached");
_;
}
modifier onlyOnGoing() {
require(now <= deadline && !isRaiseGoalReached, "CrowdFund is not ongoing");
_;
}
modifier onlyContributor() {
require(contributors[msg.sender].contributionAmount != 0, "Caller is not a contributor");
_;
}
modifier onlyTrustee() {
require(isCallerTrustee(), "Caller is not a trustee");
_;
}
}

View File

@ -0,0 +1,31 @@
pragma solidity ^0.4.24;
import "./CrowdFund.sol";
contract CrowdFundFactory {
address[] crowdfunds;
event ContractCreated(address newAddress);
function createCrowdFund (
uint raiseGoalAmount,
address payOutAddress,
address[] trusteesAddresses,
uint[] allMilestones,
uint durationInSeconds,
uint milestoneVotingPeriodInSeconds,
bool immediateFirstMilestonePayout
) public returns(address) {
address newCrowdFundContract = new CrowdFund(
raiseGoalAmount,
payOutAddress,
trusteesAddresses,
allMilestones,
durationInSeconds,
milestoneVotingPeriodInSeconds,
immediateFirstMilestonePayout
);
emit ContractCreated(newCrowdFundContract);
crowdfunds.push(newCrowdFundContract);
return newCrowdFundContract;
}
}

View File

@ -0,0 +1,15 @@
pragma solidity ^0.4.24;
contract Forward {
address public destinationAddress;
constructor(address _destinationAddress) public {
destinationAddress = _destinationAddress;
}
function() public payable { }
function payOut() public {
destinationAddress.transfer(address(this).balance);
}
}

View File

@ -0,0 +1,23 @@
pragma solidity ^0.4.23;
contract Migrations {
address public owner;
uint public last_completed_migration;
constructor() public {
owner = msg.sender;
}
modifier restricted() {
if (msg.sender == owner) _;
}
function setCompleted(uint completed) public restricted {
last_completed_migration = completed;
}
function upgrade(address new_address) public restricted {
Migrations upgraded = Migrations(new_address);
upgraded.setCompleted(last_completed_migration);
}
}

View File

@ -0,0 +1,202 @@
pragma solidity ^0.4.24;
import "openzeppelin-solidity/contracts/math/SafeMath.sol";
contract PrivateFund {
using SafeMath for uint256;
struct Milestone {
uint amount;
bool openRequest;
bool paid;
}
struct BoardMember {
bool[] milestoneApprovals;
address refundAddress;
// TODO - refactor not to waste space like this;
bool exists;
}
event Transfered(address payee, uint weiAmount);
event Deposited(address indexed payee, uint256 weiAmount);
event Withdrawn(address indexed payee, uint256 weiAmount);
uint public amountRaised;
uint public raiseGoal;
uint public quorum;
address public beneficiary;
address public funder;
address[] public trustees;
bool unanimityForRefunds;
mapping(address => BoardMember) public boardMembers;
address[] public boardMembersList;
// constructor ensures that all values combined equal raiseGoal
Milestone[] public milestones;
constructor(
uint _raiseGoal,
address _beneficiary,
address[] _trustees,
uint _quorum,
address[] _boardMembers,
uint[] _milestones,
address _funder,
bool _unanimityForRefunds)
public {
require(_raiseGoal >= 1 ether, "Raise goal is smaller than 1 ether");
require(_milestones.length >= 1, "Milestones must be at least 1");
require(_quorum <= _boardMembers.length, "quorum is larger than total number of boardMembers");
require(_quorum >= 1, "quorum must be at least 1");
// TODO - require minimum milestone voting period
// ensure that cumalative milestone payouts equal raiseGoalAmount
uint milestoneTotal = 0;
for (uint i = 0; i < _milestones.length; i++) {
uint milestoneAmount = _milestones[i];
require(milestoneAmount > 0, "Milestone amount must be greater than 0");
milestoneTotal = milestoneTotal.add(milestoneAmount);
milestones.push(Milestone({
amount: milestoneAmount,
openRequest: false,
paid: false
}));
}
require(milestoneTotal == _raiseGoal, "Milestone total must equal raise goal");
boardMembersList = _boardMembers;
for (uint e = 0; e < boardMembersList.length; e++) {
address boardMemberAddress = boardMembersList[e];
boardMembers[boardMemberAddress] = BoardMember({
milestoneApprovals: new bool[](milestones.length),
refundAddress: 0,
exists: true
});
}
quorum = _quorum;
raiseGoal = _raiseGoal;
beneficiary = _beneficiary;
trustees = _trustees;
funder = _funder;
unanimityForRefunds = _unanimityForRefunds;
amountRaised = 0;
}
function contribute() public payable {
require(msg.sender == funder, "Sender must be funder");
require(amountRaised.add(msg.value) == raiseGoal, "Contribution must be exactly raise goal");
amountRaised = msg.value;
emit Deposited(msg.sender, msg.value);
}
function requestMilestonePayout (uint index) public onlyTrustee onlyRaised {
bool milestoneAlreadyPaid = milestones[index].paid;
// prevent requesting paid milestones
require(!milestoneAlreadyPaid, "Milestone already paid");
int lowestIndexPaid = -1;
for (uint i = 0; i < milestones.length; i++) {
if (milestones[i].paid) {
lowestIndexPaid = int(i);
}
}
require(index == uint(lowestIndexPaid + 1), "Milestone request must be for first unpaid milestone");
// begin grace period for contributors to vote no on milestone payout
require(!milestones[index].openRequest, "Milestone must not have already been requested");
milestones[index].openRequest = true;
}
function voteMilestonePayout(uint index, bool vote) public onlyBoardMember onlyRaised {
bool existingMilestoneVote = boardMembers[msg.sender].milestoneApprovals[index];
require(existingMilestoneVote != vote, "Vote value must be different than existing vote state");
require(milestones[index].openRequest, "Milestone voting must be open");
boardMembers[msg.sender].milestoneApprovals[index] = vote;
}
function payMilestonePayout(uint index) public onlyRaised {
bool quorumReached = isQuorumReachedForMilestonePayout(index);
bool milestoneAlreadyPaid = milestones[index].paid;
if (quorumReached && !milestoneAlreadyPaid) {
milestones[index].paid = true;
milestones[index].openRequest = false;
fundTransfer(beneficiary, milestones[index].amount);
// TODO trigger self-destruct with any un-spent funds (since funds could have been force sent at any point)
} else {
revert("required conditions were not satisfied");
}
}
function voteRefundAddress(address refundAddress) public onlyBoardMember onlyRaised {
boardMembers[msg.sender].refundAddress = refundAddress;
}
function refund(address refundAddress) public onlyBoardMember onlyRaised {
require(isConsensusReachedForRefund(refundAddress), "Unanimity is not reached to refund to given address");
selfdestruct(refundAddress);
}
function fundTransfer(address etherReceiver, uint256 amount) private {
etherReceiver.transfer(amount);
emit Transfered(etherReceiver, amount);
}
function isConsensusReachedForRefund(address refundAddress) public view onlyRaised returns (bool) {
uint yesVotes = 0;
for (uint i = 0; i < boardMembersList.length; i++) {
address boardMemberAddress = boardMembersList[i];
address boardMemberRefundAddressSelection = boardMembers[boardMemberAddress].refundAddress;
if (boardMemberRefundAddressSelection == refundAddress) {
yesVotes += 1;
}
}
if (unanimityForRefunds) {
return yesVotes == boardMembersList.length;
} else {
return yesVotes >= quorum;
}
}
function isQuorumReachedForMilestonePayout(uint milestoneIndex) public view onlyRaised returns (bool) {
uint yesVotes = 0;
for (uint i = 0; i < boardMembersList.length; i++) {
address boardMemberAddress = boardMembersList[i];
bool boardMemberVote = boardMembers[boardMemberAddress].milestoneApprovals[milestoneIndex];
if (boardMemberVote) {
yesVotes += 1;
}
}
return yesVotes >= quorum;
}
function isCallerTrustee() public view returns (bool) {
for (uint i = 0; i < trustees.length; i++) {
if (msg.sender == trustees[i]) {
return true;
}
}
return false;
}
function getBoardMemberMilestoneVote(address boardMemberAddress, uint milestoneIndex) public view returns (bool) {
return boardMembers[boardMemberAddress].milestoneApprovals[milestoneIndex];
}
modifier onlyRaised() {
require(raiseGoal == amountRaised, "Proposal is not funded");
_;
}
modifier onlyBoardMember() {
require(boardMembers[msg.sender].exists, "Caller is not a board member");
_;
}
modifier onlyTrustee() {
require(isCallerTrustee(), "Caller is not a trustee");
_;
}
}

View File

@ -0,0 +1,33 @@
pragma solidity ^0.4.24;
import "./PrivateFund.sol";
contract PrivateFundFactory {
address[] privateFunds;
event ContractCreated(address newAddress);
function createPrivateFund (
uint _raiseGoal,
address _beneficiary,
address[] _trustees,
uint _quorum,
address[] _boardMembers,
uint[] _milestones,
address _funder,
bool _unanimityForRefunds
) public returns(address) {
address newPrivateFundContract = new PrivateFund(
_raiseGoal,
_beneficiary,
_trustees,
_quorum,
_boardMembers,
_milestones,
_funder,
_unanimityForRefunds
);
emit ContractCreated(newPrivateFundContract);
privateFunds.push(newPrivateFundContract);
return newPrivateFundContract;
}
}

13
contract/main.js Normal file
View File

@ -0,0 +1,13 @@
export { default as CrowdFundContract } from './build/contracts/CrowdFund.json';
export { default as CrowdFundFactoryContract } from './build/contracts/CrowdFundFactory.json';
export { default as MigrationsContract } from './build/contracts/Migrations.json';
export { default as SafeMathContract } from './build/contracts/SafeMath.json';
export { default as CrowdFundABI } from './build/abi/CrowdFund.json';
export { default as CrowdFundFactoryABI } from './build/abi/CrowdFundFactory.json';
export { default as MigrationsABI } from './build/abi/Migrations.json';
export { default as SafeMathABI } from './build/abi/SafeMath.json';
export { CrowdFund } from './build/typedefs/CrowdFund.ts';
export { CrowdFundFactory } from './build/typedefs/CrowdFundFactory.ts';
export { Migrations } from './build/typedefs/Migrations.ts';

View File

@ -0,0 +1,5 @@
var Migrations = artifacts.require("./Migrations.sol");
module.exports = function(deployer) {
deployer.deploy(Migrations);
};

View File

@ -0,0 +1,7 @@
const CrowdFundFactory = artifacts.require("./CrowdFundFactory.sol");
const PrivateFundFactory = artifacts.require("./PrivateFundFactory.sol");
module.exports = function(deployer) {
deployer.deploy(CrowdFundFactory);
deployer.deploy(PrivateFundFactory);
};

24
contract/package.json Normal file
View File

@ -0,0 +1,24 @@
{
"name": "grant-contract",
"version": "1.0.0",
"description": "",
"main": "main.js",
"directories": {
"test": "test"
},
"scripts": {
"test": "truffle test",
"build": "truffle compile && node ./bin/build-abi && node ./bin/build-types"
},
"author": "",
"license": "ISC",
"dependencies": {
"openzeppelin-solidity": "^1.12.0",
"truffle-hdwallet-provider": "^0.0.6"
},
"devDependencies": {
"chai": "^4.1.2",
"eth-gas-reporter": "^0.1.10",
"typechain": "0.2.7"
}
}

View File

@ -0,0 +1,113 @@
const CrowdFund = artifacts.require("CrowdFund");
const { increaseTime } = require("./utils");
const HOUR = 3600;
const DAY = HOUR * 24;
const ETHER = 10 ** 18;
const DEADLINE = DAY * 100;
const AFTER_DEADLINE_EXPIRES = DEADLINE + DAY;
contract("CrowdFund Deadline", accounts => {
const [
firstAccount,
firstTrusteeAccount,
thirdAccount,
fourthAccount
] = accounts;
const raiseGoal = ETHER;
const beneficiary = firstTrusteeAccount;
// TODO - set multiple trustees and add tests
const trustees = [firstTrusteeAccount];
// TODO - set multiple milestones and add tests
const milestones = [raiseGoal];
const deadline = DEADLINE;
const milestoneVotingPeriod = HOUR;
const immediateFirstMilestonePayout = false;
let crowdFund;
beforeEach(async () => {
crowdFund = await CrowdFund.new(
raiseGoal,
beneficiary,
trustees,
milestones,
deadline,
milestoneVotingPeriod,
immediateFirstMilestonePayout,
{ from: firstAccount }
);
});
it("returns true when isFailed is called after deadline has passed", async () => {
assert.equal(await crowdFund.isFailed.call(), false);
await increaseTime(AFTER_DEADLINE_EXPIRES);
assert.equal(await crowdFund.isFailed.call(), true);
});
it("allows anyone to refund after time is up and goal is not reached", async () => {
const fundAmount = raiseGoal / 10;
await crowdFund.contribute({ from: fourthAccount, value: fundAmount });
assert.equal(
(await crowdFund.contributors(fourthAccount))[0].toNumber(),
fundAmount
);
assert.equal(await crowdFund.contributorList(0), fourthAccount);
const initBalance = await web3.eth.getBalance(fourthAccount);
await increaseTime(AFTER_DEADLINE_EXPIRES);
await crowdFund.refund();
await crowdFund.withdraw(fourthAccount);
const finalBalance = await web3.eth.getBalance(fourthAccount);
assert.ok(finalBalance.greaterThan(initBalance)); // hard to be exact due to the gas usage
});
it("refunds remaining proportionally when fundraiser has failed", async () => {
const tenthOfRaiseGoal = raiseGoal / 10;
await crowdFund.contribute({
from: fourthAccount,
value: tenthOfRaiseGoal
});
const initBalanceFourthAccount = await web3.eth.getBalance(fourthAccount);
await increaseTime(AFTER_DEADLINE_EXPIRES);
assert.ok(await crowdFund.isFailed());
await crowdFund.refund();
await crowdFund.withdraw(fourthAccount);
const finalBalanceFourthAccount = await web3.eth.getBalance(fourthAccount);
assert.ok(finalBalanceFourthAccount.gt(initBalanceFourthAccount));
});
it("refund remaining proportionally when fundraiser has failed (more complex)", async () => {
const tenthOfRaiseGoal = raiseGoal / 10;
await crowdFund.contribute({
from: fourthAccount,
value: tenthOfRaiseGoal
});
await crowdFund.contribute({
from: thirdAccount,
value: tenthOfRaiseGoal * 4
});
const initBalanceFourthAccount = await web3.eth.getBalance(fourthAccount);
const initBalanceThirdAccount = await web3.eth.getBalance(thirdAccount);
await increaseTime(AFTER_DEADLINE_EXPIRES);
assert.ok(await crowdFund.isFailed());
const afterContributionBalanceFourthAccount = await web3.eth.getBalance(
fourthAccount
);
const afterContributionBalanceThirdAccount = await web3.eth.getBalance(
thirdAccount
);
// fourthAccount contributed a tenth of the raise goal, compared to third account with a fourth
assert.ok(
afterContributionBalanceFourthAccount.gt(
afterContributionBalanceThirdAccount
)
);
await crowdFund.refund();
await crowdFund.withdraw(fourthAccount);
await crowdFund.withdraw(thirdAccount);
const finalBalanceFourthAccount = await web3.eth.getBalance(fourthAccount);
const finalBalanceThirdAccount = await web3.eth.getBalance(thirdAccount);
assert.ok(finalBalanceFourthAccount.gt(initBalanceFourthAccount));
assert.ok(finalBalanceThirdAccount.gt(initBalanceThirdAccount));
});
});

View File

@ -0,0 +1,425 @@
// References https://michalzalecki.com/ethereum-test-driven-introduction-to-solidity/
const CrowdFund = artifacts.require("CrowdFund");
const { increaseTime, assertRevert, assertVMException } = require("./utils");
const HOUR = 3600;
const DAY = HOUR * 24;
const ETHER = 10 ** 18;
const NOW = Math.round(new Date().getTime() / 1000);
const AFTER_VOTING_EXPIRES = HOUR * 2;
contract("CrowdFund", accounts => {
const [
firstAccount,
firstTrusteeAccount,
thirdAccount,
fourthAccount,
fifthAccount
] = accounts;
const raiseGoal = 1 * ETHER;
const beneficiary = firstTrusteeAccount;
// TODO - set multiple trustees and add tests
const trustees = [firstTrusteeAccount];
// TODO - set multiple milestones and add tests
const milestones = [raiseGoal];
const deadline = NOW + DAY * 100;
const milestoneVotingPeriod = HOUR;
const immediateFirstMilestonePayout = false;
let crowdFund;
beforeEach(async () => {
crowdFund = await CrowdFund.new(
raiseGoal,
beneficiary,
trustees,
milestones,
deadline,
milestoneVotingPeriod,
immediateFirstMilestonePayout,
{ from: fifthAccount }
);
});
// [BEGIN] constructor
// TODO - test all initial variables have expected values
it("initializes", async () => {
assert.equal(await crowdFund.raiseGoal.call(), raiseGoal);
assert.equal(await crowdFund.beneficiary.call(), beneficiary);
trustees.forEach(async (address, i) => {
assert.equal(await crowdFund.trustees.call(i), trustees[i]);
});
milestones.forEach(async (milestoneAmount, i) => {
assert.equal(await crowdFund.milestones.call(i)[0], milestoneAmount);
});
});
// [END] constructor
// [BEGIN] contribute
it("reverts on next contribution once raise goal is reached", async () => {
await crowdFund.contribute({
from: firstAccount,
value: raiseGoal
});
assert.ok(await crowdFund.isRaiseGoalReached());
assertRevert(
crowdFund.contribute({
from: firstAccount,
value: raiseGoal
})
);
});
it("keeps track of contributions", async () => {
await crowdFund.contribute({
from: firstAccount,
value: raiseGoal / 10
});
await crowdFund.contribute({
from: firstTrusteeAccount,
value: raiseGoal / 10
});
await crowdFund.contribute({
from: firstTrusteeAccount,
value: raiseGoal / 10
});
assert.equal(
(await crowdFund.contributors(firstAccount))[0].toNumber(),
raiseGoal / 10
);
assert.equal(
(await crowdFund.contributors(firstTrusteeAccount))[0].toNumber(),
raiseGoal / 5
);
});
// TODO BLOCKED - it reverts when contribution is under 1 wei. Blocked by switching contract to use minimum percentage contribution
it("revertd on contribution that exceeds raise goal", async () => {
assertRevert(
crowdFund.contribute({
from: firstAccount,
value: raiseGoal + raiseGoal / 10
})
);
});
// [BEGIN] requestMilestonePayout
it("does not allow milestone requests when caller is not a trustee", async () => {
assertRevert(crowdFund.requestMilestonePayout(0, { from: firstAccount }));
});
it("does not allow milestone requests when milestone has already been paid", async () => {
await crowdFund.contribute({ from: thirdAccount, value: raiseGoal });
const initBalance = await web3.eth.getBalance(firstTrusteeAccount);
await crowdFund.requestMilestonePayout(0, { from: firstTrusteeAccount });
await increaseTime(AFTER_VOTING_EXPIRES);
await crowdFund.payMilestonePayout(0);
const finalBalance = await web3.eth.getBalance(firstTrusteeAccount);
assert.ok(finalBalance.greaterThan(initBalance));
// TODO - enable
// assertRevert(
// crowdFund.requestMilestonePayout(0, { from: firstTrusteeAccount })
// );
});
// [END] requestMilestonePayout
// [BEGIN] voteMilestonePayout
it("only counts milestone vote once", async () => {
const tenthOfRaiseGoal = raiseGoal / 10;
await crowdFund.contribute({ from: thirdAccount, value: tenthOfRaiseGoal });
await crowdFund.contribute({
from: firstAccount,
value: tenthOfRaiseGoal * 9
});
assert.ok(await crowdFund.isRaiseGoalReached());
await crowdFund.requestMilestonePayout(0, { from: firstTrusteeAccount });
// first vote
await crowdFund.voteMilestonePayout(0, true, { from: firstAccount });
assert.equal(
(await crowdFund.milestones(0))[1].toNumber(),
tenthOfRaiseGoal * 9
);
// second vote
assertRevert(
crowdFund.voteMilestonePayout(0, true, { from: firstAccount })
);
assert.equal(
(await crowdFund.milestones(0))[1].toNumber(),
tenthOfRaiseGoal * 9
);
});
it("does not allow milestone voting before vote period has started", async () => {
await crowdFund.contribute({
from: thirdAccount,
value: raiseGoal / 10
});
await crowdFund.contribute({
from: firstAccount,
value: (raiseGoal / 10) * 9
});
assertRevert(
crowdFund.voteMilestonePayout(0, true, { from: thirdAccount })
);
});
it("does not allow milestone voting after vote period has ended", async () => {
await crowdFund.contribute({
from: thirdAccount,
value: raiseGoal / 10
});
await crowdFund.contribute({
from: firstAccount,
value: (raiseGoal / 10) * 9
});
await crowdFund.requestMilestonePayout(0, { from: firstTrusteeAccount });
await crowdFund.voteMilestonePayout(0, true, { from: thirdAccount });
await increaseTime(AFTER_VOTING_EXPIRES);
assertRevert(
crowdFund.voteMilestonePayout(0, true, { from: firstAccount })
);
});
// [END] voteMilestonePayout
// [BEGIN] payMilestonePayout
it("pays milestone when milestone is unpaid, caller is trustee, and no earlier milestone is unpaid", async () => {
await crowdFund.contribute({ from: thirdAccount, value: raiseGoal });
const initBalance = await web3.eth.getBalance(firstTrusteeAccount);
await crowdFund.requestMilestonePayout(0, { from: firstTrusteeAccount });
await increaseTime(AFTER_VOTING_EXPIRES);
await crowdFund.payMilestonePayout(0);
const finalBalance = await web3.eth.getBalance(firstTrusteeAccount);
assert.ok(finalBalance.greaterThan(initBalance));
});
it("does not pay milestone when vote deadline has not passed", async () => {
await crowdFund.contribute({ from: thirdAccount, value: raiseGoal });
await crowdFund.requestMilestonePayout(0, { from: firstTrusteeAccount });
assertRevert(
crowdFund.payMilestonePayout(0, { from: firstTrusteeAccount })
);
});
it("does not pay milestone when raise goal is not met", async () => {
await crowdFund.contribute({
from: thirdAccount,
value: raiseGoal / 10
});
assert.ok((await crowdFund.raiseGoal()).gt(await crowdFund.amountRaised()));
assertRevert(
crowdFund.requestMilestonePayout(0, { from: firstTrusteeAccount })
);
});
it("does not pay milestone when majority is voting no on a milestone", async () => {
await crowdFund.contribute({ from: thirdAccount, value: raiseGoal });
await crowdFund.requestMilestonePayout(0, { from: firstTrusteeAccount });
await crowdFund.voteMilestonePayout(0, true, { from: thirdAccount });
await increaseTime(AFTER_VOTING_EXPIRES);
assertRevert(crowdFund.payMilestonePayout(0));
});
// [END] payMilestonePayout
// [BEGIN] voteRefund
it("keeps track of refund vote amount", async () => {
const tenthOfRaiseGoal = raiseGoal / 10;
await crowdFund.contribute({ from: thirdAccount, value: tenthOfRaiseGoal });
await crowdFund.contribute({
from: firstAccount,
value: tenthOfRaiseGoal * 9
});
assert.ok(await crowdFund.isRaiseGoalReached());
await crowdFund.voteRefund(true, { from: thirdAccount });
await crowdFund.voteRefund(true, { from: firstAccount });
assert.equal(
(await crowdFund.amountVotingForRefund()).toNumber(),
tenthOfRaiseGoal * 9 + tenthOfRaiseGoal
);
await crowdFund.voteRefund(false, { from: firstAccount });
assert.equal(
(await crowdFund.amountVotingForRefund()).toNumber(),
tenthOfRaiseGoal
);
});
it("does not allow non-contributors to vote", async () => {
const tenthOfRaiseGoal = raiseGoal / 10;
await crowdFund.contribute({ from: thirdAccount, value: tenthOfRaiseGoal });
await crowdFund.contribute({
from: firstAccount,
value: tenthOfRaiseGoal * 9
});
assert.ok(await crowdFund.isRaiseGoalReached());
assertRevert(crowdFund.voteRefund(true, { from: firstTrusteeAccount }));
});
it("only allows contributors to vote after raise goal has been reached", async () => {
const tenthOfRaiseGoal = raiseGoal / 10;
await crowdFund.contribute({
from: fourthAccount,
value: tenthOfRaiseGoal
});
assert.ok(!(await crowdFund.isRaiseGoalReached()));
assertRevert(crowdFund.voteRefund(true, { from: fourthAccount }));
await crowdFund.contribute({
from: firstAccount,
value: tenthOfRaiseGoal * 9
});
assert.ok(await crowdFund.isRaiseGoalReached());
assert.ok(await crowdFund.voteRefund(true, { from: fourthAccount }));
});
it("only adds refund voter amount once", async () => {
const tenthOfRaiseGoal = raiseGoal / 10;
await crowdFund.contribute({ from: thirdAccount, value: tenthOfRaiseGoal });
await crowdFund.contribute({
from: firstAccount,
value: tenthOfRaiseGoal * 9
});
assert.ok(await crowdFund.isRaiseGoalReached());
await crowdFund.voteRefund(true, { from: thirdAccount });
assert.equal(
(await crowdFund.amountVotingForRefund()).toNumber(),
tenthOfRaiseGoal
);
await crowdFund.voteRefund(false, { from: thirdAccount });
assert.equal((await crowdFund.amountVotingForRefund()).toNumber(), 0);
await crowdFund.voteRefund(true, { from: thirdAccount });
assert.equal(
(await crowdFund.amountVotingForRefund()).toNumber(),
tenthOfRaiseGoal
);
assertVMException(crowdFund.voteRefund(true, { from: thirdAccount }));
});
// [END] voteRefund
// [BEGIN] refund
it("does not allow non-trustees to refund", async () => {
await crowdFund.contribute({
from: fourthAccount,
value: raiseGoal / 5
});
assert.ok(!(await crowdFund.isRaiseGoalReached()));
assertRevert(crowdFund.refund());
});
it("allows trustee to refund while the CrowdFund is on-going", async () => {
await crowdFund.contribute({
from: fourthAccount,
value: raiseGoal / 5
});
assert.ok(!(await crowdFund.isRaiseGoalReached()));
const balanceAfterFundingFourthAccount = await web3.eth.getBalance(
fourthAccount
);
await crowdFund.refund({ from: firstTrusteeAccount });
await crowdFund.withdraw(fourthAccount);
const balanceAfterRefundFourthAccount = await web3.eth.getBalance(
fourthAccount
);
assert.ok(
balanceAfterRefundFourthAccount.gt(balanceAfterFundingFourthAccount)
);
});
it("allows trustee to refund after the CrowdFund has finished", async () => {
await crowdFund.contribute({
from: fourthAccount,
value: raiseGoal
});
assert.ok(await crowdFund.isRaiseGoalReached());
const balanceAfterFundingFourthAccount = await web3.eth.getBalance(
fourthAccount
);
await crowdFund.refund({ from: firstTrusteeAccount });
await crowdFund.withdraw(fourthAccount);
const balanceAfterRefundFourthAccount = await web3.eth.getBalance(
fourthAccount
);
assert.ok(
balanceAfterRefundFourthAccount.gt(balanceAfterFundingFourthAccount)
);
});
it("reverts if non-trustee attempts to refund on active CrowdFund", async () => {
const tenthOfRaiseGoal = raiseGoal / 10;
await crowdFund.contribute({
from: fourthAccount,
value: tenthOfRaiseGoal
});
assertRevert(crowdFund.refund());
});
it("reverts if non-trustee attempts to refund a successful CrowdFund without a majority voting to refund", async () => {
const tenthOfRaiseGoal = raiseGoal / 10;
await crowdFund.contribute({
from: fourthAccount,
value: tenthOfRaiseGoal * 2
});
await crowdFund.contribute({
from: thirdAccount,
value: tenthOfRaiseGoal * 8
});
assert.ok(await crowdFund.isRaiseGoalReached());
assertRevert(crowdFund.refund());
});
it("refunds proportionally if majority is voting for refund after raise goal has been reached", async () => {
const tenthOfRaiseGoal = raiseGoal / 10;
await crowdFund.contribute({
from: fourthAccount,
value: tenthOfRaiseGoal * 2
});
await crowdFund.contribute({
from: thirdAccount,
value: tenthOfRaiseGoal * 8
});
const initBalanceFourthAccount = await web3.eth.getBalance(fourthAccount);
const initBalanceThirdAccount = await web3.eth.getBalance(thirdAccount);
assert.ok(await crowdFund.isRaiseGoalReached());
const afterContributionBalanceFourthAccount = await web3.eth.getBalance(
fourthAccount
);
const afterContributionBalanceThirdAccount = await web3.eth.getBalance(
thirdAccount
);
// fourthAccount contributed a tenth of the raise goal, compared to third account with a fourth
assert.ok(
afterContributionBalanceFourthAccount.gt(
afterContributionBalanceThirdAccount
)
);
await crowdFund.voteRefund(true, { from: thirdAccount });
await crowdFund.refund();
await crowdFund.withdraw(fourthAccount);
await crowdFund.withdraw(thirdAccount);
const finalBalanceFourthAccount = await web3.eth.getBalance(fourthAccount);
const finalBalanceThirdAccount = await web3.eth.getBalance(thirdAccount);
assert.ok(finalBalanceFourthAccount.gt(initBalanceFourthAccount));
assert.ok(finalBalanceThirdAccount.gt(initBalanceThirdAccount));
});
// [END] refund
// [BEGIN] getContributorMilestoneVote
it("returns milestone vote for a contributor", async () => {
await crowdFund.contribute({ from: thirdAccount, value: raiseGoal });
await crowdFund.requestMilestonePayout(0, { from: firstTrusteeAccount });
await crowdFund.voteMilestonePayout(0, true, { from: thirdAccount });
await increaseTime(AFTER_VOTING_EXPIRES);
const milestoneVote = await crowdFund.getContributorMilestoneVote.call(thirdAccount, 0);
assert.equal(true, milestoneVote)
});
});

View File

@ -0,0 +1,26 @@
const Forward = artifacts.require("Forward");
contract("Forward", accounts => {
const [creatorAccount, destinationAddress] = accounts;
const amount = 1;
let forward;
beforeEach(async () => {
forward = await Forward.new(destinationAddress, { from: creatorAccount });
});
it("deposits", async () => {
await forward.sendTransaction({ from: creatorAccount, value: amount });
const forwardBalance = await web3.eth.getBalance(forward.address);
assert.equal(forwardBalance.toNumber(), amount);
});
it("forwards", async () => {
const initBalance = await web3.eth.getBalance(destinationAddress);
await forward.sendTransaction({ from: creatorAccount, value: amount });
await forward.payOut({ from: creatorAccount });
const finalBalance = await web3.eth.getBalance(destinationAddress);
assert.ok(finalBalance.gt(initBalance));
});
});

View File

@ -0,0 +1,235 @@
// test/CrowdFundTest.js
// References https://michalzalecki.com/ethereum-test-driven-introduction-to-solidity/
const PrivateFund = artifacts.require("PrivateFund");
const { assertRevert } = require("./utils");
const ETHER = 10 ** 18;
contract("PrivateFund", accounts => {
const [
funderAccount,
firstTrusteeAccount,
refundAccount,
boardOne,
boardTwo,
boardThree
] = accounts;
const raiseGoal = 1 * ETHER;
const halfRaiseGoal = raiseGoal / 2;
const beneficiary = firstTrusteeAccount;
// TODO - set multiple trustees and add tests
const trustees = [beneficiary];
const quorum = 2;
const boardMembers = [boardOne, boardTwo, boardThree];
const milestones = [halfRaiseGoal, halfRaiseGoal];
const funder = funderAccount;
const useQuroumForRefund = false;
let privateFund;
beforeEach(async () => {
privateFund = await PrivateFund.new(
raiseGoal,
beneficiary,
trustees,
quorum,
boardMembers,
milestones,
funder,
useQuroumForRefund,
{ from: funderAccount }
);
});
// [BEGIN] constructor
// TODO - test all initial variables have expected values
it("initializes", async () => {
assert.equal(await privateFund.raiseGoal.call(), raiseGoal);
assert.equal(await privateFund.beneficiary.call(), beneficiary);
trustees.forEach(async (address, i) => {
assert.equal(await privateFund.trustees.call(i), trustees[i]);
});
// TODO - get working
// milestones.forEach(async (milestoneAmount, i) => {
// console.log(i)
// assert.equal(await privateFund.milestones(i)[0], milestoneAmount);
// });
});
// [END] constructor
// [BEGIN] contribute
it("revert on next contribution once raise goal is reached", async () => {
await privateFund.contribute({
from: funderAccount,
value: raiseGoal
});
assertRevert(
privateFund.contribute({
from: funderAccount,
value: raiseGoal
})
);
});
it("revert when raiseGoal isn't paid in full", async () => {
assertRevert(
privateFund.contribute({
from: funderAccount,
value: raiseGoal / 5
})
);
});
it("amountRaised is set after contribution", async () => {
await privateFund.contribute({
from: funderAccount,
value: raiseGoal
});
assert.equal(
(await privateFund.amountRaised()).toNumber(),
(await privateFund.raiseGoal()).toNumber()
);
});
// [BEGIN] requestMilestonePayout
it("does not request milestone when earlier milestone is unpaid", async () => {
await privateFund.contribute({ from: funderAccount, value: raiseGoal });
assertRevert(
privateFund.requestMilestonePayout(1, { from: firstTrusteeAccount })
);
});
it("does not allow milestone request when caller is not trustee", async () => {
assertRevert(
privateFund.requestMilestonePayout(0, { from: funderAccount })
);
});
it("does not allow milestone request when milestone has already been paid", async () => {
await privateFund.contribute({ from: funderAccount, value: raiseGoal });
const initBalance = await web3.eth.getBalance(beneficiary);
await privateFund.requestMilestonePayout(0, { from: beneficiary });
await privateFund.voteMilestonePayout(0, true, { from: boardOne });
await privateFund.voteMilestonePayout(0, true, { from: boardTwo });
await privateFund.payMilestonePayout(0);
const finalBalance = await web3.eth.getBalance(beneficiary);
assert.ok(finalBalance.greaterThan(initBalance));
assertRevert(privateFund.requestMilestonePayout(0, { from: beneficiary }));
});
// [END] requestMilestonePayout
// [BEGIN] voteMilestonePayout
it("persists board member votes", async () => {
await privateFund.contribute({
from: funderAccount,
value: raiseGoal
});
await privateFund.requestMilestonePayout(0, { from: firstTrusteeAccount });
await privateFund.voteMilestonePayout(0, true, { from: boardOne });
await privateFund.voteMilestonePayout(0, true, { from: boardTwo });
assert.equal((await privateFund.getBoardMemberMilestoneVote(boardOne, 0)), true )
});
it("only allows board members to vote", async () => {
await privateFund.contribute({
from: funderAccount,
value: raiseGoal
});
await privateFund.requestMilestonePayout(0, { from: firstTrusteeAccount });
assertRevert(
privateFund.voteMilestonePayout(0, true, { from: funderAccount }) // even funders can't vote unless they are also part of the board
);
});
it("does not allow milestone voting before vote period has started", async () => {
await privateFund.contribute({
from: funderAccount,
value: raiseGoal
});
assertRevert(
privateFund.voteMilestonePayout(0, true, { from: boardThree })
);
});
// [END] voteMilestonePayout
// [BEGIN] payMilestonePayout
it("pays milestone when milestone is unpaid, quorum is reached, caller is trustee, and no earlier milestone is unpaid", async () => {
await privateFund.contribute({ from: funderAccount, value: raiseGoal });
const initBalance = await web3.eth.getBalance(beneficiary);
await privateFund.requestMilestonePayout(0, { from: firstTrusteeAccount });
// quorum of two needed
await privateFund.voteMilestonePayout(0, true, { from: boardOne });
await privateFund.voteMilestonePayout(0, true, { from: boardTwo });
await privateFund.payMilestonePayout(0);
const finalBalance = await web3.eth.getBalance(firstTrusteeAccount);
assert.ok(finalBalance.greaterThan(initBalance));
});
it("does not pay milestone when raise goal is not met", async () => {
assert.ok(
(await privateFund.raiseGoal()).gt(await privateFund.amountRaised())
);
assertRevert(
privateFund.requestMilestonePayout(0, { from: firstTrusteeAccount })
);
});
it("does not pay milestone when quorum is not reached", async () => {
await privateFund.contribute({ from: funderAccount, value: raiseGoal });
await privateFund.requestMilestonePayout(0, { from: firstTrusteeAccount });
// only one vote in favor
await privateFund.voteMilestonePayout(0, true, { from: boardOne });
assertRevert(privateFund.payMilestonePayout(0));
});
// [END] payMilestonePayout
// [BEGIN] voteRefundAddress
it("keeps track of refund vote address choices", async () => {
await privateFund.contribute({ from: funderAccount, value: raiseGoal });
await privateFund.voteRefundAddress(refundAccount, { from: boardOne });
await privateFund.voteRefundAddress(refundAccount, { from: boardTwo });
assert.equal((await privateFund.boardMembers(boardOne))[0], refundAccount);
assert.equal((await privateFund.boardMembers(boardTwo))[0], refundAccount);
});
it("does not allow non-contributors to vote", async () => {
await privateFund.contribute({
from: funderAccount,
value: raiseGoal
});
assertRevert(privateFund.voteRefundAddress(true, { from: funderAccount }));
});
// [BEGIN] refund
// TODO - fix up
// it("refunds to voted refund address", async () => {
// const refundBalanceInit = await web3.eth.getBalance(refundAccount);
//
// await privateFund.contribute({ from: funderAccount, value: raiseGoal });
// await privateFund.voteRefundAddress(refundAccount, { from: boardOne });
// await privateFund.voteRefundAddress(refundAccount, { from: boardTwo });
// await privateFund.voteRefundAddress(refundAccount, { from: boardThree });
// await privateFund.refund(refundAccount, { from: boardTwo });
//
// const refundBalancePostRefund = await web3.eth.getBalance(refundAccount);
//
// assert.ok(refundBalancePostRefund.gt(refundBalanceInit));
// });
//
// [END] refund
});

65
contract/test/utils.js Normal file
View File

@ -0,0 +1,65 @@
// source: https://github.com/OpenZeppelin/zeppelin-solidity/blob/master/test/helpers/increaseTime.js
const should = require("chai").should();
async function assertRevert(promise) {
try {
await promise;
} catch (error) {
error.message.should.include(
"revert",
`Expected "revert", got ${error} instead`
);
return;
}
should.fail("Expected revert not received");
}
async function assertVMException(promise) {
try {
await promise;
} catch (error) {
error.message.should.include(
"VM Exception",
`Expected "VM Exception", got ${error} instead`
);
return;
}
should.fail("Expected VM Exception not received");
}
async function increaseTime(duration) {
const id = Date.now();
return new Promise((resolve, reject) => {
web3.currentProvider.sendAsync(
{
jsonrpc: "2.0",
method: "evm_increaseTime",
params: [duration],
id: id
},
err1 => {
if (err1) return reject(err1);
web3.currentProvider.sendAsync(
{
jsonrpc: "2.0",
method: "evm_mine",
id: id + 1
},
(err2, res) => {
return err2 ? reject(err2) : resolve(res);
}
);
}
);
});
}
module.exports = {
assertRevert,
increaseTime,
assertVMException
};

107
contract/truffle-config.js Normal file
View File

@ -0,0 +1,107 @@
/**
* Use this file to configure your truffle project. It's seeded with some
* common settings for different networks and features like migrations,
* compilation and testing. Uncomment the ones you need or modify
* them to suit your project as necessary.
*
* More information about configuration can be found at:
*
* truffleframework.com/docs/advanced/configuration
*
* To deploy via Infura you'll need a wallet provider (like truffle-hdwallet-provider)
* to sign your transactions before they're sent to a remote public node. Infura API
* keys are available for free at: infura.io/register
*
* > > Using Truffle V5 or later? Make sure you install the `web3-one` version.
*
* > > $ npm install truffle-hdwallet-provider@web3-one
*
* You'll also need a mnemonic - the twelve word phrase the wallet uses to generate
* public/private key pairs. If you're publishing your code to GitHub make sure you load this
* phrase from a file you've .gitignored so it doesn't accidentally become public.
*
*/
const HDWallet = require('truffle-hdwallet-provider');
const infuraKey = process.env.INFURA_KEY;
//
// const fs = require('fs');
const mnemonic = process.env.MNEMONIC;
module.exports = {
/**
* Networks define how you connect to your ethereum client and let you set the
* defaults web3 uses to send transactions. If you don't specify one truffle
* will spin up a development blockchain for you on port 9545 when you
* run `develop` or `test`. You can ask a truffle command to use a specific
* network from the command line, e.g
*
* $ truffle test --network <network-name>
*/
networks: {
// Useful for testing. The `development` name is special - truffle uses it by default
// if it's defined here and no other network is specified at the command line.
// You should run a client (like ganache-cli, geth or parity) in a separate terminal
// tab if you use this network and you must also set the `host`, `port` and `network_id`
// options below to some value.
//
development: {
host: "127.0.0.1", // Localhost (default: none)
port: 8545, // Standard Ethereum port (default: none)
network_id: "*" // Any network (default: none)
},
// Another network with more advanced options...
advanced: {
// port: 8777, // Custom port
// network_id: 1342, // Custom network
// gas: 8500000, // Gas sent with each transaction (default: ~6700000)
// gasPrice: 20000000000, // 20 gwei (in wei) (default: 100 gwei)
// from: <address>, // Account to send txs from (default: accounts[0])
// websockets: true // Enable EventEmitter interface for web3 (default: false)
},
// Useful for deploying to a public network.
// NB: It's important to wrap the provider as a function.
ropsten: {
provider: function () {return new HDWallet(mnemonic, 'https://ropsten.infura.io/' + infuraKey)},
network_id: 3, // Ropsten's id
gas: 5500000, // Ropsten has a lower block limit than mainnet
confirmations: 2, // # of confs to wait between deployments. (default: 0)
timeoutBlocks: 200, // # of blocks before a deployment times out (minimum/default: 50)
skipDryRun: true // Skip dry run before migrations? (default: false for public nets )
},
// Useful for private networks
private: {
// provider: () => new HDWalletProvider(mnemonic, `https://network.io`),
// network_id: 2111, // This network is yours, in the cloud.
// production: true // Treats this network as if it was a public net. (default: false)
}
},
// Set default mocha options here, use special reporters etc.
mocha: {
reporter: "eth-gas-reporter",
reporterOptions: {
currency: "USD",
gasPrice: 21
}
},
// Configure your compilers
compilers: {
solc: {
// version: "0.5.1", // Fetch exact version from solc-bin (default: truffle's version)
// docker: true, // Use "0.5.1" you've installed locally with docker (default: false)
// settings: { // See the solidity docs for advice about optimization and evmVersion
// optimizer: {
// enabled: false,
// runs: 200
// },
// evmVersion: "byzantium"
// }
}
}
};

2888
contract/yarn.lock Normal file

File diff suppressed because it is too large Load Diff

29
frontend/.babelrc Normal file
View File

@ -0,0 +1,29 @@
{
"presets": ["next/babel", "@zeit/next-typescript/babel"],
"env": {
"development": {
"plugins": ["inline-dotenv"]
},
"production": {
"plugins": ["transform-inline-environment-variables"]
}
},
"plugins": [
["import", { "libraryName": "antd", "style": false }],
[
"module-resolver",
{
"root": ["client"],
"extensions": [".js", ".tsx", ".ts"]
}
],
[
"styled-components",
{
"ssr": true,
"displayName": true,
"preprocess": false
}
]
]
}

2
frontend/.envexample Normal file
View File

@ -0,0 +1,2 @@
# Funds these addresses when `npm run truffle` runs. Comma separated.
FUND_ETH_ADDRESSES=0x4bbeEB066eD09B7AEd07bF39EEe0460DFa261520,0xDECAF9CD2367cdbb726E904cD6397eDFcAe6068DEe0460DFa261520,0xDECAF9CD2367cdbb726E904cD6397eDFcAe6068D

11
frontend/.gitignore vendored Normal file
View File

@ -0,0 +1,11 @@
.next
node_modules
.idea
build
out
src/build
dist
*.log
.env
*.pid
client/lib/contracts

1
frontend/.npmrc Normal file
View File

@ -0,0 +1 @@
package-lock=false

1
frontend/.nvmrc Normal file
View File

@ -0,0 +1 @@
8.11.4

10
frontend/.prettierrc Normal file
View File

@ -0,0 +1,10 @@
{
"printWidth": 90,
"singleQuote": true,
"useTabs": false,
"semi": true,
"tabWidth": 2,
"trailingComma": "all",
"bracketSpacing": true,
"jsxBracketSameLine": false
}

53
frontend/README.md Normal file
View File

@ -0,0 +1,53 @@
# Grant.io Front-End
This is the front-end component of [Grant.io](http://grant.io).
## Development
1. Install local project dependencies, and also install Truffle & Ganache globally:
```bash
# Local dependencies
yarn
# Global dependencies
yarn global add truffle ganache-cli
```
2. (In a separate terminal) Run the ganache development blockchain:
```bash
yarn run ganache
```
3. Ensure you have grant-contract cloned locally and setup.
4. (In a separate terminal) Initialize truffle, open up the repl (Changes to smart contracts will require you to re-run this):
```bash
yarn run truffle
```
5. Run the next.js server / webpack build for the front-end:
```bash
yarn run dev
```
5. Go to the dapp on localhost:3000. You'll need to setup metamask to connect to the ganache network. You'll want to add a custom "RPC" network, and point it towards localhost:8545.
## Testing
### Application
TBD
### Smart Contract
Truffle can run tests written in Solidity or JavaScript against your smart contracts. Note the command varies slightly if you're in or outside of the development console.
```bash
# If inside the truffle console
test
# If outside the truffle console
truffle test
```

View File

@ -0,0 +1,53 @@
// Initialize the truffle environment however we want, web3 is available
const rimraf = require('rimraf');
const path = require('path');
const fs = require('fs');
const childProcess = require('child_process');
require('dotenv').config({path: path.resolve(__dirname, '../.env')});
const contractsDir = path.resolve(__dirname, '../client/lib/contracts');
const CONTRACTS_REPO_BASE_PATH = path.resolve(
__dirname,
'../../contract',
);
const externalBuildContractsDir = path.join(
CONTRACTS_REPO_BASE_PATH,
'/build/contracts',
);
module.exports = function (done) {
// Remove the old contracts
rimraf.sync(contractsDir);
// Fund ETH accounts
const ethAccounts = process.env.FUND_ETH_ADDRESSES
? process.env.FUND_ETH_ADDRESSES.split(',').map(a => a.trim())
: [];
if (ethAccounts.length) {
console.info('Sending 50 ETH to the following addresses...');
ethAccounts.forEach((addr, i) => {
web3.eth.sendTransaction({
to: addr,
from: web3.eth.accounts[i],
value: web3.toWei('50', 'ether'),
});
console.info(` ${addr} <- from ${web3.eth.accounts[i]}`);
});
} else {
console.info('No accounts specified for funding in .env file...');
}
console.info('Changing working directory to: ' + process.cwd());
console.info('Compiling smart contracts...');
childProcess.execSync('yarn build', {cwd: CONTRACTS_REPO_BASE_PATH});
console.info('Running migrations...');
childProcess.execSync('truffle migrate', {cwd: CONTRACTS_REPO_BASE_PATH});
console.info('Linking contracts to client/lib/contracts...');
fs.symlinkSync(externalBuildContractsDir, contractsDir);
console.info('Truffle initialized, starting repl console!');
done();
};

View File

@ -0,0 +1,28 @@
import axios from './axios';
import { Proposal } from 'modules/proposals/reducers';
export function getProposals(): Promise<{ data: Proposal[] }> {
return axios.get('/api/proposals/');
}
export function getProposal(proposalId: number | string): Promise<{ data: Proposal }> {
return axios.get(`/api/proposals/${proposalId}`);
}
export function getProposalComments(proposalId: number | string) {
return axios.get(`/api/proposals/${proposalId}/comments`);
}
export function getProposalUpdates(proposalId: number | string) {
return axios.get(`/api/proposals/${proposalId}/updates`);
}
export function postProposal(payload: {
accountAddress;
crowdFundContractAddress;
content;
title;
milestones;
}) {
return axios.post(`/api/proposals/create`, payload);
}

View File

@ -0,0 +1,8 @@
import axios from 'axios';
const instance = axios.create({
baseURL: process.env.BACKEND_URL,
headers: {},
});
export default instance;

View File

@ -0,0 +1,87 @@
export enum PROPOSAL_SORT {
NEWEST = 'NEWEST',
OLDEST = 'OLDEST',
MOST_FUNDED = 'MOST_FUNDED',
LEAST_FUNDED = 'LEAST_FUNDED',
}
export const SORT_LABELS: { [key in PROPOSAL_SORT]: string } = {
NEWEST: 'Newest',
OLDEST: 'Oldest',
MOST_FUNDED: 'Most funded',
LEAST_FUNDED: 'Least funded',
};
export enum PROPOSAL_CATEGORY {
DAPP = 'DAPP',
DEV_TOOL = 'DEV_TOOL',
CORE_DEV = 'CORE_DEV',
COMMUNITY = 'COMMUNITY',
DOCUMENTATION = 'DOCUMENTATION',
ACCESSIBILITY = 'ACCESSIBILITY',
}
interface CategoryUI {
label: string;
color: string;
icon: string;
}
export const CATEGORY_UI: { [key in PROPOSAL_CATEGORY]: CategoryUI } = {
DAPP: {
label: 'DApp',
color: '#8e44ad',
icon: 'appstore',
},
DEV_TOOL: {
label: 'Developer tool',
color: '#2c3e50',
icon: 'tool',
},
CORE_DEV: {
label: 'Core dev',
color: '#d35400',
icon: 'rocket',
},
COMMUNITY: {
label: 'Community',
color: '#27ae60',
icon: 'team',
},
DOCUMENTATION: {
label: 'Documentation',
color: '#95a5a6',
icon: 'paper-clip',
},
ACCESSIBILITY: {
label: 'Accessibility',
color: '#2980b9',
icon: 'eye-o',
},
};
export enum PROPOSAL_STAGE {
FUNDING_REQUIRED = 'FUNDING_REQUIRED',
WIP = 'WIP',
COMPLETED = 'COMPLETED',
}
interface StageUI {
label: string;
color: string;
}
export const STAGE_UI: { [key in PROPOSAL_STAGE]: StageUI } = {
FUNDING_REQUIRED: {
label: 'Funding required',
color: '#8e44ad',
},
WIP: {
label: 'In progress',
color: '#2980b9',
},
COMPLETED: {
label: 'Completed',
color: '#27ae60',
},
};

View File

@ -0,0 +1,66 @@
import React from 'react';
import { Layout, Breadcrumb } from 'antd';
import BasicHead from './BasicHead';
import Header from './Header';
import Footer from './Footer';
interface Props {
title: string;
isHeaderTransparent?: boolean;
isFullScreen?: boolean;
hideFooter?: boolean;
withBreadcrumb?: boolean | null;
centerContent?: boolean;
}
const { Content } = Layout;
class AntWrap extends React.Component<Props> {
render() {
const {
children,
withBreadcrumb,
title,
isHeaderTransparent,
isFullScreen,
hideFooter,
centerContent,
} = this.props;
return (
<BasicHead title={title}>
<div style={{ display: 'flex', flexDirection: 'column', minHeight: '100vh' }}>
<Header isTransparent={isHeaderTransparent} />
<Content
style={{
display: 'flex',
justifyContent: 'center',
flex: 1,
padding: isFullScreen ? '0' : '0 2.5rem',
}}
>
{withBreadcrumb && (
<Breadcrumb style={{ margin: '16px 0' }}>
<Breadcrumb.Item>Home</Breadcrumb.Item>
<Breadcrumb.Item>List</Breadcrumb.Item>
<Breadcrumb.Item>App</Breadcrumb.Item>
</Breadcrumb>
)}
<div
style={{
width: '100%',
paddingTop: isFullScreen ? 0 : 50,
paddingBottom: isFullScreen ? 0 : 50,
minHeight: 280,
alignSelf: centerContent ? 'center' : 'initial',
}}
>
{children}
</div>
</Content>
{!hideFooter && <Footer />}
</div>
</BasicHead>
);
}
}
export default AntWrap;

View File

@ -0,0 +1,33 @@
import React from 'react';
import Head from 'next/head';
import 'styles/style.less';
interface Props {
title: string;
}
export default class BasicHead extends React.Component<Props> {
render() {
const { children, title } = this.props;
return (
<div>
<Head>
<title>Grant.io - {title}</title>
{/*TODO - bundle*/}
<link
rel="stylesheet"
href="https://use.fontawesome.com/releases/v5.2.0/css/all.css"
integrity="sha384-hWVjflwFxL6sNzntih27bfxkr27PmbbK/iSvJ+a4+0owXq79v+lsFkW54bOGbiDQ"
crossOrigin="anonymous"
/>
<link rel="stylesheet" href="/_next/static/style.css" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
</Head>
{children}
</div>
);
}
}

View File

@ -0,0 +1,43 @@
import React from 'react';
import moment from 'moment';
import Markdown from 'react-markdown';
import { Comment as IComment } from 'modules/proposals/reducers';
import * as Styled from './styled';
interface Props {
comment: IComment;
}
export default class Comment extends React.Component<Props> {
public render(): React.ReactNode {
const { comment } = this.props;
return (
<Styled.Container>
<Styled.Info>
<Styled.InfoThumb src={comment.author.avatar['120x120']} />
<Styled.InfoName>{comment.author.username}</Styled.InfoName>
<Styled.InfoTime>
{moment(comment.dateCreated * 1000).fromNow()}
</Styled.InfoTime>
</Styled.Info>
<Styled.Body>
<Markdown source={comment.body} />
</Styled.Body>
<Styled.Controls>
<Styled.ControlButton>Reply</Styled.ControlButton>
{/*<Styled.ControlButton>Report</Styled.ControlButton>*/}
</Styled.Controls>
{comment.replies && (
<Styled.Replies>
{comment.replies.map(reply => (
<Comment key={reply.commentId} comment={reply} />
))}
</Styled.Replies>
)}
</Styled.Container>
);
}
}

View File

@ -0,0 +1,65 @@
import styled from 'styled-components';
const infoHeight = '1.8rem';
export const Container = styled.div`
position: relative;
margin-bottom: 2rem;
&:last-child {
margin-bottom: 0;
}
`;
export const Info = styled.div`
display: flex;
line-height: ${infoHeight};
margin-bottom: 1rem;
`;
export const InfoThumb = styled.img`
display: block;
margin-right: 0.5rem;
width: ${infoHeight};
height: ${infoHeight};
border-radius: 4px;
`;
export const InfoName = styled.div`
font-size: 1.1rem;
margin-right: 0.5rem;
`;
export const InfoTime = styled.div`
font-size: 0.8rem;
opacity: 0.5;
`;
export const Body = styled.div`
font-size: 1rem;
`;
export const Controls = styled.div`
display: flex;
margin-left: -0.5rem;
`;
export const ControlButton = styled.a`
font-size: 0.65rem;
opacity: 0.5;
padding: 0 0.5rem;
background: none;
cursor: pointer;
color: #4c4c4c;
&:hover {
opacity: 0.7;
color: inherit;
}
`;
export const Replies = styled.div`
margin: 1rem;
padding: 1rem 0rem 1rem 2rem;
border-left: 1px solid rgba(0, 0, 0, 0.12);
`;

View File

@ -0,0 +1,17 @@
import React from 'react';
import { ProposalComments } from 'modules/proposals/reducers';
import Comment from 'components/Comment';
interface Props {
comments: ProposalComments['comments'];
}
const Comments = ({ comments }: Props) => (
<React.Fragment>
{comments.map(c => (
<Comment key={c.commentId} comment={c} />
))}
</React.Fragment>
);
export default Comments;

View File

@ -0,0 +1,28 @@
import React from 'react';
import Link from 'next/link';
import { Icon } from 'antd';
import * as Styled from './styled';
interface Props {
crowdFundCreatedAddress: string;
}
const CreateSuccess = ({ crowdFundCreatedAddress }: Props) => (
<Styled.Success>
<Styled.SuccessIcon>
<Icon type="check-circle-o" />
</Styled.SuccessIcon>
<Styled.SuccessText>
<h2>Contract was succesfully deployed!</h2>
<div>
Your proposal is now live and on the blockchain!{' '}
<Link href={`/proposals/${crowdFundCreatedAddress}`}>
<a>Click here</a>
</Link>{' '}
to check it out.
</div>
</Styled.SuccessText>
</Styled.Success>
);
export default CreateSuccess;

View File

@ -0,0 +1,102 @@
import { Input, DatePicker, Card, Icon, Alert, Checkbox } from 'antd';
import moment from 'moment';
export interface Milestone {
title: string;
description: string;
date: string;
payoutPercent: number;
immediatePayout: boolean;
}
interface Props {
index: number;
milestone: Milestone;
error: null | false | string;
onChange(index: number, milestone: Milestone): void;
onRemove(index: number): void;
}
const MilestoneFields = ({ index, milestone, error, onChange, onRemove }: Props) => (
<Card style={{ marginBottom: '2rem' }}>
<div style={{ display: 'flex', marginBottom: '0.5rem', alignItems: 'center' }}>
<Input
size="large"
placeholder="Title"
type="text"
name="title"
value={milestone.title}
onChange={ev => onChange(index, { ...milestone, title: ev.currentTarget.value })}
/>
<button
onClick={() => onRemove(index)}
style={{
paddingLeft: '0.5rem',
fontSize: '1.5rem',
cursor: 'pointer',
opacity: 0.8,
}}
>
<Icon type="close-circle-o" />
</button>
</div>
<div style={{ marginBottom: '0.5rem' }}>
<Input.TextArea
rows={3}
name="body"
placeholder="Description of the deliverable"
value={milestone.description}
onChange={ev =>
onChange(index, { ...milestone, description: ev.currentTarget.value })
}
/>
</div>
<div style={{ display: 'flex' }}>
<DatePicker.MonthPicker
style={{ flex: 1, marginRight: '0.5rem' }}
placeholder="Expected completion date"
value={milestone.date ? moment(milestone.date) : undefined}
format="MMMM YYYY"
allowClear={false}
onChange={(_, date) => onChange(index, { ...milestone, date })}
/>
<Input
min={1}
max={100}
type="number"
value={milestone.payoutPercent}
onChange={ev =>
onChange(index, {
...milestone,
payoutPercent: parseInt(ev.currentTarget.value, 10) || 0,
})
}
addonAfter="%"
style={{ maxWidth: '120px', width: '100%' }}
/>
{index === 0 && (
<div style={{ display: 'flex', alignItems: 'center', marginLeft: '0.5rem' }}>
<Checkbox
checked={milestone.immediatePayout}
onChange={ev =>
onChange(index, {
...milestone,
immediatePayout: ev.target.checked,
})
}
>
<span style={{ opacity: 0.7 }}>Payout Immediately</span>
</Checkbox>
</div>
)}
</div>
{error && (
<Alert style={{ marginTop: '1rem' }} type="error" message={error} showIcon />
)}
</Card>
);
export default MilestoneFields;

View File

@ -0,0 +1,35 @@
import { Input, Form, Icon } from 'antd';
interface Props {
index: number;
value: string;
error: null | false | string;
onChange(index: number, value: string): void;
onRemove(index: number): void;
}
const TrusteeFields = ({ index, value, error, onChange, onRemove }: Props) => (
<Form.Item validateStatus={error ? 'error' : undefined} help={error}>
<div style={{ display: 'flex' }}>
<Input
size="large"
placeholder="0xe12a34230e5e7fc73d094e52025135e4fbf24653"
type="text"
value={value}
onChange={ev => onChange(index, ev.currentTarget.value)}
/>
<button
onClick={() => onRemove(index)}
style={{
paddingLeft: '0.5rem',
fontSize: '1.3rem',
cursor: 'pointer',
}}
>
<Icon type="close-circle-o" />
</button>
</div>
</Form.Item>
);
export default TrusteeFields;

View File

@ -0,0 +1,528 @@
// TODO: Make each section its own page. Reduce size of this component!
import React from 'react';
import Web3Container from 'lib/Web3Container';
import { bindActionCreators, Dispatch } from 'redux';
import { connect } from 'react-redux';
import { compose } from 'recompose';
import { AppState } from 'store/reducers';
import { web3Actions } from 'modules/web3';
import { Button, Input, Form, Alert, Spin, Divider, Icon, Radio, Select } from 'antd';
import { PROPOSAL_CATEGORY, CATEGORY_UI } from 'api/constants';
import { RadioChangeEvent } from 'antd/lib/radio';
import TrusteeFields from './TrusteeFields';
import MilestoneFields, { Milestone } from './MilestoneFields';
import CreateSuccess from './CreateSuccess';
import { computePercentage } from 'utils/helpers';
import { getAmountError } from 'utils/validators';
import MarkdownEditor from 'components/MarkdownEditor';
import * as Styled from './styled';
interface Errors {
title?: string;
amountToRaise?: string;
payOutAddress?: string;
trustees?: string[];
milestones?: string[];
}
interface State {
title: string;
proposalBody: string;
category: PROPOSAL_CATEGORY | undefined;
amountToRaise: string;
payOutAddress: string;
trustees: string[];
milestones: Milestone[];
deadline: number | null;
milestoneDeadline: number | null;
}
const DEFAULT_STATE: State = {
title: '',
proposalBody: '',
category: undefined,
amountToRaise: '',
payOutAddress: '',
trustees: [],
milestones: [
{
title: '',
description: '',
date: '',
payoutPercent: 100,
immediatePayout: false,
},
],
deadline: 60 * 60 * 24 * 60,
milestoneDeadline: 60 * 60 * 24 * 7,
};
function milestoneToMilestoneAmount(milestone: Milestone, raiseGoal: number) {
return computePercentage(raiseGoal, milestone.payoutPercent);
}
class CreateProposal extends React.Component<any, State> {
constructor(props: any) {
super(props);
this.state = { ...DEFAULT_STATE };
}
componentWillUpdate() {
if (this.props.crowdFundLoading) {
this.setState({ ...DEFAULT_STATE });
}
}
handleInputChange = (
event: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>,
) => {
const { value, name } = event.currentTarget;
this.setState({ [name]: value } as any);
};
handleProposalBodyChange = (markdown: string) => {
this.setState({ proposalBody: markdown });
};
handleCategoryChange = (value: PROPOSAL_CATEGORY) => {
this.setState({ category: value });
};
handleRadioChange = (event: RadioChangeEvent) => {
const { value, name } = event.target;
this.setState({ [name]: value } as any);
};
handleTrusteeChange = (index: number, value: string) => {
const trustees = [...this.state.trustees];
trustees[index] = value;
this.setState({ trustees });
};
addTrustee = () => {
const trustees = [...this.state.trustees, ''];
this.setState({ trustees });
};
removeTrustee = (index: number) => {
const trustees = this.state.trustees.filter((_, i) => i !== index);
this.setState({ trustees });
};
handleMilestoneChange = (index: number, milestone: Milestone) => {
const milestones = [...this.state.milestones];
milestones[index] = milestone;
this.setState({ milestones });
};
addMilestone = () => {
const { milestones: oldMilestones } = this.state;
const lastMilestone = oldMilestones[oldMilestones.length - 1];
const halfPayout = lastMilestone.payoutPercent / 2;
const milestones = [
...oldMilestones,
{
...DEFAULT_STATE.milestones[0],
payoutPercent: halfPayout,
},
];
milestones[milestones.length - 2] = {
...lastMilestone,
payoutPercent: halfPayout,
};
this.setState({ milestones });
};
removeMilestone = (index: number) => {
let milestones = this.state.milestones.filter((_, i) => i !== index);
if (milestones.length === 0) {
milestones = [...DEFAULT_STATE.milestones];
}
this.setState({ milestones });
};
createCrowdFund = async () => {
const { contract, createCrowdFund, web3 } = this.props;
const {
title,
proposalBody,
amountToRaise,
payOutAddress,
trustees,
deadline,
milestoneDeadline,
milestones,
category,
} = this.state;
const backendData = { content: proposalBody, title, category };
const targetInWei = web3.utils.toWei(String(amountToRaise), 'ether');
const milestoneAmounts = milestones.map(milestone =>
milestoneToMilestoneAmount(milestone, targetInWei),
);
const immediateFirstMilestonePayout = milestones[0].immediatePayout;
const contractData = {
ethAmount: targetInWei,
payOutAddress,
trusteesAddresses: trustees,
milestoneAmounts,
milestones,
durationInMinutes: deadline,
milestoneVotingPeriodInMinutes: milestoneDeadline,
immediateFirstMilestonePayout,
category,
};
createCrowdFund(contract, contractData, backendData);
};
// TODO: Replace me with ant form validation?
getFormErrors = () => {
const { web3 } = this.props;
const { title, amountToRaise, payOutAddress, trustees, milestones } = this.state;
const errors: Errors = {};
// Title
if (title.length > 60) {
errors.title = 'Title can be 60 characters maximum';
}
// Amount to raise
const amountFloat = parseFloat(amountToRaise);
if (amountToRaise && !Number.isNaN(amountFloat)) {
const amountError = getAmountError(amountFloat, 10);
if (amountError) {
errors.amountToRaise = amountError;
}
}
// Payout address
if (payOutAddress && !web3.utils.isAddress(payOutAddress)) {
errors.payOutAddress = 'That doesnt look like a valid address';
}
// Trustees
let didTrusteeError = false;
const trusteeErrors = trustees.map((address, idx) => {
if (!address) {
return '';
}
let err = '';
if (!web3.utils.isAddress(address)) {
err = 'That doesnt look like a valid address';
} else if (trustees.indexOf(address) !== idx) {
err = 'That address is already a trustee';
} else if (payOutAddress === address) {
err = 'That address is already a trustee';
}
didTrusteeError = didTrusteeError || !!err;
return err;
});
if (didTrusteeError) {
errors.trustees = trusteeErrors;
}
// Milestones
let didMilestoneError = false;
let cumulativeMilestonePct = 0;
const milestoneErrors = milestones.map((ms, idx) => {
if (isMilestoneUnfilled(ms)) {
didMilestoneError = true;
return '';
}
let err = '';
if (ms.title.length > 40) {
err = 'Title length can be 40 characters maximum';
} else if (ms.description.length > 200) {
err = 'Description can be 200 characters maximum';
}
// Last one shows percentage errors
cumulativeMilestonePct += ms.payoutPercent;
if (idx === milestones.length - 1 && cumulativeMilestonePct !== 100) {
err = `Payout percentages doesnt add up to 100% (currently ${cumulativeMilestonePct}%)`;
}
didMilestoneError = didMilestoneError || !!err;
return err;
});
if (didMilestoneError) {
errors.milestones = milestoneErrors;
}
return errors;
};
render() {
const { crowdFundLoading, crowdFundError, crowdFundCreatedAddress } = this.props;
const {
title,
category,
proposalBody,
amountToRaise,
payOutAddress,
trustees,
milestones,
deadline,
milestoneDeadline,
} = this.state;
if (crowdFundCreatedAddress) {
return <CreateSuccess crowdFundCreatedAddress={crowdFundCreatedAddress} />;
}
const errors = this.getFormErrors();
const hasErrors = Object.keys(errors).length !== 0;
const isMissingFields =
!title ||
!category ||
!proposalBody ||
!amountToRaise ||
trustees.includes('') ||
!!milestones.find(isMilestoneUnfilled);
const isDisabled = hasErrors || isMissingFields || crowdFundLoading;
return (
<Form layout="vertical">
<Styled.Title>Create a proposal</Styled.Title>
<Styled.HelpText>All fields are required</Styled.HelpText>
<Form.Item
label="Title"
validateStatus={errors.title ? 'error' : undefined}
help={errors.title}
>
<Input
size="large"
name="title"
placeholder="Short and sweet"
type="text"
value={title}
onChange={this.handleInputChange}
/>
</Form.Item>
<Form.Item label="Category">
<Select
size="large"
placeholder="Select a category"
value={category}
onChange={this.handleCategoryChange}
>
{Object.keys(PROPOSAL_CATEGORY).map((c: PROPOSAL_CATEGORY) => (
<Select.Option value={c} key={c}>
<Icon
type={CATEGORY_UI[c].icon}
style={{ color: CATEGORY_UI[c].color }}
/>{' '}
{CATEGORY_UI[c].label}
</Select.Option>
))}
</Select>
</Form.Item>
<Form.Item
label="Target amount"
validateStatus={errors.amountToRaise ? 'error' : undefined}
help={errors.amountToRaise}
>
<Input
size="large"
name="amountToRaise"
placeholder="1.5"
type="number"
value={amountToRaise}
onChange={this.handleInputChange}
addonAfter="ETH"
/>
</Form.Item>
<Divider style={{ margin: '3rem 0' }}>Description</Divider>
<Styled.BodyField>
<MarkdownEditor onChange={this.handleProposalBodyChange} />
</Styled.BodyField>
<Divider style={{ margin: '3rem 0' }}>Addresses</Divider>
<Form.Item
label="Payout address"
validateStatus={errors.payOutAddress ? 'error' : undefined}
help={errors.payOutAddress}
>
<Input
size="large"
name="payOutAddress"
placeholder="0xe12a34230e5e7fc73d094e52025135e4fbf24653"
type="text"
value={payOutAddress}
onChange={this.handleInputChange}
/>
</Form.Item>
<Form.Item label="Trustee addresses">
<Input
placeholder="Payout address will also become a trustee"
size="large"
type="text"
disabled
value={payOutAddress}
/>
</Form.Item>
{trustees.map((address, idx) => (
<TrusteeFields
key={idx}
value={address}
index={idx}
error={errors.trustees && errors.trustees[idx]}
onChange={this.handleTrusteeChange}
onRemove={this.removeTrustee}
/>
))}
{trustees.length < 9 && (
<Button type="dashed" onClick={this.addTrustee}>
<Icon type="plus" /> Add another trustee
</Button>
)}
<Divider style={{ margin: '3rem 0' }}>Milestones</Divider>
{milestones.map((milestone, idx) => (
<MilestoneFields
key={idx}
milestone={milestone}
index={idx}
error={errors.milestones && errors.milestones[idx]}
onChange={this.handleMilestoneChange}
onRemove={this.removeMilestone}
/>
))}
{milestones.length < 10 && (
<Button type="dashed" onClick={this.addMilestone}>
<Icon type="plus" /> Add another milestone
</Button>
)}
<Divider style={{ margin: '3rem 0' }}>Deadlines</Divider>
<Form.Item label="Funding Deadline">
<Radio.Group
name="deadline"
value={deadline}
onChange={this.handleRadioChange}
size="large"
style={{ display: 'flex', textAlign: 'center' }}
>
<Radio.Button style={{ flex: 1 }} value={60 * 60 * 24 * 30}>
30 Days
</Radio.Button>
<Radio.Button style={{ flex: 1 }} value={60 * 60 * 24 * 60}>
60 Days
</Radio.Button>
<Radio.Button style={{ flex: 1 }} value={60 * 60 * 24 * 90}>
90 Days
</Radio.Button>
</Radio.Group>
</Form.Item>
<Form.Item label="Milestone Voting Period">
<Radio.Group
name="milestoneDeadline"
value={milestoneDeadline}
onChange={this.handleRadioChange}
size="large"
style={{ display: 'flex', textAlign: 'center' }}
>
<Radio.Button style={{ flex: 1 }} value={60 * 60 * 24 * 3}>
3 Days
</Radio.Button>
<Radio.Button style={{ flex: 1 }} value={60 * 60 * 24 * 7}>
7 Days
</Radio.Button>
<Radio.Button style={{ flex: 1 }} value={60 * 60 * 24 * 10}>
10 Days
</Radio.Button>
</Radio.Group>
</Form.Item>
{crowdFundError && (
<Alert
style={{ marginBottom: '2rem' }}
message="Something went wrong"
description={crowdFundError}
type="error"
showIcon
/>
)}
<Button
onClick={this.createCrowdFund}
size="large"
type="primary"
disabled={isDisabled}
style={{ marginTop: '3rem' }}
block
>
{crowdFundLoading ? <Spin /> : 'Create Proposal'}
</Button>
{isMissingFields && (
<Alert
message="It looks like some fields are still missing. All fields are required."
type="info"
style={{ marginTop: '1rem' }}
showIcon
/>
)}
{!isMissingFields &&
hasErrors && (
<Alert
message="It looks like some fields still have errors. They must be fixed before continuing."
type="error"
style={{ marginTop: '1rem' }}
showIcon
/>
)}
</Form>
);
}
}
function isMilestoneUnfilled(milestone: Milestone) {
return !milestone.title || !milestone.description || !milestone.date;
}
function mapDispatchToProps(dispatch: Dispatch) {
return bindActionCreators(web3Actions, dispatch);
}
function mapStateToProps(state: AppState) {
return {
crowdFundLoading: state.web3.crowdFundLoading,
crowdFundError: state.web3.crowdFundError,
crowdFundCreatedAddress: state.web3.crowdFundCreatedAddress,
};
}
const withConnect = connect(
mapStateToProps,
mapDispatchToProps,
);
const ConnectedCreateProposal = compose(withConnect)(CreateProposal);
export default () => (
<Web3Container
renderLoading={() => <div>Loading Dapp Page...</div>}
render={({ web3, contracts }) => (
<ConnectedCreateProposal contract={contracts[0]} web3={web3} />
)}
/>
);

View File

@ -0,0 +1,41 @@
import styled from 'styled-components';
export const BodyField = styled.div`
margin: 0 -10rem 0;
@media (max-width: 980px) {
margin: 0;
}
`;
export const Title = styled.h1`
border-bottom: 4px solid #ddd;
font-size: 1.6rem;
max-width: 15rem;
margin: 0 auto 0.4rem;
text-align: center;
`;
export const HelpText = styled.p`
text-align: center;
opacity: 0.4;
margin-bottom: 2rem;
font-size: 0.8rem;
`;
export const Success = styled.div`
display: flex;
justify-content: center;
align-items: center;
margin-top: 1rem;
`;
export const SuccessIcon = styled.div`
margin-right: 1rem;
font-size: 4rem;
color: #2ecc71;
`;
export const SuccessText = styled.div`
font-size: 1.05rem;
`;

View File

@ -0,0 +1,112 @@
import React from 'react';
import {
Form,
Select,
InputNumber,
Switch,
Radio,
Slider,
Button,
Upload,
Icon,
Rate,
} from 'antd';
const FormItem = Form.Item;
const Option = Select.Option;
const RadioButton = Radio.Button;
const RadioGroup = Radio.Group;
class Demo extends React.Component {
handleSubmit = e => {
e.preventDefault();
this.props.form.validateFields((err, values) => {
if (!err) {
console.log('Received values of form: ', values);
}
});
};
normFile = e => {
console.log('Upload event:', e);
if (Array.isArray(e)) {
return e;
}
return e && e.fileList;
};
render() {
const { getFieldDecorator } = this.props.form;
const formItemLayout = {
labelCol: { span: 6 },
wrapperCol: { span: 14 },
};
return (
<Form onSubmit={this.handleSubmit}>
<FormItem {...formItemLayout} label="InputNumber">
{getFieldDecorator('input-number', { initialValue: 3 })(
<InputNumber min={1} max={10} />,
)}
<span className="ant-form-text"> machines</span>
</FormItem>
<FormItem {...formItemLayout} label="Switch">
{getFieldDecorator('switch', { valuePropName: 'checked' })(<Switch />)}
</FormItem>
<FormItem {...formItemLayout} label="Slider">
{getFieldDecorator('slider')(
<Slider marks={{ 0: 'A', 20: 'B', 40: 'C', 60: 'D', 80: 'E', 100: 'F' }} />,
)}
</FormItem>
<FormItem {...formItemLayout} label="Radio.Group">
{getFieldDecorator('radio-group')(
<RadioGroup>
<Radio value="a">item 1</Radio>
<Radio value="b">item 2</Radio>
<Radio value="c">item 3</Radio>
</RadioGroup>,
)}
</FormItem>
<FormItem {...formItemLayout} label="Radio.Button">
{getFieldDecorator('radio-button')(
<RadioGroup>
<RadioButton value="a">item 1</RadioButton>
<RadioButton value="b">item 2</RadioButton>
<RadioButton value="c">item 3</RadioButton>
</RadioGroup>,
)}
</FormItem>
<FormItem {...formItemLayout} label="Dragger">
<div className="dropbox">
{getFieldDecorator('dragger', {
valuePropName: 'fileList',
getValueFromEvent: this.normFile,
})(
<Upload.Dragger name="files" action="/upload.do">
<p className="ant-upload-drag-icon">
<Icon type="inbox" />
</p>
<p className="ant-upload-text">
Click or drag file to this area to upload
</p>
<p className="ant-upload-hint">Support for a single or bulk upload.</p>
</Upload.Dragger>,
)}
</div>
</FormItem>
<FormItem wrapperCol={{ span: 12, offset: 6 }}>
<Button type="primary" htmlType="submit">
Submit
</Button>
</FormItem>
</Form>
);
}
}
export default Form.create()(Demo);

View File

@ -0,0 +1,70 @@
import React from 'react';
import ReactMde, { ReactMdeTypes } from 'react-mde';
import Showdown from 'showdown';
import * as Styled from './styled';
import { Input } from 'antd';
import { Row, Col } from 'antd';
import { InputNumber } from 'antd';
import Form from './Form';
export interface AppState {
mdeState: ReactMdeTypes.MdeState;
}
export default class App extends React.Component<{}, AppState> {
converter: Showdown.Converter;
constructor(props: {}) {
super(props);
this.state = {
mdeState: null,
};
this.converter = new Showdown.Converter({
tables: true,
simplifiedAutoLink: true,
});
}
handleValueChange = (mdeState: ReactMdeTypes.MdeState) => {
this.setState({ mdeState });
};
onChange = () => {};
render() {
// https://github.com/andrerpena/react-mde
return (
<div>
<Row gutter={16}>
<Form />
<Col xs={32} sm={28} md={24} lg={20} xl={18}>
<Styled.Header>Create a new proposal! </Styled.Header>
<InputNumber
size="large"
min={1}
max={100000}
defaultValue={3}
onChange={this.onChange}
/>
<Input
size="large"
placeholder="My Awesome Proposal"
onChange={this.onChange}
/>
<ReactMde
onChange={this.handleValueChange}
editorState={this.state.mdeState}
generateMarkdownPreview={markdown =>
Promise.resolve(this.converter.makeHtml(markdown))
}
/>
</Col>
</Row>
</div>
);
}
}

View File

@ -0,0 +1,5 @@
import styled from 'styled-components';
export const Header = styled.h1`
font-size: 1.5rem;
`;

View File

@ -0,0 +1,16 @@
import React from 'react';
import Link from 'next/link';
import * as Styled from './styled';
export default () => (
<Styled.Footer>
<Link href="/">
<Styled.Title>Grant.io</Styled.Title>
</Link>
{/*<Styled.Links>
<Styled.Link>about</Styled.Link>
<Styled.Link>legal</Styled.Link>
<Styled.Link>privacy policy</Styled.Link>
</Styled.Links>*/}
</Styled.Footer>
);

View File

@ -0,0 +1,45 @@
import styled from 'styled-components';
export const Footer = styled.footer`
display: flex;
justify-content: center;
align-items: center;
flex-direction: column;
color: #fff;
background: #4c4c4c;
height: 140px;
`;
export const Title = styled.a`
font-size: 1.5rem;
font-weight: 600;
margin-bottom: 0.5rem;
color: #fff;
transition: transform 100ms ease;
&:hover,
&:focus,
&:active {
transform: translateY(-1px);
color: inherit;
}
`;
export const Links = styled.div`
display: flex;
justify-content: center;
align-items: center;
`;
export const Link = styled.a`
font-size: 1rem;
padding: 0 1rem;
color: #fff;
opacity: 0.8;
transition: opacity 100ms ease;
&:hover {
color: inherit;
opacity: 1;
}
`;

View File

@ -0,0 +1,49 @@
import React from 'react';
import { Icon } from 'antd';
import Link from 'next/link';
import * as Styled from './styled';
interface OwnProps {
isTransparent?: boolean;
}
type Props = OwnProps;
export default class Header extends React.Component<Props> {
render() {
const { isTransparent } = this.props;
return (
<React.Fragment>
<Styled.Header isTransparent={isTransparent}>
<div style={{ display: 'flex' }}>
<Link href="/proposals">
<Styled.Button>
<Styled.ButtonIcon>
<Icon type="shop" />
</Styled.ButtonIcon>
<Styled.ButtonText>Explore</Styled.ButtonText>
</Styled.Button>
</Link>
</div>
<Link href="/">
<Styled.Title>Grant.io</Styled.Title>
</Link>
<React.Fragment>
<Link href="/create">
<Styled.Button style={{ marginLeft: '1.5rem' }}>
<Styled.ButtonIcon>
<Icon type="form" />
</Styled.ButtonIcon>
<Styled.ButtonText>Start a Proposal</Styled.ButtonText>
</Styled.Button>
</Link>
</React.Fragment>
{!isTransparent && <Styled.AlphaBanner>Alpha</Styled.AlphaBanner>}
</Styled.Header>
</React.Fragment>
);
}
}

View File

@ -0,0 +1,107 @@
import styled from 'styled-components';
const headerHeight = '78px';
const smallQuery = '520px';
export const Placeholder = styled.div`
height: ${headerHeight};
`;
export const Header = styled.header`
position: ${(p: any) => (p.isTransparent ? 'absolute' : 'relative')};
top: 0;
left: 0;
right: 0;
height: ${headerHeight};
display: flex;
justify-content: space-between;
align-items: center;
padding: 0 3rem;
z-index: 999;
color: ${(p: any) => (p.isTransparent ? '#FFF' : '#333')};
background: ${(p: any) => (p.isTransparent ? 'transparent' : '#FFF')};
text-shadow: ${(p: any) => (p.isTransparent ? '0 2px 4px rgba(0, 0, 0, 0.4)' : 'none')};
box-shadow: ${(p: any) => (p.isTransparent ? 'none' : '0 1px 2px rgba(0, 0, 0, 0.3)')};
`;
export const Title = styled.a`
position: absolute;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
font-size: 2.2rem;
margin: 0;
color: inherit;
letter-spacing: 0.08rem;
font-weight: 500;
transition: transform 100ms ease;
flex-grow: 1;
text-align: center;
&:hover,
&:focus,
&:active {
color: inherit;
transform: translateY(-2px) translate(-50%, -50%);
}
`;
export const Button = styled.a`
display: block;
background: none;
padding: 0;
font-size: 1.2rem;
font-weight: 300;
color: inherit;
letter-spacing: 0.05rem;
cursor: pointer;
transition: transform 100ms ease;
&:hover,
&:focus,
&:active {
transform: translateY(-1px);
color: inherit;
}
`;
interface ButtonTextProps {
size?: number;
}
export const ButtonText = styled.span`
@media (max-width: ${smallQuery}) {
display: none;
}
font-size: ${(props: ButtonTextProps) => (props.size ? props.size + 'rem' : '1.1rem')};
`;
export const ButtonIcon = styled.span`
padding-right: 10px;
@media (max-width: ${smallQuery}) {
padding: 0;
font-weight: 400;
font-size: 1.5rem;
}
`;
export const AlphaBanner = styled.div`
position: absolute;
bottom: 0;
left: 50%;
transform: translate(-50%, 50%);
background: linear-gradient(to right, #8e2de2, #4a00e0);
color: #fff;
width: 80px;
height: 22px;
border-radius: 11px;
line-height: 22px;
text-align: center;
text-transform: uppercase;
letter-spacing: 0.2rem;
font-size: 10px;
font-weight: bold;
text-shadow: 1px 1px 1px rgba(0, 0, 0, 0.3);
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.4);
`;

Some files were not shown because too many files have changed in this diff Show More