initial commit
This commit is contained in:
commit
2f513d0ce6
|
@ -0,0 +1 @@
|
|||
.idea
|
|
@ -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
|
||||
|
||||
|
||||
|
||||
|
||||
|
|
@ -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"
|
|
@ -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/
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
[settings]
|
||||
line_length=120
|
|
@ -0,0 +1,6 @@
|
|||
repos:
|
||||
- repo: https://github.com/ambv/black
|
||||
rev: stable
|
||||
hooks:
|
||||
- id: black
|
||||
language_version: python3.6
|
|
@ -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
|
|
@ -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.
|
|
@ -0,0 +1 @@
|
|||
web: gunicorn grant.app:create_app\(\) -b 0.0.0.0:$PORT -w 1
|
|
@ -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"
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
"""Create an application instance."""
|
||||
from grant.app import create_app
|
||||
|
||||
app = create_app()
|
|
@ -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
|
|
@ -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)
|
|
@ -0,0 +1,3 @@
|
|||
from . import views
|
||||
from . import models
|
||||
from . import commands
|
|
@ -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()
|
|
@ -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)
|
|
@ -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)
|
|
@ -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]))
|
|
@ -0,0 +1,2 @@
|
|||
from . import views
|
||||
from . import models
|
|
@ -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)
|
|
@ -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)
|
|
@ -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)
|
|
@ -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()
|
|
@ -0,0 +1,3 @@
|
|||
from . import views
|
||||
from . import models
|
||||
from . import commands
|
|
@ -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()
|
|
@ -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)
|
|
@ -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)
|
|
@ -0,0 +1,3 @@
|
|||
from . import commands
|
||||
from . import models
|
||||
from . import views
|
|
@ -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()
|
|
@ -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)
|
|
@ -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)
|
|
@ -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
|
|
@ -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
|
|
@ -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)
|
|
@ -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()))
|
|
@ -0,0 +1 @@
|
|||
Generic single-database configuration.
|
|
@ -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
|
|
@ -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()
|
|
@ -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"}
|
|
@ -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 ###
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -0,0 +1,3 @@
|
|||
[flake8]
|
||||
ignore = D401
|
||||
max-line-length=120
|
|
@ -0,0 +1 @@
|
|||
"""Tests for the app."""
|
|
@ -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
|
|
@ -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."""
|
||||
|
|
@ -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
|
|
@ -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
|
|
@ -0,0 +1,2 @@
|
|||
INFURA_KEY=key
|
||||
MNEMONIC=mnemonic
|
|
@ -0,0 +1,5 @@
|
|||
node_modules
|
||||
build
|
||||
.idea/
|
||||
yarn-error.log
|
||||
.env
|
|
@ -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
|
|
@ -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));
|
||||
}
|
||||
);
|
||||
});
|
||||
});
|
|
@ -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,
|
||||
});
|
|
@ -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");
|
||||
_;
|
||||
}
|
||||
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -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");
|
||||
_;
|
||||
}
|
||||
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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';
|
|
@ -0,0 +1,5 @@
|
|||
var Migrations = artifacts.require("./Migrations.sol");
|
||||
|
||||
module.exports = function(deployer) {
|
||||
deployer.deploy(Migrations);
|
||||
};
|
|
@ -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);
|
||||
};
|
|
@ -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"
|
||||
}
|
||||
}
|
|
@ -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));
|
||||
});
|
||||
});
|
|
@ -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)
|
||||
});
|
||||
|
||||
});
|
|
@ -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));
|
||||
});
|
||||
});
|
|
@ -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
|
||||
});
|
|
@ -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
|
||||
};
|
|
@ -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"
|
||||
// }
|
||||
}
|
||||
}
|
||||
};
|
File diff suppressed because it is too large
Load Diff
|
@ -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
|
||||
}
|
||||
]
|
||||
]
|
||||
}
|
|
@ -0,0 +1,2 @@
|
|||
# Funds these addresses when `npm run truffle` runs. Comma separated.
|
||||
FUND_ETH_ADDRESSES=0x4bbeEB066eD09B7AEd07bF39EEe0460DFa261520,0xDECAF9CD2367cdbb726E904cD6397eDFcAe6068DEe0460DFa261520,0xDECAF9CD2367cdbb726E904cD6397eDFcAe6068D
|
|
@ -0,0 +1,11 @@
|
|||
.next
|
||||
node_modules
|
||||
.idea
|
||||
build
|
||||
out
|
||||
src/build
|
||||
dist
|
||||
*.log
|
||||
.env
|
||||
*.pid
|
||||
client/lib/contracts
|
|
@ -0,0 +1 @@
|
|||
package-lock=false
|
|
@ -0,0 +1 @@
|
|||
8.11.4
|
|
@ -0,0 +1,10 @@
|
|||
{
|
||||
"printWidth": 90,
|
||||
"singleQuote": true,
|
||||
"useTabs": false,
|
||||
"semi": true,
|
||||
"tabWidth": 2,
|
||||
"trailingComma": "all",
|
||||
"bracketSpacing": true,
|
||||
"jsxBracketSameLine": false
|
||||
}
|
|
@ -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
|
||||
```
|
|
@ -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();
|
||||
};
|
|
@ -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);
|
||||
}
|
|
@ -0,0 +1,8 @@
|
|||
import axios from 'axios';
|
||||
|
||||
const instance = axios.create({
|
||||
baseURL: process.env.BACKEND_URL,
|
||||
headers: {},
|
||||
});
|
||||
|
||||
export default instance;
|
|
@ -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',
|
||||
},
|
||||
};
|
|
@ -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;
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
`;
|
|
@ -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;
|
|
@ -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;
|
|
@ -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;
|
|
@ -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;
|
|
@ -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 doesn’t 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 doesn’t 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 doesn’t 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} />
|
||||
)}
|
||||
/>
|
||||
);
|
|
@ -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;
|
||||
`;
|
|
@ -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);
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,5 @@
|
|||
import styled from 'styled-components';
|
||||
|
||||
export const Header = styled.h1`
|
||||
font-size: 1.5rem;
|
||||
`;
|
|
@ -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>
|
||||
);
|
|
@ -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;
|
||||
}
|
||||
`;
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
}
|
|
@ -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
Loading…
Reference in New Issue