Merge pull request #417 from grant-project/develop

Release 1.1.0 (Unsquashed)
This commit is contained in:
William O'Beirne 2019-03-28 13:37:52 -04:00 committed by GitHub
commit 90fd44102c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
53 changed files with 2018 additions and 202 deletions

8
admin/.env.example Normal file
View File

@ -0,0 +1,8 @@
# admin listen port
PORT=3500
# backend url
BACKEND_URL=http://localhost:5000
# Disable SSL in production
# DISABLE_SSL=true

View File

@ -1,4 +0,0 @@
# admin listen port
PORT=3500
# backend url
BACKEND_URL=http://localhost:5000

View File

@ -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",

View File

@ -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) {

View File

@ -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"

View File

@ -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

View File

@ -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):

View File

@ -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)

View File

@ -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')

View File

@ -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

View File

@ -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')

View File

@ -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))

View File

@ -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:

View File

@ -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

View File

@ -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',

View File

@ -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

View File

@ -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,

View File

@ -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()

View File

@ -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=""

View File

@ -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",

View File

@ -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);
}
}

57
blockchain/src/bitgo.ts Normal file
View File

@ -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}`;
}

View File

@ -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;

View File

@ -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);
});

View File

@ -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;
}

View File

@ -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' });
}
}

View File

@ -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}`);

View File

@ -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>;

View File

@ -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();
}

View File

@ -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)}`);
});
};

View File

@ -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),
);
}
};

View File

@ -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 */

71
blockchain/types/bitgo.d.ts vendored Normal file
View File

@ -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;
}
}

File diff suppressed because it is too large Load Diff

View File

@ -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

View File

@ -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>

View File

@ -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>

View File

@ -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>
);
}

View File

@ -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' }}>

View File

@ -138,6 +138,10 @@
font-size: 0.8rem;
opacity: 0.3;
animation: draft-notification-popup 120ms ease 1;
&.is-error {
color: @error-color;
}
}
&-loading {

View File

@ -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,
},

View File

@ -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

View File

@ -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"

View File

@ -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>

View File

@ -7,6 +7,9 @@
margin-bottom: 1rem;
&-info {
min-width: 0;
padding-right: 2rem;
&-title {
font-size: 1.2rem;
font-weight: 600;

View File

@ -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>

View File

@ -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 '';

View File

@ -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 ZFs 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 ZFs 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 dont let that stop you! Proposals can be submitted at any time."

View File

@ -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;
}
}

View File

@ -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,

View File

@ -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;

View File

@ -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,

View File

@ -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 }));
}