Merge pull request #417 from grant-project/develop
Release 1.1.0 (Unsquashed)
This commit is contained in:
commit
90fd44102c
|
@ -0,0 +1,8 @@
|
|||
# admin listen port
|
||||
PORT=3500
|
||||
|
||||
# backend url
|
||||
BACKEND_URL=http://localhost:5000
|
||||
|
||||
# Disable SSL in production
|
||||
# DISABLE_SSL=true
|
|
@ -1,4 +0,0 @@
|
|||
# admin listen port
|
||||
PORT=3500
|
||||
# backend url
|
||||
BACKEND_URL=http://localhost:5000
|
|
@ -68,6 +68,7 @@
|
|||
"dotenv": "^6.0.0",
|
||||
"ethereum-blockies-base64": "1.0.2",
|
||||
"ethereumjs-util": "5.2.0",
|
||||
"express-sslify": "1.2.0",
|
||||
"file-loader": "^2.0.0",
|
||||
"font-awesome": "^4.7.0",
|
||||
"fork-ts-checker-webpack-plugin": "^0.4.2",
|
||||
|
|
|
@ -1,10 +1,17 @@
|
|||
const express = require('express');
|
||||
const path = require('path');
|
||||
const enforce = require('express-sslify');
|
||||
|
||||
require('dotenv').config();
|
||||
const isDev = process.env.NODE_ENV === 'development';
|
||||
const PORT = process.env.PORT || 3500;
|
||||
const app = express();
|
||||
|
||||
if (!isDev && !process.env.DISABLE_SSL) {
|
||||
console.log('PRODUCTION mode, enforcing HTTPS redirect');
|
||||
app.use(enforce.HTTPS({ trustProtoHeader: true }));
|
||||
}
|
||||
|
||||
app.use(express.static(__dirname + '/build'));
|
||||
|
||||
app.get('*', function(request, response) {
|
||||
|
|
|
@ -3225,6 +3225,11 @@ expand-tilde@^2.0.0, expand-tilde@^2.0.2:
|
|||
dependencies:
|
||||
homedir-polyfill "^1.0.1"
|
||||
|
||||
express-sslify@1.2.0:
|
||||
version "1.2.0"
|
||||
resolved "https://registry.yarnpkg.com/express-sslify/-/express-sslify-1.2.0.tgz#30e84bceed1557eb187672bbe1430a0a2a100d9c"
|
||||
integrity sha1-MOhLzu0VV+sYdnK74UMKCioQDZw=
|
||||
|
||||
express@^4.16.2:
|
||||
version "4.16.4"
|
||||
resolved "https://registry.yarnpkg.com/express/-/express-4.16.4.tgz#fddef61926109e24c515ea97fd2f1bdbf62df12e"
|
||||
|
|
|
@ -32,5 +32,8 @@ BLOCKCHAIN_API_SECRET="ef0b48e41f78d3ae85b1379b386f1bca"
|
|||
# EXPLORER_URL="https://chain.so/tx/ZEC/<txid>"
|
||||
EXPLORER_URL="https://chain.so/tx/ZECTEST/<txid>"
|
||||
|
||||
# Amount for staking a proposal in ZEC
|
||||
# Amount for staking a proposal in ZEC, keep in sync with frontend .env
|
||||
PROPOSAL_STAKING_AMOUNT=0.025
|
||||
|
||||
# Maximum amount for a proposal target, keep in sync with frontend .env
|
||||
PROPOSAL_TARGET_MAX=10000
|
||||
|
|
|
@ -472,7 +472,7 @@ def get_rfps():
|
|||
"category": fields.Str(required=True, validate=validate.OneOf(choices=Category.list())),
|
||||
"bounty": fields.Str(required=False, missing=0),
|
||||
"matching": fields.Bool(required=False, missing=False),
|
||||
"dateCloses": fields.Int(required=True)
|
||||
"dateCloses": fields.Int(required=False, missing=None)
|
||||
})
|
||||
@admin.admin_auth_required
|
||||
def create_rfp(date_closes, **kwargs):
|
||||
|
|
|
@ -158,6 +158,7 @@ def register_commands(app):
|
|||
app.cli.add_command(commands.lint)
|
||||
app.cli.add_command(commands.clean)
|
||||
app.cli.add_command(commands.urls)
|
||||
app.cli.add_command(commands.reset_db_chain_data)
|
||||
app.cli.add_command(proposal.commands.create_proposal)
|
||||
app.cli.add_command(proposal.commands.create_proposals)
|
||||
app.cli.add_command(user.commands.set_admin)
|
||||
|
|
|
@ -8,6 +8,7 @@ import click
|
|||
from flask import current_app
|
||||
from flask.cli import with_appcontext
|
||||
from werkzeug.exceptions import MethodNotAllowed, NotFound
|
||||
from sqlalchemy import text
|
||||
|
||||
HERE = os.path.abspath(os.path.dirname(__file__))
|
||||
PROJECT_ROOT = os.path.join(HERE, os.pardir)
|
||||
|
@ -137,3 +138,46 @@ def urls(url, order):
|
|||
|
||||
for row in rows:
|
||||
click.echo(str_template.format(*row[:column_length]))
|
||||
|
||||
|
||||
@click.command()
|
||||
@with_appcontext
|
||||
def reset_db_chain_data():
|
||||
"""Removes chain-state dependent entities from the database. Cannot be undone!"""
|
||||
from grant.extensions import db
|
||||
from grant.proposal.models import Proposal
|
||||
from grant.user.models import UserSettings
|
||||
from grant.task.models import Task
|
||||
|
||||
# Delete all proposals. Should cascade to contributions, comments etc.
|
||||
p_count = 0
|
||||
for proposal in Proposal.query.all():
|
||||
db.session.delete(proposal)
|
||||
p_count = p_count + 1
|
||||
|
||||
# Delete all outstanding tasks
|
||||
t_count = Task.query.delete()
|
||||
|
||||
# Delete refund address from settings
|
||||
s_count = 0
|
||||
for settings in UserSettings.query.all():
|
||||
if settings.refund_address:
|
||||
settings.refund_address = None
|
||||
db.session.add(settings)
|
||||
s_count = s_count + 1
|
||||
|
||||
# Commit state
|
||||
db.session.commit()
|
||||
|
||||
# Attempt to reset contribution ID sequence, psql specific. Don't fail out
|
||||
# if this messes up though, just warn them.
|
||||
try:
|
||||
db.engine.execute(text('ALTER SEQUENCE proposal_contribution_id_seq RESTART WITH 1'))
|
||||
except e:
|
||||
print(e)
|
||||
print('Failed to reset contribution id sequence, see above error. Continuing anyway.')
|
||||
|
||||
print('Successfully wiped chain-dependent db state!')
|
||||
print(f'* Deleted {p_count} proposals and their linked entities')
|
||||
print(f'* Deleted {t_count} tasks')
|
||||
print(f'* Removed refund address from {s_count} user settings')
|
||||
|
|
|
@ -30,7 +30,7 @@ class Comment(db.Model):
|
|||
self.proposal_id = proposal_id
|
||||
self.user_id = user_id
|
||||
self.parent_comment_id = parent_comment_id
|
||||
self.content = content
|
||||
self.content = content[:1000]
|
||||
self.date_created = datetime.datetime.now()
|
||||
|
||||
@staticmethod
|
||||
|
|
|
@ -53,11 +53,11 @@ class Milestone(db.Model):
|
|||
proposal_id=int,
|
||||
):
|
||||
self.id = gen_random_id(Milestone)
|
||||
self.title = title
|
||||
self.content = content
|
||||
self.title = title[:255]
|
||||
self.content = content[:255]
|
||||
self.stage = stage
|
||||
self.date_estimated = date_estimated
|
||||
self.payout_percent = payout_percent
|
||||
self.payout_percent = payout_percent[:255]
|
||||
self.immediate_payout = immediate_payout
|
||||
self.proposal_id = proposal_id
|
||||
self.date_created = datetime.datetime.now()
|
||||
|
@ -70,21 +70,16 @@ class Milestone(db.Model):
|
|||
[db.session.delete(x) for x in proposal.milestones]
|
||||
for i, milestone_data in enumerate(milestones_data):
|
||||
m = Milestone(
|
||||
title=milestone_data["title"],
|
||||
content=milestone_data["content"],
|
||||
title=milestone_data["title"][:255],
|
||||
content=milestone_data["content"][:255],
|
||||
date_estimated=datetime.datetime.fromtimestamp(milestone_data["date_estimated"]),
|
||||
payout_percent=str(milestone_data["payout_percent"]),
|
||||
payout_percent=str(milestone_data["payout_percent"])[:255],
|
||||
immediate_payout=milestone_data["immediate_payout"],
|
||||
proposal_id=proposal.id,
|
||||
index=i
|
||||
)
|
||||
db.session.add(m)
|
||||
|
||||
@staticmethod
|
||||
def validate(milestone):
|
||||
if len(milestone.title) > 60:
|
||||
raise ValidationException("Milestone title must be no more than 60 chars")
|
||||
|
||||
def request_payout(self, user_id: int):
|
||||
if self.stage not in [MilestoneStage.IDLE, MilestoneStage.REJECTED]:
|
||||
raise MilestoneException(f'Cannot request payout for milestone at {self.stage} stage')
|
||||
|
|
|
@ -11,7 +11,7 @@ from flask import current_app
|
|||
from grant.comment.models import Comment
|
||||
from grant.email.send import send_email
|
||||
from grant.extensions import ma, db
|
||||
from grant.settings import PROPOSAL_STAKING_AMOUNT
|
||||
from grant.settings import PROPOSAL_STAKING_AMOUNT, PROPOSAL_TARGET_MAX
|
||||
from grant.task.jobs import ContributionExpired
|
||||
from grant.utils.enums import (
|
||||
ProposalStatus,
|
||||
|
@ -45,7 +45,7 @@ class ProposalTeamInvite(db.Model):
|
|||
|
||||
def __init__(self, proposal_id: int, address: str, accepted: bool = None):
|
||||
self.proposal_id = proposal_id
|
||||
self.address = address
|
||||
self.address = address[:255]
|
||||
self.accepted = accepted
|
||||
self.date_created = datetime.datetime.now()
|
||||
|
||||
|
@ -70,7 +70,7 @@ class ProposalUpdate(db.Model):
|
|||
def __init__(self, proposal_id: int, title: str, content: str):
|
||||
self.id = gen_random_id(ProposalUpdate)
|
||||
self.proposal_id = proposal_id
|
||||
self.title = title
|
||||
self.title = title[:255]
|
||||
self.content = content
|
||||
self.date_created = datetime.datetime.now()
|
||||
|
||||
|
@ -275,12 +275,11 @@ class Proposal(db.Model):
|
|||
|
||||
@staticmethod
|
||||
def simple_validate(proposal):
|
||||
title = proposal.get('title')
|
||||
# Validate fields to be database save-able.
|
||||
# Stricter validation is done in validate_publishable.
|
||||
stage = proposal.get('stage')
|
||||
category = proposal.get('category')
|
||||
|
||||
if title and len(title) > 60:
|
||||
raise ValidationException("Proposal title cannot be longer than 60 characters")
|
||||
if stage and not ProposalStage.includes(stage):
|
||||
raise ValidationException("Proposal stage {} is not a valid stage".format(stage))
|
||||
if category and not Category.includes(category):
|
||||
|
@ -294,37 +293,59 @@ class Proposal(db.Model):
|
|||
raise ValidationException("Only the first milestone can have an immediate payout")
|
||||
|
||||
if len(milestone.title) > 60:
|
||||
raise ValidationException("Milestone title must be no more than 60 chars")
|
||||
raise ValidationException("Milestone title cannot be longer than 60 chars")
|
||||
|
||||
if len(milestone.content) > 200:
|
||||
raise ValidationException("Milestone content must be no more than 200 chars")
|
||||
raise ValidationException("Milestone content cannot be longer than 200 chars")
|
||||
|
||||
payout_total += float(milestone.payout_percent)
|
||||
try:
|
||||
p = float(milestone.payout_percent)
|
||||
if not p.is_integer():
|
||||
raise ValidationException("Milestone payout percents must be whole numbers, no decimals")
|
||||
if p <= 0 or p > 100:
|
||||
raise ValidationException("Milestone payout percent must be greater than zero")
|
||||
except ValueError:
|
||||
raise ValidationException("Milestone payout percent must be a number")
|
||||
|
||||
payout_total += p
|
||||
|
||||
try:
|
||||
present = datetime.datetime.today().replace(day=1)
|
||||
if present > milestone.date_estimated:
|
||||
raise ValidationException("Milestone date_estimated must be in the future ")
|
||||
raise ValidationException("Milestone date estimate must be in the future ")
|
||||
|
||||
except Exception as e:
|
||||
current_app.logger.warn(
|
||||
f"Unexpected validation error - client prohibits {e}"
|
||||
)
|
||||
raise ValidationException("date_estimated does not convert to a datetime")
|
||||
raise ValidationException("Date estimate is not a valid datetime")
|
||||
|
||||
if payout_total != 100.0:
|
||||
raise ValidationException("payoutPercent across milestones must sum to exactly 100")
|
||||
raise ValidationException("Payout percentages of milestones must add up to exactly 100%")
|
||||
|
||||
def validate_publishable(self):
|
||||
self.validate_publishable_milestones()
|
||||
|
||||
# Require certain fields
|
||||
|
||||
required_fields = ['title', 'content', 'brief', 'category', 'target', 'payout_address']
|
||||
for field in required_fields:
|
||||
if not hasattr(self, field):
|
||||
raise ValidationException("Proposal must have a {}".format(field))
|
||||
|
||||
# Stricter limits on certain fields
|
||||
if len(self.title) > 60:
|
||||
raise ValidationException("Proposal title cannot be longer than 60 characters")
|
||||
if len(self.brief) > 140:
|
||||
raise ValidationException("Brief cannot be longer than 140 characters")
|
||||
if len(self.content) > 250000:
|
||||
raise ValidationException("Content cannot be longer than 250,000 characters")
|
||||
if Decimal(self.target) > PROPOSAL_TARGET_MAX:
|
||||
raise ValidationException("Target cannot be more than {} ZEC".format(PROPOSAL_TARGET_MAX))
|
||||
if Decimal(self.target) < 0.0001:
|
||||
raise ValidationException("Target cannot be less than 0.0001")
|
||||
if self.deadline_duration > 7776000:
|
||||
raise ValidationException("Deadline duration cannot be more than 90 days")
|
||||
|
||||
# Check with node that the address is kosher
|
||||
try:
|
||||
res = blockchain_get('/validate/address', {'address': self.payout_address})
|
||||
|
@ -380,12 +401,12 @@ class Proposal(db.Model):
|
|||
payout_address: str = '',
|
||||
deadline_duration: int = 5184000 # 60 days
|
||||
):
|
||||
self.title = title
|
||||
self.brief = brief
|
||||
self.title = title[:255]
|
||||
self.brief = brief[:255]
|
||||
self.category = category
|
||||
self.content = content
|
||||
self.target = target if target != '' else None
|
||||
self.payout_address = payout_address
|
||||
self.content = content[:300000]
|
||||
self.target = target[:255] if target != '' else '0'
|
||||
self.payout_address = payout_address[:255]
|
||||
self.deadline_duration = deadline_duration
|
||||
Proposal.simple_validate(vars(self))
|
||||
|
||||
|
|
|
@ -4,6 +4,7 @@ from flask import Blueprint, g, request, current_app
|
|||
from marshmallow import fields, validate
|
||||
from sqlalchemy import or_
|
||||
from sentry_sdk import capture_message
|
||||
from webargs import validate
|
||||
|
||||
from grant.extensions import limiter
|
||||
from grant.comment.models import Comment, comment_schema, comments_schema
|
||||
|
@ -98,7 +99,7 @@ def report_proposal_comment(proposal_id, comment_id):
|
|||
@limiter.limit("30/hour;2/minute")
|
||||
@requires_email_verified_auth
|
||||
@body({
|
||||
"comment": fields.Str(required=True),
|
||||
"comment": fields.Str(required=True, validate=validate.Length(max=1000)),
|
||||
"parentCommentId": fields.Int(required=False, missing=None),
|
||||
})
|
||||
def post_proposal_comments(proposal_id, comment, parent_comment_id):
|
||||
|
@ -122,9 +123,6 @@ def post_proposal_comments(proposal_id, comment, parent_comment_id):
|
|||
if g.current_user.silenced:
|
||||
return {"message": "Your account has been silenced, commenting is disabled."}, 403
|
||||
|
||||
if len(comment) > 1000:
|
||||
return {"message": "Please make sure your comment is less than 1000 characters long"}, 400
|
||||
|
||||
# Make the comment
|
||||
comment = Comment(
|
||||
proposal_id=proposal_id,
|
||||
|
@ -218,6 +216,7 @@ def get_proposal_drafts():
|
|||
@blueprint.route("/<proposal_id>", methods=["PUT"])
|
||||
@requires_team_member_auth
|
||||
@body({
|
||||
# Length checks are to prevent database errors, not actual user limits imposed
|
||||
"title": fields.Str(required=True),
|
||||
"brief": fields.Str(required=True),
|
||||
"category": fields.Str(required=True, validate=validate.OneOf(choices=Category.list() + [''])),
|
||||
|
@ -226,7 +225,7 @@ def get_proposal_drafts():
|
|||
"payoutAddress": fields.Str(required=True),
|
||||
"deadlineDuration": fields.Int(required=True),
|
||||
"milestones": fields.List(fields.Dict(), required=True),
|
||||
"rfpOptIn": fields.Bool(required=False, missing=None)
|
||||
"rfpOptIn": fields.Bool(required=False, missing=None),
|
||||
})
|
||||
def update_proposal(milestones, proposal_id, rfp_opt_in, **kwargs):
|
||||
# Update the base proposal fields
|
||||
|
@ -348,8 +347,8 @@ def get_proposal_update(proposal_id, update_id):
|
|||
@limiter.limit("5/day;1/minute")
|
||||
@requires_team_member_auth
|
||||
@body({
|
||||
"title": fields.Str(required=True, validate=lambda p: 3 <= len(p) <= 30),
|
||||
"content": fields.Str(required=True, validate=lambda p: 5 <= len(p) <= 10000),
|
||||
"title": fields.Str(required=True, validate=validate.Length(min=3, max=60)),
|
||||
"content": fields.Str(required=True, validate=validate.Length(min=5, max=10000)),
|
||||
})
|
||||
def post_proposal_update(proposal_id, title, content):
|
||||
update = ProposalUpdate(
|
||||
|
@ -376,9 +375,13 @@ def post_proposal_update(proposal_id, title, content):
|
|||
@limiter.limit("30/day;10/minute")
|
||||
@requires_team_member_auth
|
||||
@body({
|
||||
"address": fields.Str(required=True),
|
||||
"address": fields.Str(required=True, validate=validate.Length(max=255)),
|
||||
})
|
||||
def post_proposal_team_invite(proposal_id, address):
|
||||
for u in g.current_proposal.team:
|
||||
if address == u.email_address:
|
||||
return {"message": f"Cannot invite members already on the team"}, 400
|
||||
|
||||
existing_invite = ProposalTeamInvite.query.filter_by(
|
||||
proposal_id=proposal_id,
|
||||
address=address
|
||||
|
@ -643,7 +646,7 @@ def accept_milestone_payout_request(proposal_id, milestone_id):
|
|||
@blueprint.route("/<proposal_id>/milestone/<milestone_id>/reject", methods=["PUT"])
|
||||
@requires_arbiter_auth
|
||||
@body({
|
||||
"reason": fields.Str(required=True, validate=lambda p: 2 <= len(p) <= 200),
|
||||
"reason": fields.Str(required=True, validate=validate.Length(min=2, max=200)),
|
||||
})
|
||||
def reject_milestone_payout_request(proposal_id, milestone_id, reason):
|
||||
if not g.current_proposal.is_funded:
|
||||
|
|
|
@ -64,8 +64,8 @@ class RFP(db.Model):
|
|||
assert Category.includes(category)
|
||||
self.id = gen_random_id(RFP)
|
||||
self.date_created = datetime.now()
|
||||
self.title = title
|
||||
self.brief = brief
|
||||
self.title = title[:255]
|
||||
self.brief = brief[:255]
|
||||
self.content = content
|
||||
self.category = category
|
||||
self.bounty = bounty
|
||||
|
|
|
@ -61,6 +61,7 @@ BLOCKCHAIN_API_SECRET = env.str("BLOCKCHAIN_API_SECRET")
|
|||
EXPLORER_URL = env.str("EXPLORER_URL", default="https://chain.so/tx/ZECTEST/<txid>")
|
||||
|
||||
PROPOSAL_STAKING_AMOUNT = Decimal(env.str("PROPOSAL_STAKING_AMOUNT"))
|
||||
PROPOSAL_TARGET_MAX = Decimal(env.str("PROPOSAL_TARGET_MAX"))
|
||||
|
||||
UI = {
|
||||
'NAME': 'ZF Grants',
|
||||
|
|
|
@ -46,8 +46,8 @@ class SocialMedia(db.Model):
|
|||
user_id = db.Column(db.Integer, db.ForeignKey("user.id"), nullable=False)
|
||||
|
||||
def __init__(self, service: str, username: str, user_id):
|
||||
self.service = service.upper()
|
||||
self.username = username.lower()
|
||||
self.service = service.upper()[:255]
|
||||
self.username = username.lower()[:255]
|
||||
self.user_id = user_id
|
||||
|
||||
|
||||
|
@ -145,8 +145,8 @@ class User(db.Model, UserMixin):
|
|||
):
|
||||
self.id = gen_random_id(User)
|
||||
self.email_address = email_address
|
||||
self.display_name = display_name
|
||||
self.title = title
|
||||
self.display_name = display_name[:255]
|
||||
self.title = title[:255]
|
||||
self.password = password
|
||||
|
||||
@staticmethod
|
||||
|
|
|
@ -3,6 +3,7 @@ from animal_case import keys_to_snake_case
|
|||
from flask import Blueprint, g, current_app
|
||||
from marshmallow import fields
|
||||
from validate_email import validate_email
|
||||
from webargs import validate
|
||||
|
||||
import grant.utils.auth as auth
|
||||
from grant.comment.models import Comment, user_comments_schema
|
||||
|
@ -95,8 +96,8 @@ def get_user(user_id, with_proposals, with_comments, with_funded, with_pending,
|
|||
@body({
|
||||
"emailAddress": fields.Str(required=True, validate=lambda e: validate_email(e)),
|
||||
"password": fields.Str(required=True),
|
||||
"displayName": fields.Str(required=True, validate=lambda p: 2 <= len(p) <= 200),
|
||||
"title": fields.Str(required=True, validate=lambda p: 2 <= len(p) <= 200),
|
||||
"displayName": fields.Str(required=True, validate=validate.Length(min=2, max=50)),
|
||||
"title": fields.Str(required=True, validate=validate.Length(min=2, max=50)),
|
||||
})
|
||||
def create_user(
|
||||
email_address,
|
||||
|
|
|
@ -128,7 +128,6 @@ class TestProposalCommentAPI(BaseUserConfig):
|
|||
)
|
||||
|
||||
self.assertStatus(comment_res, 400)
|
||||
self.assertIn('less than', comment_res.json['message'])
|
||||
|
||||
def test_create_new_proposal_comment_fails_with_silenced_user(self):
|
||||
self.login_default_user()
|
||||
|
|
|
@ -14,13 +14,26 @@ MINIMUM_BLOCK_CONFIRMATIONS="6"
|
|||
API_SECRET_HASH=""
|
||||
API_SECRET_KEY=""
|
||||
|
||||
############################ ADDRESS DERIVATION ############################
|
||||
# You should only set one OR the other. The former will generate addresses #
|
||||
# using Bip32 address derivation. The latter uses BitGo's API. If you set #
|
||||
# both, BitGo takes precedence. API key should ONLY HAVE VIEW ACCESS! #
|
||||
############################################################################
|
||||
|
||||
# BITGO_WALLET_ID=""
|
||||
# BITGO_ACCESS_TOKEN=""
|
||||
|
||||
### OR ###
|
||||
|
||||
# BIP32_XPUB=""
|
||||
|
||||
############################################################################
|
||||
|
||||
|
||||
# Addresses, run `yarn genaddress` to get sprout information
|
||||
SPROUT_ADDRESS=""
|
||||
SPROUT_VIEWKEY=""
|
||||
|
||||
# extended public seed
|
||||
BIP32_XPUB=""
|
||||
|
||||
# Block heights to fall back on for starting our scan
|
||||
MAINNET_START_BLOCK="464000"
|
||||
TESTNET_START_BLOCK="390000"
|
||||
|
@ -30,3 +43,6 @@ SENTRY_DSN=""
|
|||
|
||||
# Logging level
|
||||
LOG_LEVEL="debug"
|
||||
|
||||
# Fixie proxy URL for BitGo requests (optional)
|
||||
# FIXIE_URL=""
|
||||
|
|
|
@ -35,6 +35,7 @@
|
|||
"@types/dotenv": "^6.1.0",
|
||||
"@types/ws": "^6.0.1",
|
||||
"axios": "0.18.0",
|
||||
"bitgo": "4.48.1",
|
||||
"body-parser": "1.18.3",
|
||||
"cors": "2.8.5",
|
||||
"dotenv": "^6.1.0",
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
import node from '../node';
|
||||
import { extractErrMessage } from '../util';
|
||||
|
||||
async function printAddressAndKey() {
|
||||
try {
|
||||
|
@ -9,11 +10,7 @@ async function printAddressAndKey() {
|
|||
console.log(`SPROUT_ADDRESS="${address}"`);
|
||||
console.log(`SPROUT_VIEWKEY="${viewkey}"\n`);
|
||||
} catch(err) {
|
||||
if (err.response && err.response.data) {
|
||||
console.error(err.response.data);
|
||||
} else {
|
||||
console.error(err);
|
||||
}
|
||||
console.error(extractErrMessage(err));
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,57 @@
|
|||
import { BitGo, Wallet } from 'bitgo';
|
||||
import bitcore from "zcash-bitcore-lib";
|
||||
import env from './env';
|
||||
import log from './log';
|
||||
import { getNetwork } from './node';
|
||||
|
||||
let bitgoWallet: Wallet;
|
||||
|
||||
export async function initBitGo() {
|
||||
if (!env.BITGO_ACCESS_TOKEN || !env.BITGO_WALLET_ID) {
|
||||
log.info('BITGO environment variables not set, nooping initBitGo');
|
||||
return;
|
||||
}
|
||||
|
||||
// Assert that we're on mainnet
|
||||
const network = getNetwork();
|
||||
if (network !== bitcore.Networks.mainnet) {
|
||||
throw new Error(`BitGo cannot be used on anything but mainnet, connected node is ${network}`);
|
||||
}
|
||||
|
||||
const proxy = env.FIXIE_URL || undefined;
|
||||
const bitgo = new BitGo({
|
||||
env: 'prod', // Non-prod ZEC is not supported
|
||||
accessToken: env.BITGO_ACCESS_TOKEN,
|
||||
proxy,
|
||||
});
|
||||
bitgoWallet = await bitgo.coin('zec').wallets().get({ id: env.BITGO_WALLET_ID });
|
||||
log.info(`Initialized BitGo wallet "${bitgoWallet.label()}"`);
|
||||
if (proxy) {
|
||||
log.info(`Proxying BitGo requests through ${proxy}`);
|
||||
}
|
||||
}
|
||||
|
||||
export async function getContributionAddress(id: number) {
|
||||
if (!bitgoWallet) {
|
||||
throw new Error('Must run initBitGo before getContributionAddress');
|
||||
}
|
||||
|
||||
// Attempt to fetch first
|
||||
const label = `Contribution #${id}`;
|
||||
const res = await bitgoWallet.addresses({ labelContains: label });
|
||||
if (res.addresses.length) {
|
||||
if (res.addresses.length > 1) {
|
||||
log.warn(`Contribution ${id} has ${res.addresses.length} associated with it. Using the first one (${res.addresses[0].address})`);
|
||||
}
|
||||
return res.addresses[0].address;
|
||||
}
|
||||
|
||||
// Create a new one otherwise
|
||||
const createRes = await bitgoWallet.createAddress({ label });
|
||||
log.info(`Generate new address for contribution ${id}`);
|
||||
return createRes.address;
|
||||
}
|
||||
|
||||
function generateLabel(id: number) {
|
||||
return `Contribution #${id}`;
|
||||
}
|
|
@ -18,31 +18,55 @@ const DEFAULTS = {
|
|||
ZCASH_NODE_PASSWORD: "",
|
||||
MINIMUM_BLOCK_CONFIRMATIONS: "6",
|
||||
|
||||
BITGO_WALLET_ID: "",
|
||||
BITGO_ACCESS_TOKEN: "",
|
||||
|
||||
BIP32_XPUB: "",
|
||||
|
||||
SPROUT_ADDRESS: "",
|
||||
SPROUT_VIEWKEY: "",
|
||||
BIP32_XPUB: "",
|
||||
|
||||
MAINNET_START_BLOCK: "464000",
|
||||
TESTNET_START_BLOCK: "390000",
|
||||
|
||||
SENTRY_DSN: "",
|
||||
FIXIE_URL: "",
|
||||
};
|
||||
|
||||
const OPTIONAL: { [key: string]: undefined | boolean } = {
|
||||
BITGO_WALLET_ID: true,
|
||||
BITGO_ACCESS_TOKEN: true,
|
||||
BIP32_XPUB: true,
|
||||
FIXIE_URL: true,
|
||||
// NOTE: Remove these from optional when sapling is ready
|
||||
SPROUT_ADDRESS: true,
|
||||
SPROUT_VIEWKEY: true,
|
||||
}
|
||||
|
||||
type CustomEnvironment = typeof DEFAULTS;
|
||||
|
||||
// ignore when testing
|
||||
if (process.env.NODE_ENV !== "test") {
|
||||
// Set environment variables, throw on missing required ones
|
||||
Object.entries(DEFAULTS).forEach(([k, v]) => {
|
||||
if (!process.env[k]) {
|
||||
const defVal = (DEFAULTS as any)[k];
|
||||
if (defVal) {
|
||||
console.info(`Using default environment variable ${k}="${defVal}"`);
|
||||
process.env[k] = defVal;
|
||||
} else {
|
||||
} else if (!OPTIONAL[k]) {
|
||||
throw new Error(`Missing required environment variable ${k}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Ensure we have either xpub or bitgo, and warn if we have both
|
||||
if (!process.env.BIP32_XPUB && (!process.env.BITGO_WALLET_ID || !process.env.BITGO_ACCESS_TOKEN)) {
|
||||
throw new Error('Either BIP32_XPUB or BITGO_* environment variables required, missing both');
|
||||
}
|
||||
if (process.env.BIP32_XPUB && process.env.BITGO_WALLET_ID) {
|
||||
console.info('BIP32_XPUB and BITGO environment variables set, BIP32_XPUB will be ignored');
|
||||
}
|
||||
}
|
||||
|
||||
export default (process.env as any) as CustomEnvironment;
|
||||
|
|
|
@ -2,6 +2,8 @@ import * as Sentry from "@sentry/node";
|
|||
import * as Webhooks from "./webhooks";
|
||||
import * as RestServer from "./server";
|
||||
import { initNode } from "./node";
|
||||
import { initBitGo } from "./bitgo";
|
||||
import { extractErrMessage } from "./util";
|
||||
import env from "./env";
|
||||
import log from "./log";
|
||||
|
||||
|
@ -15,6 +17,7 @@ async function start() {
|
|||
|
||||
log.info("============== Starting services ==============");
|
||||
await initNode();
|
||||
await initBitGo();
|
||||
await RestServer.start();
|
||||
Webhooks.start();
|
||||
log.info("===============================================");
|
||||
|
@ -28,4 +31,8 @@ process.on("SIGINT", () => {
|
|||
process.exit();
|
||||
});
|
||||
|
||||
start();
|
||||
start().catch(err => {
|
||||
Sentry.captureException(err);
|
||||
log.error(`Unexpected error while starting blockchain watcher: ${extractErrMessage(err)}`);
|
||||
process.exit(1);
|
||||
});
|
||||
|
|
|
@ -3,6 +3,7 @@ import bitcore from "zcash-bitcore-lib";
|
|||
import { captureException } from "@sentry/node";
|
||||
import env from "./env";
|
||||
import log from "./log";
|
||||
import { extractErrMessage } from "./util";
|
||||
|
||||
export interface BlockChainInfo {
|
||||
chain: string;
|
||||
|
@ -16,9 +17,9 @@ export interface BlockChainInfo {
|
|||
export interface ScriptPubKey {
|
||||
asm: string;
|
||||
hex: string;
|
||||
reqSigs: number;
|
||||
type: string;
|
||||
addresses: string[];
|
||||
reqSigs?: number;
|
||||
addresses?: string[];
|
||||
}
|
||||
|
||||
export interface VIn {
|
||||
|
@ -49,6 +50,27 @@ export interface Transaction {
|
|||
vjoinsplit: any[];
|
||||
}
|
||||
|
||||
export interface RawTransaction {
|
||||
txid: string;
|
||||
hex: string;
|
||||
overwintered: boolean;
|
||||
version: number;
|
||||
versiongroupid: number;
|
||||
locktime: number;
|
||||
expiryheight: string;
|
||||
vin: VIn[];
|
||||
vout: VOut[];
|
||||
valueBalance: string;
|
||||
blockhash: string;
|
||||
blocktime: number;
|
||||
confirmations: number;
|
||||
time: number;
|
||||
// unclear what these are
|
||||
vjoinsplit: any[];
|
||||
vShieldedSpend: any[];
|
||||
vShieldedOutput: any[];
|
||||
}
|
||||
|
||||
export interface Block {
|
||||
hash: string;
|
||||
confirmations: number;
|
||||
|
@ -119,6 +141,10 @@ interface ZCashNode {
|
|||
(numberOrHash: string | number, verbosity: 0): Promise<string>;
|
||||
};
|
||||
gettransaction: (txid: string) => Promise<Transaction>;
|
||||
getrawtransaction: {
|
||||
(numberOrHash: string | number, verbosity: 1): Promise<RawTransaction>;
|
||||
(numberOrHash: string | number, verbosity?: 0): Promise<string>;
|
||||
};
|
||||
validateaddress: (address: string) => Promise<ValidationResponse>;
|
||||
z_getbalance: (address: string, minConf?: number) => Promise<number>;
|
||||
z_getnewaddress: (type?: "sprout" | "sapling") => Promise<string>;
|
||||
|
@ -166,31 +192,29 @@ export async function initNode() {
|
|||
}
|
||||
} catch (err) {
|
||||
captureException(err);
|
||||
log.error(err.response ? err.response.data : err);
|
||||
log.error(
|
||||
"Failed to connect to zcash node with the following credentials:\r\n",
|
||||
rpcOptions
|
||||
);
|
||||
log.error(extractErrMessage(err));
|
||||
log.error(`Failed to connect to zcash node with the following credentials: ${JSON.stringify(rpcOptions, null, 2)}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Check if sprout address is readable
|
||||
try {
|
||||
if (!env.SPROUT_ADDRESS) {
|
||||
console.error("Missing SPROUT_ADDRESS environment variable, exiting");
|
||||
process.exit(1);
|
||||
}
|
||||
await node.z_getbalance(env.SPROUT_ADDRESS as string);
|
||||
} catch (err) {
|
||||
if (!env.SPROUT_VIEWKEY) {
|
||||
log.error(
|
||||
"Unable to view SPROUT_ADDRESS and missing SPROUT_VIEWKEY environment variable, exiting"
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
await node.z_importviewingkey(env.SPROUT_VIEWKEY as string);
|
||||
await node.z_getbalance(env.SPROUT_ADDRESS as string);
|
||||
}
|
||||
// NOTE: Replace with sapling when ready
|
||||
// try {
|
||||
// if (!env.SPROUT_ADDRESS) {
|
||||
// console.error("Missing SPROUT_ADDRESS environment variable, exiting");
|
||||
// process.exit(1);
|
||||
// }
|
||||
// await node.z_getbalance(env.SPROUT_ADDRESS as string);
|
||||
// } catch (err) {
|
||||
// if (!env.SPROUT_VIEWKEY) {
|
||||
// log.error(
|
||||
// "Unable to view SPROUT_ADDRESS and missing SPROUT_VIEWKEY environment variable, exiting"
|
||||
// );
|
||||
// process.exit(1);
|
||||
// }
|
||||
// await node.z_importviewingkey(env.SPROUT_VIEWKEY as string);
|
||||
// await node.z_getbalance(env.SPROUT_ADDRESS as string);
|
||||
// }
|
||||
}
|
||||
|
||||
export function getNetwork() {
|
||||
|
@ -204,29 +228,27 @@ export function getNetwork() {
|
|||
export async function getBootstrapBlockHeight(txid: string | undefined) {
|
||||
if (txid) {
|
||||
try {
|
||||
const tx = await node.gettransaction(txid);
|
||||
const tx = await node.getrawtransaction(txid, 1);
|
||||
const block = await node.getblock(tx.blockhash);
|
||||
const height =
|
||||
block.height - parseInt(env.MINIMUM_BLOCK_CONFIRMATIONS, 10);
|
||||
return height.toString();
|
||||
} catch (err) {
|
||||
console.warn(
|
||||
`Attempted to get block height for tx ${txid} but failed with the following error:\n`,
|
||||
err
|
||||
);
|
||||
console.warn("Falling back to hard-coded starter blocks");
|
||||
log.warn(`Attempted to get block height for tx ${txid} but failed with the following error: ${extractErrMessage(err)}`);
|
||||
}
|
||||
}
|
||||
|
||||
// If we can't find the latest tx block, fall back to when the grant
|
||||
// system first launched, and scan from there.
|
||||
// system first launched, and scan from there. Regtest or unknown networks
|
||||
// start from the bottom.
|
||||
const net = getNetwork();
|
||||
let height = "0";
|
||||
if (net === bitcore.Networks.mainnet) {
|
||||
return env.MAINNET_START_BLOCK;
|
||||
height = env.MAINNET_START_BLOCK;
|
||||
} else if (net === bitcore.Networks.testnet && !net.regtestEnabled) {
|
||||
return env.TESTNET_START_BLOCK;
|
||||
height = env.TESTNET_START_BLOCK;
|
||||
}
|
||||
|
||||
// Regtest or otherwise unknown networks should start at the bottom
|
||||
return "0";
|
||||
log.info(`Falling back to hard-coded starter block height ${height}`);
|
||||
return height;
|
||||
}
|
||||
|
|
|
@ -14,7 +14,7 @@ import {
|
|||
} from '../store';
|
||||
import env from '../env';
|
||||
import node, { getBootstrapBlockHeight } from '../node';
|
||||
import { makeContributionMemo } from '../util';
|
||||
import { makeContributionMemo, extractErrMessage } from '../util';
|
||||
import log from '../log';
|
||||
|
||||
// Configure server
|
||||
|
@ -29,8 +29,16 @@ app.use(authMiddleware);
|
|||
// Routes
|
||||
app.post('/bootstrap', async (req, res) => {
|
||||
const { pendingContributions, latestTxId } = req.body;
|
||||
const info = await node.getblockchaininfo();
|
||||
const startHeight = await getBootstrapBlockHeight(latestTxId);
|
||||
|
||||
let info;
|
||||
let startHeight;
|
||||
try {
|
||||
info = await node.getblockchaininfo();
|
||||
startHeight = await getBootstrapBlockHeight(latestTxId);
|
||||
} catch(err) {
|
||||
log.error(`Unknown node error during bootstrap: ${extractErrMessage(err)}`);
|
||||
return res.status(500).json({ error: 'Unknown zcash node error' });
|
||||
}
|
||||
|
||||
console.info('Bootstrapping watcher!');
|
||||
console.info(' * Start height:', startHeight);
|
||||
|
@ -39,11 +47,18 @@ app.post('/bootstrap', async (req, res) => {
|
|||
console.info('Generating addresses to watch for each contribution...');
|
||||
|
||||
// Running generate address on each will add each contribution to redux state
|
||||
pendingContributions.forEach((c: any) => {
|
||||
store.dispatch(generateAddresses(c.id));
|
||||
});
|
||||
console.info(`Done! Generated ${pendingContributions.length} addresses.`);
|
||||
store.dispatch(setStartingBlockHeight(startHeight));
|
||||
try {
|
||||
const dispatchers = pendingContributions.map(async (c: any) => {
|
||||
const action = await generateAddresses(c.id);
|
||||
store.dispatch(action);
|
||||
});
|
||||
await Promise.all(dispatchers);
|
||||
console.info(`Done! Generated ${pendingContributions.length} addresses.`);
|
||||
store.dispatch(setStartingBlockHeight(startHeight));
|
||||
} catch(err) {
|
||||
log.error(`Unknown error during bootstrap address generation: ${extractErrMessage(err)}`);
|
||||
return res.status(500).json({ error: 'Failed to generate addresses for contributions' });
|
||||
}
|
||||
|
||||
// Send back some basic info about where the chain is at
|
||||
res.json({
|
||||
|
@ -54,20 +69,29 @@ app.post('/bootstrap', async (req, res) => {
|
|||
});
|
||||
});
|
||||
|
||||
app.get('/contribution/addresses', (req, res) => {
|
||||
app.get('/contribution/addresses', async (req, res) => {
|
||||
const { contributionId } = req.query;
|
||||
let addresses = getAddressesByContributionId(store.getState(), contributionId)
|
||||
if (!addresses) {
|
||||
const action = generateAddresses(req.query.contributionId);
|
||||
addresses = action.payload.addresses;
|
||||
store.dispatch(action);
|
||||
try {
|
||||
const action = await generateAddresses(contributionId);
|
||||
addresses = action.payload.addresses;
|
||||
store.dispatch(action);
|
||||
} catch(err) {
|
||||
log.error(`Unknown error during address generation for contribution ${contributionId}: ${extractErrMessage(err)}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (addresses) {
|
||||
res.json({
|
||||
data: {
|
||||
...addresses,
|
||||
memo: makeContributionMemo(contributionId),
|
||||
},
|
||||
});
|
||||
} else {
|
||||
res.status(500).json({ error: 'Failed to generate addresses' });
|
||||
}
|
||||
res.json({
|
||||
data: {
|
||||
...addresses,
|
||||
memo: makeContributionMemo(contributionId),
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
|
@ -96,7 +120,7 @@ app.post('/contribution/disclosure', async (req, res) => {
|
|||
return res.status(400).json({ error: err.response.data.error.message });
|
||||
}
|
||||
else {
|
||||
log.error('Unknown node error:', err.response ? err.response.data : err);
|
||||
log.error(`Unknown node error during disclosure: ${extractErrMessage(err)}`);
|
||||
return res.status(500).json({ error: 'Unknown zcash node error' });
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
import { captureException } from "@sentry/node";
|
||||
import { Request, Response, NextFunction } from 'express';
|
||||
import log from "../../log";
|
||||
import { extractErrMessage } from "../../util";
|
||||
|
||||
export default function errorHandler(err: Error, req: Request, res: Response, next: NextFunction) {
|
||||
// Non-error responses, or something else handled & responded
|
||||
|
@ -9,7 +10,7 @@ export default function errorHandler(err: Error, req: Request, res: Response, ne
|
|||
}
|
||||
|
||||
captureException(err);
|
||||
log.error(`Uncaught ${err.name} exception at ${req.method} ${req.path}: ${err.message}`);
|
||||
log.error(`Uncaught ${err.name} exception at ${req.method} ${req.path}: ${extractErrMessage(err)}`);
|
||||
log.debug(`Query: ${JSON.stringify(req.query, null, 2)}`);
|
||||
log.debug(`Body: ${JSON.stringify(req.body, null, 2)}`);
|
||||
log.debug(`Full stacktrace:\n${err.stack}`);
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
import type, { AddressCollection } from './types';
|
||||
import { deriveTransparentAddress } from '../util';
|
||||
import { getNetwork } from '../node';
|
||||
import { getContributionAddress } from '../bitgo';
|
||||
import env from '../env';
|
||||
|
||||
export function setStartingBlockHeight(height: string | number) {
|
||||
|
@ -10,10 +11,16 @@ export function setStartingBlockHeight(height: string | number) {
|
|||
}
|
||||
}
|
||||
|
||||
export function generateAddresses(contributionId: number) {
|
||||
// 2^31 is the maximum number of BIP32 addresses
|
||||
export async function generateAddresses(contributionId: number) {
|
||||
let transparent;
|
||||
if (env.BITGO_WALLET_ID) {
|
||||
transparent = await getContributionAddress(contributionId);
|
||||
} else {
|
||||
transparent = deriveTransparentAddress(contributionId, getNetwork());
|
||||
}
|
||||
|
||||
const addresses: AddressCollection = {
|
||||
transparent: deriveTransparentAddress(contributionId, getNetwork()),
|
||||
transparent,
|
||||
sprout: env.SPROUT_ADDRESS,
|
||||
};
|
||||
return {
|
||||
|
@ -45,8 +52,9 @@ export function confirmPaymentDisclosure(contributionId: number, disclosure: str
|
|||
};
|
||||
}
|
||||
|
||||
type UnwrapPromise<T> = T extends Promise<infer U> ? U : T;
|
||||
export type ActionTypes =
|
||||
| ReturnType<typeof setStartingBlockHeight>
|
||||
| ReturnType<typeof generateAddresses>
|
||||
| UnwrapPromise<ReturnType<typeof generateAddresses>>
|
||||
| ReturnType<typeof addPaymentDisclosure>
|
||||
| ReturnType<typeof confirmPaymentDisclosure>;
|
||||
|
|
|
@ -73,3 +73,15 @@ export function sleep(ms: number) {
|
|||
setTimeout(resolve, ms);
|
||||
});
|
||||
}
|
||||
|
||||
// They come in all shapes and sizes, and nested data can get truncated as
|
||||
// [object Object], so try to extract the best parts available.
|
||||
export function extractErrMessage(err: any) {
|
||||
if (err.response && err.response.data) {
|
||||
if (err.response.data.error && err.response.data.error.message) {
|
||||
return err.response.data.error.message;
|
||||
}
|
||||
return JSON.stringify(err.response.data, null, 2);
|
||||
}
|
||||
return err.message || err.toString();
|
||||
}
|
|
@ -5,13 +5,13 @@ import { Notifier } from "./notifiers/notifier";
|
|||
import node from "../node";
|
||||
import env from "../env";
|
||||
import { store } from "../store";
|
||||
import { sleep } from "../util";
|
||||
import { sleep, extractErrMessage } from "../util";
|
||||
import log from "../log";
|
||||
|
||||
let blockScanTimeout: any = null;
|
||||
let notifiers = [] as Notifier[];
|
||||
let consecutiveBlockFailures = 0;
|
||||
const MAXIMUM_BLOCK_FAILURES = 5;
|
||||
const MAXIMUM_BLOCK_FAILURES = 10;
|
||||
const MIN_BLOCK_CONF = parseInt(env.MINIMUM_BLOCK_CONFIRMATIONS, 10);
|
||||
|
||||
export async function start() {
|
||||
|
@ -38,7 +38,7 @@ function initScan() {
|
|||
store.subscribe(() => {
|
||||
const { startingBlockHeight } = store.getState();
|
||||
if (startingBlockHeight !== null && prevHeight !== startingBlockHeight) {
|
||||
console.info(`Starting block scan at block ${startingBlockHeight}`);
|
||||
log.info(`Starting block scan at block ${startingBlockHeight}`);
|
||||
clearTimeout(blockScanTimeout);
|
||||
scanBlock(startingBlockHeight);
|
||||
prevHeight = startingBlockHeight;
|
||||
|
@ -47,25 +47,25 @@ function initScan() {
|
|||
}
|
||||
|
||||
async function scanBlock(height: number) {
|
||||
const highestBlock = await node.getblockcount();
|
||||
|
||||
// Try again in 5 seconds if the next block isn't ready
|
||||
if (height > highestBlock - MIN_BLOCK_CONF) {
|
||||
blockScanTimeout = setTimeout(() => {
|
||||
scanBlock(height);
|
||||
}, 5000);
|
||||
return;
|
||||
}
|
||||
|
||||
// Process the block
|
||||
try {
|
||||
// Fetch the current block height, try again in 5 seconds if the next
|
||||
// block doesn't meet our confirmation requirement
|
||||
const highestBlock = await node.getblockcount();
|
||||
if (height > highestBlock - MIN_BLOCK_CONF) {
|
||||
blockScanTimeout = setTimeout(() => {
|
||||
scanBlock(height);
|
||||
}, 5000);
|
||||
return;
|
||||
}
|
||||
|
||||
// Process the block, then try the next one
|
||||
const block = await node.getblock(String(height), 2); // 2 == full blocks
|
||||
log.info(`Processing block #${block.height}...`);
|
||||
notifiers.forEach(n => n.onNewBlock && n.onNewBlock(block));
|
||||
consecutiveBlockFailures = 0;
|
||||
scanBlock(height + 1);
|
||||
} catch(err) {
|
||||
log.warn(err.response ? err.response.data : err);
|
||||
log.warn(`Failed to fetch block ${height}, see above error`);
|
||||
log.warn(`Failed to fetch block ${height}: ${extractErrMessage(err)}`);
|
||||
consecutiveBlockFailures++;
|
||||
// If we fail a certain number of times, it's reasonable to
|
||||
// assume that the blockchain is down, and we should just quit.
|
||||
|
@ -75,13 +75,12 @@ async function scanBlock(height: number) {
|
|||
process.exit(1);
|
||||
}
|
||||
else {
|
||||
log.warn('Attempting to fetch again shortly...');
|
||||
await sleep(5000);
|
||||
log.warn('Attempting to fetch again in 60 seconds...');
|
||||
await sleep(60000);
|
||||
}
|
||||
// Try same block again
|
||||
scanBlock(height);
|
||||
}
|
||||
|
||||
// Try next block
|
||||
scanBlock(height + 1);
|
||||
}
|
||||
|
||||
function initNotifiers() {
|
||||
|
@ -94,8 +93,7 @@ async function requestBootstrap() {
|
|||
log.debug('Requesting bootstrap from backend...');
|
||||
await send('/blockchain/bootstrap', 'GET');
|
||||
} catch(err) {
|
||||
log.error(err.response ? err.response.data : err);
|
||||
log.error('Request for bootstrap failed, see above for details');
|
||||
log.error(`Request for bootstrap failed: ${extractErrMessage(err)}`);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -126,7 +124,6 @@ const send: Send = (route, method, payload) => {
|
|||
return;
|
||||
}
|
||||
captureException(err);
|
||||
const errMsg = err.response ? `Response: ${JSON.stringify(err.response.data, null, 2)}` : err.message;
|
||||
log.error(`Webhook server request to ${method} ${route} failed: ${errMsg}`);
|
||||
log.error(`Webhook server request to ${method} ${route} failed: ${extractErrMessage(err)}`);
|
||||
});
|
||||
};
|
||||
|
|
|
@ -10,7 +10,7 @@ import {
|
|||
} from "../../../store";
|
||||
import env from "../../../env";
|
||||
import log from "../../../log";
|
||||
import { getContributionIdFromMemo, decodeHexMemo, toBaseUnit } from "../../../util";
|
||||
import { getContributionIdFromMemo, decodeHexMemo, toBaseUnit, extractErrMessage } from "../../../util";
|
||||
|
||||
interface ContributionConfirmationPayload {
|
||||
to: string;
|
||||
|
@ -26,8 +26,9 @@ export default class ContributionNotifier implements Notifier {
|
|||
|
||||
onNewBlock = (block: BlockWithTransactions) => {
|
||||
this.checkBlockForTransparentPayments(block);
|
||||
this.checkForMemoPayments();
|
||||
this.checkDisclosuresForPayment(block);
|
||||
// NOTE: Re-enable when sapling is ready
|
||||
// this.checkForMemoPayments();
|
||||
// this.checkDisclosuresForPayment(block);
|
||||
};
|
||||
|
||||
registerSend = (sm: Send) => (this.send = sm);
|
||||
|
@ -41,6 +42,11 @@ export default class ContributionNotifier implements Notifier {
|
|||
|
||||
block.tx.forEach(tx => {
|
||||
tx.vout.forEach(vout => {
|
||||
// Some vouts are not transactions with addresses, ignore those
|
||||
if (!vout.scriptPubKey.addresses) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Addresses is an array because of multisigs, but we'll never
|
||||
// generate one, so all of our addresses will only have addresses[0]
|
||||
const to = vout.scriptPubKey.addresses[0];
|
||||
|
@ -91,7 +97,7 @@ export default class ContributionNotifier implements Notifier {
|
|||
captureException(err);
|
||||
log.error(
|
||||
'Failed to check sprout address for memo payments:\n',
|
||||
err.response ? err.response.data : err,
|
||||
extractErrMessage(err),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
@ -131,9 +137,9 @@ export default class ContributionNotifier implements Notifier {
|
|||
store.dispatch(confirmPaymentDisclosure(contributionId, disclosure));
|
||||
} catch(err) {
|
||||
captureException(err);
|
||||
log.error(
|
||||
log.warn(
|
||||
'Encountered an error while checking disclosure:\n',
|
||||
err.response ? err.response.data : err,
|
||||
extractErrMessage(err),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
|
|
@ -50,7 +50,8 @@
|
|||
// https://www.npmjs.com/package/ts-node#help-my-types-are-missing
|
||||
"paths": {
|
||||
"stdrpc": ["types/stdrpc"],
|
||||
"zcash-bitcore-lib": ["types/zcash-bitcore-lib"]
|
||||
"zcash-bitcore-lib": ["types/zcash-bitcore-lib"],
|
||||
"bitgo": ["types/bitgo"]
|
||||
},
|
||||
|
||||
/* Source Map Options */
|
||||
|
|
|
@ -0,0 +1,71 @@
|
|||
// Adapted from documentation here: https://www.bitgo.com/api/v2/?javascript
|
||||
// Far from exhaustive, only functions used are properly typed.
|
||||
|
||||
declare module 'bitgo' {
|
||||
// Wallet
|
||||
interface CreateAddressOptions {
|
||||
label: string;
|
||||
}
|
||||
|
||||
interface GetAddressesOptions {
|
||||
labelContains?: string;
|
||||
limit?: number;
|
||||
mine?: boolean;
|
||||
prevId?: string;
|
||||
chains?: number[];
|
||||
sort?: 1 | -1;
|
||||
}
|
||||
|
||||
interface AddressInfo {
|
||||
id: string;
|
||||
address: string;
|
||||
chain: number;
|
||||
index: number;
|
||||
coin: string;
|
||||
lastNonce: number;
|
||||
wallet: string;
|
||||
label: string;
|
||||
addressType: string;
|
||||
}
|
||||
|
||||
interface GetAddressResponse {
|
||||
coin: string;
|
||||
totalAddressCount: number;
|
||||
pendingAddressCount: number;
|
||||
addresses: AddressInfo[];
|
||||
nextBatchPrevId: string;
|
||||
}
|
||||
|
||||
export class Wallet {
|
||||
id(): string;
|
||||
label(): string;
|
||||
createAddress(options?: CreateAddressOptions): Promise<AddressInfo>;
|
||||
addresses(options?: GetAddressesOptions): Promise<GetAddressResponse>;
|
||||
}
|
||||
|
||||
// Wallets
|
||||
interface GetWalletOptions {
|
||||
id: string;
|
||||
}
|
||||
|
||||
export class Wallets {
|
||||
get(options: GetWalletOptions): Promise<Wallet>;
|
||||
}
|
||||
|
||||
// BaseCoin
|
||||
export class BaseCoin {
|
||||
wallets(): Wallets;
|
||||
}
|
||||
|
||||
// BitGo
|
||||
interface BitGoOptions {
|
||||
env: 'test' | 'prod';
|
||||
accessToken: string;
|
||||
proxy?: string;
|
||||
}
|
||||
|
||||
export class BitGo {
|
||||
constructor(options: BitGoOptions);
|
||||
coin(coin: string): BaseCoin;
|
||||
}
|
||||
}
|
1430
blockchain/yarn.lock
1430
blockchain/yarn.lock
File diff suppressed because it is too large
Load Diff
|
@ -16,7 +16,7 @@ BACKEND_URL=http://localhost:5000
|
|||
# EXPLORER_URL="https://chain.so/tx/ZEC/<txid>"
|
||||
EXPLORER_URL="https://chain.so/tx/ZECTEST/<txid>"
|
||||
|
||||
# Amount for staking a proposal in ZEC
|
||||
# Amount for staking a proposal in ZEC, keep in sync with backend .env
|
||||
PROPOSAL_STAKING_AMOUNT=0.025
|
||||
|
||||
# Normally production runs with SSL, this disables that
|
||||
|
@ -24,3 +24,6 @@ DISABLE_SSL=true
|
|||
|
||||
# Uncomment if running on testnet
|
||||
# TESTNET=true
|
||||
|
||||
# Maximum amount for a proposal target, keep in sync with backend .env
|
||||
PROPOSAL_TARGET_MAX=10000
|
||||
|
|
|
@ -36,6 +36,7 @@ class SignUp extends React.Component<Props> {
|
|||
name="name"
|
||||
placeholder="Non-unique name that others will see you as"
|
||||
autoComplete="name"
|
||||
maxLength={50}
|
||||
/>,
|
||||
)}
|
||||
</Form.Item>
|
||||
|
@ -47,6 +48,7 @@ class SignUp extends React.Component<Props> {
|
|||
<Input
|
||||
name="title"
|
||||
placeholder="A short description about you, e.g. Core Ethereum Developer"
|
||||
maxLength={50}
|
||||
/>,
|
||||
)}
|
||||
</Form.Item>
|
||||
|
@ -62,6 +64,7 @@ class SignUp extends React.Component<Props> {
|
|||
name="email"
|
||||
placeholder="We promise not to spam you or share your email"
|
||||
autoComplete="username"
|
||||
maxLength={255}
|
||||
/>,
|
||||
)}
|
||||
</Form.Item>
|
||||
|
|
|
@ -68,6 +68,12 @@ class CreateFlowBasics extends React.Component<Props, State> {
|
|||
const { title, brief, category, target, rfp, rfpOptIn } = this.state;
|
||||
const errors = getCreateErrors(this.state, true);
|
||||
|
||||
// Don't show target error at zero since it defaults to that
|
||||
// Error just shows up at the end to prevent submission
|
||||
if (target === '0') {
|
||||
errors.target = undefined;
|
||||
}
|
||||
|
||||
const rfpOptInRequired =
|
||||
rfp && (rfp.matching || (rfp.bounty && new BN(rfp.bounty).gtn(0)));
|
||||
|
||||
|
@ -147,6 +153,7 @@ class CreateFlowBasics extends React.Component<Props, State> {
|
|||
type="text"
|
||||
value={title}
|
||||
onChange={this.handleInputChange}
|
||||
maxLength={200}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
|
@ -161,6 +168,7 @@ class CreateFlowBasics extends React.Component<Props, State> {
|
|||
value={brief}
|
||||
onChange={this.handleInputChange}
|
||||
rows={3}
|
||||
maxLength={200}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
|
@ -196,6 +204,7 @@ class CreateFlowBasics extends React.Component<Props, State> {
|
|||
value={target}
|
||||
onChange={this.handleInputChange}
|
||||
addonAfter="ZEC"
|
||||
maxLength={16}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
|
|
|
@ -1,7 +1,8 @@
|
|||
import React from 'react';
|
||||
import { Form } from 'antd';
|
||||
import { Form, Alert } from 'antd';
|
||||
import MarkdownEditor from 'components/MarkdownEditor';
|
||||
import { ProposalDraft } from 'types';
|
||||
import { getCreateErrors } from 'modules/create/utils';
|
||||
|
||||
interface State {
|
||||
content: string;
|
||||
|
@ -22,6 +23,8 @@ export default class CreateFlowTeam extends React.Component<Props, State> {
|
|||
}
|
||||
|
||||
render() {
|
||||
const errors = getCreateErrors(this.state, true);
|
||||
|
||||
return (
|
||||
<Form layout="vertical" style={{ maxWidth: 980, margin: '0 auto' }}>
|
||||
<MarkdownEditor
|
||||
|
@ -29,6 +32,7 @@ export default class CreateFlowTeam extends React.Component<Props, State> {
|
|||
initialMarkdown={this.state.content}
|
||||
minHeight={200}
|
||||
/>
|
||||
{errors.content && <Alert type="error" message={errors.content} showIcon />}
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -124,6 +124,7 @@ const MilestoneFields = ({
|
|||
name="title"
|
||||
value={milestone.title}
|
||||
onChange={ev => onChange(index, { ...milestone, title: ev.currentTarget.value })}
|
||||
maxLength={80}
|
||||
/>
|
||||
<button
|
||||
onClick={() => onRemove(index)}
|
||||
|
@ -147,6 +148,7 @@ const MilestoneFields = ({
|
|||
onChange={ev =>
|
||||
onChange(index, { ...milestone, content: ev.currentTarget.value })
|
||||
}
|
||||
maxLength={255}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
@ -190,6 +192,7 @@ const MilestoneFields = ({
|
|||
}
|
||||
addonAfter="%"
|
||||
style={{ maxWidth: '120px', width: '100%' }}
|
||||
maxLength={6}
|
||||
/>
|
||||
{index === 0 && (
|
||||
<div style={{ display: 'flex', alignItems: 'center', marginLeft: '0.5rem' }}>
|
||||
|
|
|
@ -138,6 +138,10 @@
|
|||
font-size: 0.8rem;
|
||||
opacity: 0.3;
|
||||
animation: draft-notification-popup 120ms ease 1;
|
||||
|
||||
&.is-error {
|
||||
color: @error-color;
|
||||
}
|
||||
}
|
||||
|
||||
&-loading {
|
||||
|
|
|
@ -103,6 +103,7 @@ interface StateProps {
|
|||
form: AppState['create']['form'];
|
||||
isSavingDraft: AppState['create']['isSavingDraft'];
|
||||
hasSavedDraft: AppState['create']['hasSavedDraft'];
|
||||
saveDraftError: AppState['create']['saveDraftError'];
|
||||
}
|
||||
|
||||
interface DispatchProps {
|
||||
|
@ -149,7 +150,7 @@ class CreateFlow extends React.Component<Props, State> {
|
|||
}
|
||||
|
||||
render() {
|
||||
const { isSavingDraft } = this.props;
|
||||
const { isSavingDraft, saveDraftError } = this.props;
|
||||
const { step, isPreviewing, isSubmitting, isShowingSubmitWarning } = this.state;
|
||||
|
||||
const info = STEP_INFO[step];
|
||||
|
@ -238,8 +239,16 @@ class CreateFlow extends React.Component<Props, State> {
|
|||
)}
|
||||
</div>
|
||||
)}
|
||||
{isSavingDraft && (
|
||||
{isSavingDraft ? (
|
||||
<div className="CreateFlow-draftNotification">Saving draft...</div>
|
||||
) : (
|
||||
saveDraftError && (
|
||||
<div className="CreateFlow-draftNotification is-error">
|
||||
Failed to save draft!
|
||||
<br />
|
||||
{saveDraftError}
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
<SubmitWarningModal
|
||||
proposal={this.props.form}
|
||||
|
@ -326,13 +335,12 @@ class CreateFlow extends React.Component<Props, State> {
|
|||
}
|
||||
|
||||
const withConnect = connect<StateProps, DispatchProps, {}, AppState>(
|
||||
(state: AppState) => {
|
||||
return {
|
||||
form: state.create.form,
|
||||
isSavingDraft: state.create.isSavingDraft,
|
||||
hasSavedDraft: state.create.hasSavedDraft,
|
||||
};
|
||||
},
|
||||
(state: AppState) => ({
|
||||
form: state.create.form,
|
||||
isSavingDraft: state.create.isSavingDraft,
|
||||
hasSavedDraft: state.create.hasSavedDraft,
|
||||
saveDraftError: state.create.saveDraftError,
|
||||
}),
|
||||
{
|
||||
updateForm: createActions.updateForm,
|
||||
},
|
||||
|
|
|
@ -2,21 +2,36 @@ import React from 'react';
|
|||
import * as Sentry from '@sentry/browser';
|
||||
import { Button } from 'antd';
|
||||
import Exception from 'ant-design-pro/lib/Exception';
|
||||
import Loader from 'components/Loader';
|
||||
import './index.less';
|
||||
|
||||
interface Props {
|
||||
error: Error;
|
||||
}
|
||||
|
||||
const isChunkError = (err: Error) => {
|
||||
return err.message.includes('Loading chunk');
|
||||
};
|
||||
|
||||
export default class ErrorScreen extends React.PureComponent<Props> {
|
||||
componentDidMount() {
|
||||
const { error } = this.props;
|
||||
Sentry.captureException(error);
|
||||
console.error('Error screen showing due to the following error:', error);
|
||||
if (isChunkError(error)) {
|
||||
setTimeout(() => {
|
||||
window.location.reload();
|
||||
}, 500);
|
||||
} else {
|
||||
Sentry.captureException(error);
|
||||
console.error('Error screen showing due to the following error:', error);
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
const { error } = this.props;
|
||||
if (isChunkError(error)) {
|
||||
return <Loader size="large" />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="ErrorScreen">
|
||||
<Exception
|
||||
|
|
|
@ -48,6 +48,9 @@ const Footer: React.SFC<WithNamespaces> = ({ t }) => (
|
|||
</div>
|
||||
</div>
|
||||
<div className="Footer-social">
|
||||
<a className="Footer-social-link" href="https://zfnd.org/" target="_blank">
|
||||
Zcash Foundation <Icon type="home" />
|
||||
</a>
|
||||
<a
|
||||
className="Footer-social-link"
|
||||
href="https://twitter.com/zcashfoundation"
|
||||
|
|
|
@ -72,7 +72,7 @@ class HomeRequests extends React.Component<Props> {
|
|||
{t('home.requests.description')
|
||||
.split('\n')
|
||||
.map((s: string, idx: number) => (
|
||||
<p key={idx}>{s}</p>
|
||||
<p key={idx} dangerouslySetInnerHTML={{ __html: s }} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
@ -7,6 +7,9 @@
|
|||
margin-bottom: 1rem;
|
||||
|
||||
&-info {
|
||||
min-width: 0;
|
||||
padding-right: 2rem;
|
||||
|
||||
&-title {
|
||||
font-size: 1.2rem;
|
||||
font-weight: 600;
|
||||
|
|
|
@ -77,7 +77,7 @@ class RFPs extends React.Component<Props> {
|
|||
The Zcash Foundation periodically makes requests for proposals that solve
|
||||
high-priority needs in the Zcash ecosystem. These proposals will typically
|
||||
receive large or matched contributions, should they be approved by the
|
||||
foundation.
|
||||
Foundation.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
@ -13,8 +13,6 @@ import {
|
|||
PROPOSAL_DETAIL_INITIAL_STATE,
|
||||
} from 'modules/proposals/reducers';
|
||||
|
||||
export const TARGET_ZEC_LIMIT = 1000;
|
||||
|
||||
interface CreateFormErrors {
|
||||
rfpOptIn?: string;
|
||||
title?: string;
|
||||
|
@ -57,7 +55,17 @@ export function getCreateErrors(
|
|||
skipRequired?: boolean,
|
||||
): CreateFormErrors {
|
||||
const errors: CreateFormErrors = {};
|
||||
const { title, team, milestones, target, payoutAddress, rfp, rfpOptIn, brief } = form;
|
||||
const {
|
||||
title,
|
||||
content,
|
||||
team,
|
||||
milestones,
|
||||
target,
|
||||
payoutAddress,
|
||||
rfp,
|
||||
rfpOptIn,
|
||||
brief,
|
||||
} = form;
|
||||
|
||||
// Required fields with no extra validation
|
||||
if (!skipRequired) {
|
||||
|
@ -90,10 +98,16 @@ export function getCreateErrors(
|
|||
errors.brief = 'Brief can only be 140 characters maximum';
|
||||
}
|
||||
|
||||
// Content limit for our database's sake
|
||||
if (content && content.length > 250000) {
|
||||
errors.content = 'Details can only be 250,000 characters maximum';
|
||||
}
|
||||
|
||||
// Amount to raise
|
||||
const targetFloat = target ? parseFloat(target) : 0;
|
||||
if (target && !Number.isNaN(targetFloat)) {
|
||||
const targetErr = getAmountError(targetFloat, TARGET_ZEC_LIMIT);
|
||||
const limit = parseFloat(process.env.PROPOSAL_TARGET_MAX as string);
|
||||
const targetErr = getAmountError(targetFloat, limit, 1);
|
||||
if (targetErr) {
|
||||
errors.target = targetErr;
|
||||
}
|
||||
|
@ -134,6 +148,12 @@ export function getCreateErrors(
|
|||
return 'Payout percent is required';
|
||||
} else if (Number.isNaN(parseInt(ms.payoutPercent, 10))) {
|
||||
return 'Payout percent must be a valid number';
|
||||
} else if (parseInt(ms.payoutPercent, 10) !== parseFloat(ms.payoutPercent)) {
|
||||
return 'Payout percent must be a whole number, no decimals';
|
||||
} else if (parseInt(ms.payoutPercent, 10) <= 0) {
|
||||
return 'Payout percent must be greater than 0%';
|
||||
} else if (parseInt(ms.payoutPercent, 10) > 100) {
|
||||
return 'Payout percent must be less than or equal to 100%';
|
||||
}
|
||||
|
||||
// Last one shows percentage errors
|
||||
|
@ -155,10 +175,10 @@ export function getCreateErrors(
|
|||
}
|
||||
|
||||
export function validateUserProfile(user: User) {
|
||||
if (user.displayName.length > 30) {
|
||||
return 'Display name can only be 30 characters maximum';
|
||||
} else if (user.title.length > 30) {
|
||||
return 'Title can only be 30 characters maximum';
|
||||
if (user.displayName.length > 50) {
|
||||
return 'Display name can only be 50 characters maximum';
|
||||
} else if (user.title.length > 50) {
|
||||
return 'Title can only be 50 characters maximum';
|
||||
}
|
||||
|
||||
return '';
|
||||
|
|
|
@ -16,7 +16,7 @@
|
|||
|
||||
"requests": {
|
||||
"title": "Open Requests from the ZF",
|
||||
"description": "The Zcash Foundation will periodically open up requests for proposals that have financial incentives attached to them in the form of fixed bounties, or pledges of contribution matching.\nProposals will be reviewed and chosen based on the ZF’s confidence in the team and their plan.\nTo be eligible for funding from the Zcash Foundation, teams must provide identifying information for legal reasons.",
|
||||
"description": "The Zcash Foundation will periodically open up requests for proposals that have financial incentives attached to them in the form of fixed bounties, or pledges of contribution matching.\nProposals will be reviewed and chosen based on the ZF’s confidence in the team and their plan.\nTo be eligible for funding from the Zcash Foundation, teams must provide identifying information for legal reasons.\nIf none of the RFPs catch your eye, check out the <a href=\"https://www.zfnd.org/grants/#ideas\" target=\"_blank\">list of promising ideas<\/a>!",
|
||||
"more": "See all requests",
|
||||
"emptyTitle": "No open requests at this time",
|
||||
"emptySubtitle": "But don’t let that stop you! Proposals can be submitted at any time."
|
||||
|
|
|
@ -16,3 +16,20 @@
|
|||
div.antd-pro-ellipsis-ellipsis {
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
// List items with long content can push the actions aside
|
||||
.ant-list-item {
|
||||
overflow: hidden;
|
||||
|
||||
.ant-list-item-content,
|
||||
.ant-list-item-meta,
|
||||
.ant-list-item-meta-content {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.ant-list-item-meta-title,
|
||||
.ant-list-item-meta-description {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
}
|
|
@ -149,6 +149,10 @@ export function massageSerializedState(state: AppState) {
|
|||
);
|
||||
state.proposal.detail.contributionBounty = new BN((state.proposal.detail
|
||||
.contributionBounty as any) as string);
|
||||
state.proposal.detail.milestones = state.proposal.detail.milestones.map(m => ({
|
||||
...m,
|
||||
amount: new BN((m.amount as any) as string, 16),
|
||||
}));
|
||||
if (state.proposal.detail.rfp && state.proposal.detail.rfp.bounty) {
|
||||
state.proposal.detail.rfp.bounty = new BN(
|
||||
(state.proposal.detail.rfp.bounty as any) as string,
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
export function getAmountError(amount: number, max: number = Infinity) {
|
||||
export function getAmountError(amount: number, max: number = Infinity, min?: number) {
|
||||
if (amount < 0) {
|
||||
return 'Amount must be a positive number';
|
||||
} else if (
|
||||
|
@ -8,6 +8,8 @@ export function getAmountError(amount: number, max: number = Infinity) {
|
|||
return 'Must be in increments of 0.001';
|
||||
} else if (amount > max) {
|
||||
return `Cannot exceed maximum (${max} ZEC)`;
|
||||
} else if (min && amount < min) {
|
||||
return `Must be at least ${min} ZEC`;
|
||||
}
|
||||
|
||||
return null;
|
||||
|
|
|
@ -57,6 +57,7 @@ module.exports = () => {
|
|||
NODE_ENV: process.env.NODE_ENV || 'development',
|
||||
PORT: process.env.PORT || 3000,
|
||||
PROPOSAL_STAKING_AMOUNT: process.env.PROPOSAL_STAKING_AMOUNT,
|
||||
PROPOSAL_TARGET_MAX: process.env.PROPOSAL_TARGET_MAX || '10000',
|
||||
PUBLIC_HOST_URL: process.env.PUBLIC_HOST_URL,
|
||||
SENTRY_DSN: process.env.SENTRY_DSN || null,
|
||||
SENTRY_RELEASE: process.env.SENTRY_RELEASE || undefined,
|
||||
|
|
|
@ -29,7 +29,7 @@ const app = express();
|
|||
|
||||
// ssl
|
||||
if (!isDev && !process.env.DISABLE_SSL) {
|
||||
log.warn('PRODUCTION mode, enforcing HTTPS redirect');
|
||||
log.info('PRODUCTION mode, enforcing HTTPS redirect');
|
||||
app.use(enforce.HTTPS({ trustProtoHeader: true }));
|
||||
}
|
||||
|
||||
|
|
Loading…
Reference in New Issue