Proposal staking (#134)

* BE: proposal/<id>/stake end-point basics

* BE: proposal staking tests

* add STAKING to ProposalStatusEnum

* BE: incremental staking related changes

* admin: staking status

* FE: proposal staking first pass

* ZCash -> Zcash spelling

* staking contribution confirmed email

* FE: staking related minor style changes

* FE proposal staking env var

* notify user of partially staked proposal contributions
This commit is contained in:
AMStrix 2019-01-31 16:56:16 -06:00 committed by William O'Beirne
parent 4091deaf2f
commit f8f3bd1707
39 changed files with 533 additions and 234 deletions

View File

@ -1,6 +1,6 @@
# Zcash Grant System
This is a collection of the various services and components that make up the ZCash Grant System.
This is a collection of the various services and components that make up the Zcash Grant System.
### Setup

View File

@ -21,6 +21,7 @@ export enum PROPOSAL_STATUS {
REJECTED = 'REJECTED',
LIVE = 'LIVE',
DELETED = 'DELETED',
STAKING = 'STAKING',
}
export interface Proposal {
proposalId: number;

View File

@ -52,6 +52,13 @@ export const PROPOSAL_STATUSES: Array<StatusSoT<PROPOSAL_STATUS>> = [
hint:
'Admin has rejected this proposal. User may adjust it and resubmit for approval.',
},
{
id: PROPOSAL_STATUS.STAKING,
filterDisplay: 'Status: staking',
tagDisplay: 'Staking',
tagColor: '#722ed1',
hint: 'This proposal is awaiting a staking contribution.',
},
];
export const RFP_STATUSES: Array<StatusSoT<RFP_STATUS>> = [

View File

@ -31,3 +31,6 @@ ADMIN_PASS_HASH=18f97883b93a975deb9e29257a341a447302040da59cdc2d10ff65a5e57cc197
# Blockchain explorer to link to. Top for mainnet, bottom for testnet.
# EXPLORER_URL="https://explorer.zcha.in/"
EXPLORER_URL="https://testnet.zcha.in/"
# Amount for staking a proposal in ZEC
PROPOSAL_STAKING_AMOUNT=0.025

View File

@ -106,6 +106,21 @@ def proposal_comment(email_args):
}
def staking_contribution_confirmed(email_args):
subject = 'Your proposal has been staked!' if \
email_args['fully_staked'] else \
'Partial staking contribution confirmed'
return {
'subject': subject,
'title': 'Staking contribution confirmed',
'preview': 'Your {} ZEC staking contribution to {} has been confirmed!'.format(
email_args['contribution'].amount,
email_args['proposal'].title
),
'subscription': EmailSubscription.MY_PROPOSAL_FUNDED,
}
def contribution_confirmed(email_args):
return {
'subject': 'Your contribution has been confirmed!',
@ -150,6 +165,7 @@ get_info_lookup = {
'proposal_rejected': proposal_rejected,
'proposal_contribution': proposal_contribution,
'proposal_comment': proposal_comment,
'staking_contribution_confirmed': staking_contribution_confirmed,
'contribution_confirmed': contribution_confirmed,
'contribution_update': contribution_update,
'comment_reply': comment_reply,

View File

@ -10,15 +10,17 @@ from grant.utils.exceptions import ValidationException
from grant.utils.misc import dt_to_unix, make_url
from grant.utils.requests import blockchain_get
from grant.utils.enums import ProposalStatus, ProposalStage, Category, ContributionStatus
from grant.settings import PROPOSAL_STAKING_AMOUNT
# Proposal states
DRAFT = 'DRAFT'
PENDING = 'PENDING'
STAKING = 'STAKING'
APPROVED = 'APPROVED'
REJECTED = 'REJECTED'
LIVE = 'LIVE'
DELETED = 'DELETED'
STATUSES = [DRAFT, PENDING, APPROVED, REJECTED, LIVE, DELETED]
STATUSES = [DRAFT, PENDING, STAKING, APPROVED, REJECTED, LIVE, DELETED]
# Funding stages
FUNDING_REQUIRED = 'FUNDING_REQUIRED'
@ -255,14 +257,44 @@ class Proposal(db.Model):
self.deadline_duration = deadline_duration
Proposal.validate(vars(self))
def create_contribution(self, user_id: int, amount: float):
contribution = ProposalContribution(
proposal_id=self.id,
user_id=user_id,
amount=amount
)
db.session.add(contribution)
db.session.commit()
return contribution
def get_staking_contribution(self, user_id: int):
contribution = None
remaining = PROPOSAL_STAKING_AMOUNT - float(self.contributed)
# check funding
if remaining > 0:
# find pending contribution for any user
# (always use full staking amout so we can find it)
contribution = ProposalContribution.query.filter_by(
proposal_id=self.id,
amount=str(PROPOSAL_STAKING_AMOUNT),
status=PENDING,
).first()
if not contribution:
contribution = self.create_contribution(user_id, PROPOSAL_STAKING_AMOUNT)
return contribution
def submit_for_approval(self):
self.validate_publishable()
allowed_statuses = [ProposalStatus.DRAFT, ProposalStatus.REJECTED]
# specific validation
if self.status not in allowed_statuses:
raise ValidationException(f"Proposal status must be draft or rejected to submit for approval")
self.status = ProposalStatus.PENDING
# set to PENDING if staked, else STAKING
if self.is_staked:
self.status = ProposalStatus.PENDING
else:
self.status = ProposalStatus.STAKING
def approve_pending(self, is_approve, reject_reason=None):
self.validate_publishable()
@ -321,6 +353,10 @@ class Proposal(db.Model):
return str(funded)
@hybrid_property
def is_staked(self):
return float(self.contributed) >= PROPOSAL_STAKING_AMOUNT
class ProposalSchema(ma.Schema):
class Meta:
@ -338,6 +374,7 @@ class ProposalSchema(ma.Schema):
"proposal_id",
"target",
"contributed",
"is_staked",
"funded",
"content",
"comments",
@ -383,6 +420,7 @@ user_fields = [
"title",
"brief",
"target",
"is_staked",
"funded",
"contribution_matching",
"date_created",

View File

@ -4,7 +4,7 @@ from flask_yoloapi import endpoint, parameter
from grant.comment.models import Comment, comment_schema, comments_schema
from grant.email.send import send_email
from grant.milestone.models import Milestone
from grant.settings import EXPLORER_URL
from grant.settings import EXPLORER_URL, PROPOSAL_STAKING_AMOUNT
from grant.user.models import User
from grant.utils.auth import requires_auth, requires_team_member_auth, get_authed_user, internal_webhook
from grant.utils.exceptions import ValidationException
@ -131,14 +131,14 @@ def get_proposals(stage):
if stage:
proposals = (
Proposal.query.filter_by(status=ProposalStatus.LIVE, stage=stage)
.order_by(Proposal.date_created.desc())
.all()
.order_by(Proposal.date_created.desc())
.all()
)
else:
proposals = (
Proposal.query.filter_by(status=ProposalStatus.LIVE)
.order_by(Proposal.date_created.desc())
.all()
.order_by(Proposal.date_created.desc())
.all()
)
dumped_proposals = proposals_schema.dump(proposals)
return dumped_proposals
@ -242,6 +242,18 @@ def submit_for_approval_proposal(proposal_id):
return proposal_schema.dump(g.current_proposal), 200
@blueprint.route("/<proposal_id>/stake", methods=["GET"])
@requires_team_member_auth
@endpoint.api()
def get_proposal_stake(proposal_id):
if g.current_proposal.status != ProposalStatus.STAKING:
return None, 400
contribution = g.current_proposal.get_staking_contribution(g.current_user.id)
if contribution:
return proposal_contribution_schema.dump(contribution)
return None, 404
@blueprint.route("/<proposal_id>/publish", methods=["PUT"])
@requires_team_member_auth
@endpoint.api()
@ -419,13 +431,7 @@ def post_proposal_contribution(proposal_id, amount):
if not contribution:
code = 201
contribution = ProposalContribution(
proposal_id=proposal_id,
user_id=g.current_user.id,
amount=amount
)
db.session.add(contribution)
db.session.commit()
contribution = proposal.create_contribution(g.current_user.id, amount)
dumped_contribution = proposal_contribution_schema.dump(contribution)
return dumped_contribution, code
@ -459,26 +465,43 @@ def post_contribution_confirmation(contribution_id, to, amount, txid):
db.session.add(contribution)
db.session.commit()
# Send to the user
send_email(contribution.user.email_address, 'contribution_confirmed', {
'contribution': contribution,
'proposal': contribution.proposal,
'tx_explorer_url': f'{EXPLORER_URL}transactions/{txid}',
})
if contribution.proposal.status == ProposalStatus.STAKING:
# fully staked, set status PENDING & notify user
if contribution.proposal.is_staked: # float(contribution.proposal.contributed) >= PROPOSAL_STAKING_AMOUNT:
contribution.proposal.status = ProposalStatus.PENDING
db.session.add(contribution.proposal)
db.session.commit()
# Send to the full proposal gang
for member in contribution.proposal.team:
send_email(member.email_address, 'proposal_contribution', {
'proposal': contribution.proposal,
# email progress of staking, partial or complete
send_email(contribution.user.email_address, 'staking_contribution_confirmed', {
'contribution': contribution,
'contributor': contribution.user,
'funded': contribution.proposal.funded,
'proposal_url': make_url(f'/proposals/{contribution.proposal.id}'),
'contributor_url': make_url(f'/profile/{contribution.user.id}'),
'proposal': contribution.proposal,
'tx_explorer_url': f'{EXPLORER_URL}transactions/{txid}',
'fully_staked': contribution.proposal.is_staked,
'stake_target': PROPOSAL_STAKING_AMOUNT
})
else:
# Send to the user
send_email(contribution.user.email_address, 'contribution_confirmed', {
'contribution': contribution,
'proposal': contribution.proposal,
'tx_explorer_url': f'{EXPLORER_URL}transactions/{txid}',
})
# Send to the full proposal gang
for member in contribution.proposal.team:
send_email(member.email_address, 'proposal_contribution', {
'proposal': contribution.proposal,
'contribution': contribution,
'contributor': contribution.user,
'funded': contribution.proposal.funded,
'proposal_url': make_url(f'/proposals/{contribution.proposal.id}'),
'contributor_url': make_url(f'/profile/{contribution.user.id}'),
})
# TODO: Once we have a task queuer in place, queue emails to everyone
# on funding target reached.
# on funding target reached.
return None, 200

View File

@ -54,8 +54,10 @@ ADMIN_PASS_HASH = env.str("ADMIN_PASS_HASH")
EXPLORER_URL = env.str("EXPLORER_URL", default="https://explorer.zcha.in/")
PROPOSAL_STAKING_AMOUNT = env.float("PROPOSAL_STAKING_AMOUNT")
UI = {
'NAME': 'ZF Grants',
'PRIMARY': '#CF8A00',
'SECONDARY': '#2D2A26',
'NAME': 'ZF Grants',
'PRIMARY': '#CF8A00',
'SECONDARY': '#2D2A26',
}

View File

@ -1,20 +1,16 @@
<p style="margin: 0 0 20px;">
Your <strong>{{ args.contribution.amount }} ZEC</strong> contribution has
been confirmed! <strong>{{ args.proposal.title}}</strong> has been updated
to reflect your funding, and your account will now show your contribution.
You can view your transaction below:
Your <strong>{{ args.contribution.amount }} ZEC</strong> contribution has been
confirmed! <strong>{{ args.proposal.title }}</strong> has been updated to
reflect your funding, and your account will now show your contribution. You
can view your transaction below:
</p>
<p style="margin: 0 0 20px;">
<a
href="{{ args.tx_explorer_url }}"
target="_blank"
rel="nofollow noopener"
>
{{ args.tx_explorer_url }}
</a>
<a href="{{ args.tx_explorer_url }}" target="_blank" rel="nofollow noopener">
{{ args.tx_explorer_url }}
</a>
</p>
<p style="margin: 0;">
Thank you for your help in improving the ZCash ecosystem.
Thank you for your help in improving the Zcash ecosystem.
</p>

View File

@ -4,4 +4,4 @@ account will now show your contribution. You can view your transaction here:
{{ args.tx_explorer_url }}
Thank you for your help in improving the ZCash ecosystem.
Thank you for your help in improving the Zcash ecosystem.

View File

@ -0,0 +1,24 @@
<p style="margin: 0 0 20px;">
{% if args.fully_staked %}
<strong>{{ args.proposal.title }}</strong> has been
staked for <strong>{{ args.contribution.amount }} ZEC</strong>! Your proposal
will now be forwarded to administrators for approval.
{% else %}
<strong>{{ args.proposal.title }}</strong> has been partially staked for
<strong>{{ args.contribution.amount }} ZEC</strong>. This is not enough to
fully stake the proposal. You must send at least
<strong>{{ args.stake_target }} ZEC</strong>.
{% endif %}
You can view your transaction below:
</p>
<p style="margin: 0 0 20px;">
<a href="{{ args.tx_explorer_url }}" target="_blank" rel="nofollow noopener">
{{ args.tx_explorer_url }}
</a>
</p>
<p style="margin: 0;">
Thank you for your help in improving the Zcash ecosystem.
</p>

View File

@ -0,0 +1,14 @@
{% if args.fully_staked %}
{{args.proposal.title}} has been staked for {{args.contribution.amount}} ZEC!
Your proposal will now be forwarded to administrators for approval.
{% else %}
{{ args.proposal.title }} has been partially staked for
{{ args.contribution.amount }} ZEC. This is not enough to
fully stake the proposal. You must send at least
{{ args.stake_target }} ZEC.
{% endif %}
You can view your transaction here:
{{ args.tx_explorer_url }}
Thank you for your help in improving the Zcash ecosystem.

View File

@ -43,10 +43,10 @@ def get_users(proposal_id):
else:
users = (
User.query
.join(proposal_team)
.join(Proposal)
.filter(proposal_team.c.proposal_id == proposal.id)
.all()
.join(proposal_team)
.join(Proposal)
.filter(proposal_team.c.proposal_id == proposal.id)
.all()
)
result = users_schema.dump(users)
return result
@ -88,6 +88,7 @@ def get_user(user_id, with_proposals, with_comments, with_funded, with_pending):
result["comments"] = comments_dump
if with_pending and authed_user and authed_user.id == user.id:
pending = Proposal.get_by_user(user, [
ProposalStatus.STAKING,
ProposalStatus.PENDING,
ProposalStatus.APPROVED,
ProposalStatus.REJECTED,

View File

@ -8,16 +8,21 @@ class CustomEnum():
class ProposalStatusEnum(CustomEnum):
DRAFT = 'DRAFT'
PENDING = 'PENDING'
STAKING = 'STAKING'
APPROVED = 'APPROVED'
REJECTED = 'REJECTED'
LIVE = 'LIVE'
DELETED = 'DELETED'
ProposalStatus = ProposalStatusEnum()
class ProposalStageEnum(CustomEnum):
FUNDING_REQUIRED = 'FUNDING_REQUIRED'
COMPLETED = 'COMPLETED'
ProposalStage = ProposalStageEnum()
@ -28,6 +33,8 @@ class CategoryEnum(CustomEnum):
COMMUNITY = 'COMMUNITY'
DOCUMENTATION = 'DOCUMENTATION'
ACCESSIBILITY = 'ACCESSIBILITY'
Category = CategoryEnum()
@ -35,6 +42,8 @@ class ContributionStatusEnum(CustomEnum):
PENDING = 'PENDING'
CONFIRMED = 'CONFIRMED'
DELETED = 'DELETED'
ContributionStatus = ContributionStatusEnum()
@ -42,4 +51,6 @@ class RFPStatusEnum(CustomEnum):
DRAFT = 'DRAFT'
LIVE = 'LIVE'
CLOSED = 'CLOSED'
RFPStatus = RFPStatusEnum()

View File

@ -3,6 +3,8 @@ from grant.utils.admin import generate_admin_password_hash
from mock import patch
from ..config import BaseProposalCreatorConfig
from ..mocks import mock_request
plaintext_mock_password = "p4ssw0rd"
mock_admin_auth = {
@ -96,8 +98,10 @@ class TestAdminAPI(BaseProposalCreatorConfig):
def test_approve_proposal(self):
self.login_admin()
# submit for approval (performed by end-user)
self.proposal.submit_for_approval()
# proposal needs to be PENDING
self.proposal.status = ProposalStatus.PENDING
# approve
resp = self.app.put(
"/api/v1/admin/proposals/{}/approve".format(self.proposal.id),
@ -108,8 +112,10 @@ class TestAdminAPI(BaseProposalCreatorConfig):
def test_reject_proposal(self):
self.login_admin()
# submit for approval (performed by end-user)
self.proposal.submit_for_approval()
# proposal needs to be PENDING
self.proposal.status = ProposalStatus.PENDING
# reject
resp = self.app.put(
"/api/v1/admin/proposals/{}/approve".format(self.proposal.id),

View File

@ -1,13 +1,15 @@
import json
from mock import patch
from flask_testing import TestCase
from grant.app import create_app
from grant.proposal.models import Proposal
from grant.proposal.models import Proposal, ProposalContribution
from grant.task.jobs import ProposalReminder
from grant.user.models import User, SocialMedia, db, Avatar
from grant.settings import PROPOSAL_STAKING_AMOUNT
from grant.utils.enums import ProposalStatus
from .test_data import test_user, test_other_user, test_proposal
from .test_data import test_user, test_other_user, test_proposal, mock_contribution_addresses
class BaseTestConfig(TestCase):
@ -32,7 +34,7 @@ class BaseTestConfig(TestCase):
"""
message = message or 'HTTP Status %s expected but got %s. Response json: %s' \
% (status_code, response.status_code, response.json or response.data)
% (status_code, response.status_code, response.json or response.data)
self.assertEqual(response.status_code, status_code, message)
assert_status = assertStatus
@ -144,4 +146,17 @@ class BaseProposalCreatorConfig(BaseUserConfig):
def make_proposal_reminder_task(self):
proposal_reminder = ProposalReminder(self.proposal.id)
proposal_reminder.make_task()
proposal_reminder.make_task()
@patch('requests.get', side_effect=mock_contribution_addresses)
def stake_proposal(self, mock_get):
# 1. submit
self.proposal.submit_for_approval()
# 2. get staking contribution
contribution = self.proposal.get_staking_contribution(self.user.id)
# 3. fake a confirmation
contribution.confirm(tx_id='tx', amount=str(PROPOSAL_STAKING_AMOUNT))
db.session.add(contribution)
db.session.commit()
contribution = self.proposal.get_staking_contribution(self.user.id)
return contribution

View File

@ -1,10 +1,12 @@
import json
from mock import patch
from grant.settings import PROPOSAL_STAKING_AMOUNT
from grant.proposal.models import Proposal
from grant.utils.enums import ProposalStatus
from ..config import BaseProposalCreatorConfig
from ..test_data import test_proposal
from ..test_data import test_proposal, mock_contribution_addresses
class TestProposalAPI(BaseProposalCreatorConfig):
@ -70,6 +72,7 @@ class TestProposalAPI(BaseProposalCreatorConfig):
self.login_default_user()
resp = self.app.put("/api/v1/proposals/{}/submit_for_approval".format(self.proposal.id))
self.assert200(resp)
self.assertEqual(resp.json['status'], ProposalStatus.STAKING)
def test_no_auth_proposal_draft_submit_for_approval(self):
resp = self.app.put("/api/v1/proposals/{}/submit_for_approval".format(self.proposal.id))
@ -86,12 +89,45 @@ class TestProposalAPI(BaseProposalCreatorConfig):
resp = self.app.put("/api/v1/proposals/{}/submit_for_approval".format(self.proposal.id))
self.assert400(resp)
# /stake
@patch('requests.get', side_effect=mock_contribution_addresses)
def test_proposal_stake(self, mock_get):
self.login_default_user()
self.proposal.status = ProposalStatus.STAKING
resp = self.app.get(f"/api/v1/proposals/{self.proposal.id}/stake")
print(resp)
self.assert200(resp)
self.assertEquals(resp.json['amount'], str(PROPOSAL_STAKING_AMOUNT))
@patch('requests.get', side_effect=mock_contribution_addresses)
def test_proposal_stake_no_auth(self, mock_get):
self.proposal.status = ProposalStatus.STAKING
resp = self.app.get(f"/api/v1/proposals/{self.proposal.id}/stake")
print(resp)
self.assert401(resp)
@patch('requests.get', side_effect=mock_contribution_addresses)
def test_proposal_stake_bad_status(self, mock_get):
self.login_default_user()
self.proposal.status = ProposalStatus.PENDING # should be staking
resp = self.app.get(f"/api/v1/proposals/{self.proposal.id}/stake")
print(resp)
self.assert400(resp)
@patch('requests.get', side_effect=mock_contribution_addresses)
def test_proposal_stake_funded(self, mock_get):
self.login_default_user()
# fake stake contribution with confirmation
self.stake_proposal()
resp = self.app.get(f"/api/v1/proposals/{self.proposal.id}/stake")
print(resp)
self.assert404(resp)
# /publish
def test_publish_proposal_approved(self):
self.login_default_user()
# submit for approval, then approve
self.proposal.submit_for_approval()
self.proposal.approve_pending(True) # admin action
# proposal needs to be APPROVED
self.proposal.status = ProposalStatus.APPROVED
resp = self.app.put("/api/v1/proposals/{}/publish".format(self.proposal.id))
self.assert200(resp)

View File

@ -3,7 +3,7 @@ from mock import patch
from grant.proposal.models import Proposal
from grant.utils.enums import ProposalStatus
from ..config import BaseUserConfig
from ..config import BaseProposalCreatorConfig
from ..test_data import test_proposal
from ..mocks import mock_request
@ -14,24 +14,17 @@ mock_contribution_addresses = mock_request({
})
class TestProposalContributionAPI(BaseUserConfig):
class TestProposalContributionAPI(BaseProposalCreatorConfig):
@patch('requests.get', side_effect=mock_contribution_addresses)
def test_create_proposal_contribution(self, mock_blockchain_get):
self.login_default_user()
proposal_res = self.app.post(
"/api/v1/proposals/drafts",
data=json.dumps(test_proposal),
content_type='application/json'
)
proposal_json = proposal_res.json
proposal_id = proposal_json["proposalId"]
contribution = {
"amount": "1.2345"
}
post_res = self.app.post(
"/api/v1/proposals/{}/contributions".format(proposal_id),
"/api/v1/proposals/{}/contributions".format(self.proposal.id),
data=json.dumps(contribution),
content_type='application/json'
)
@ -41,20 +34,13 @@ class TestProposalContributionAPI(BaseUserConfig):
@patch('requests.get', side_effect=mock_contribution_addresses)
def test_create_duplicate_contribution(self, mock_blockchain_get):
self.login_default_user()
proposal_res = self.app.post(
"/api/v1/proposals/drafts",
data=json.dumps(test_proposal),
content_type='application/json'
)
proposal_json = proposal_res.json
proposal_id = proposal_json["proposalId"]
contribution = {
"amount": "1.2345"
}
post_res = self.app.post(
"/api/v1/proposals/{}/contributions".format(proposal_id),
"/api/v1/proposals/{}/contributions".format(self.proposal.id),
data=json.dumps(contribution),
content_type='application/json'
)
@ -62,7 +48,7 @@ class TestProposalContributionAPI(BaseUserConfig):
self.assertStatus(post_res, 201)
dupe_res = self.app.post(
"/api/v1/proposals/{}/contributions".format(proposal_id),
"/api/v1/proposals/{}/contributions".format(self.proposal.id),
data=json.dumps(contribution),
content_type='application/json'
)
@ -72,27 +58,20 @@ class TestProposalContributionAPI(BaseUserConfig):
@patch('requests.get', side_effect=mock_contribution_addresses)
def test_get_proposal_contribution(self, mock_blockchain_get):
self.login_default_user()
proposal_res = self.app.post(
"/api/v1/proposals/drafts",
data=json.dumps(test_proposal),
content_type='application/json'
)
proposal_json = proposal_res.json
proposal_id = proposal_json["proposalId"]
contribution = {
"amount": "1.2345"
}
post_res = self.app.post(
"/api/v1/proposals/{}/contributions".format(proposal_id),
"/api/v1/proposals/{}/contributions".format(self.proposal.id),
data=json.dumps(contribution),
content_type='application/json'
)
contribution_id = post_res.json['id']
contribution_res = self.app.get(
f'/api/v1/proposals/{proposal_id}/contributions/{contribution_id}'
f'/api/v1/proposals/{self.proposal.id}/contributions/{contribution_id}'
)
contribution = contribution_res.json

View File

@ -1,3 +1,4 @@
from .mocks import mock_request
from grant.utils.enums import Category
@ -57,3 +58,9 @@ test_reply = {
"comment": "Test reply"
# Fill in parentCommentId in test
}
mock_contribution_addresses = mock_request({
'transparent': 't123',
'sprout': 'z123',
'memo': '123',
})

View File

@ -4,7 +4,7 @@ WEBHOOK_URL="http://localhost:5000/api/v1"
# REST Server Config
PORT="5051"
# ZCash Node (Defaults are for regtest)
# Zcash Node (Defaults are for regtest)
ZCASH_NODE_URL="http://localhost:18232"
ZCASH_NODE_USERNAME="zcash_user"
ZCASH_NODE_PASSWORD="zcash_password"

View File

@ -1,6 +1,6 @@
# Blockchain Watcher
Creates a websocket server that reads and reports on the activity of the ZCash
Creates a websocket server that reads and reports on the activity of the Zcash
blockchain. Communicates with a node over RPC.
## Development

View File

@ -15,3 +15,6 @@ BACKEND_URL=http://localhost:5000
# Blockchain explorer to link to. Top for mainnet, bottom for testnet.
# EXPLORER_URL="https://explorer.zcha.in/"
EXPLORER_URL="https://testnet.zcha.in/"
# Amount for staking a proposal in ZEC
PROPOSAL_STAKING_AMOUNT=0.025

View File

@ -1,6 +1,6 @@
# ZF Grants Frontend
This is the front-end component of ZCash Grant System.
This is the front-end component of Zcash Grant System.
## Setup

View File

@ -11,7 +11,12 @@ import {
EmailSubscriptions,
RFP,
} from 'types';
import { formatUserForPost, formatProposalFromGet, formatUserFromGet, formatRFPFromGet } from 'utils/api';
import {
formatUserForPost,
formatProposalFromGet,
formatUserFromGet,
formatRFPFromGet,
} from 'utils/api';
export function getProposals(): Promise<{ data: Proposal[] }> {
return axios.get('/api/v1/proposals/').then(res => {
@ -261,6 +266,12 @@ export function getProposalContribution(
return axios.get(`/api/v1/proposals/${proposalId}/contributions/${contributionId}`);
}
export function getProposalStakingContribution(
proposalId: number,
): Promise<{ data: ContributionWithAddresses }> {
return axios.get(`/api/v1/proposals/${proposalId}/stake`);
}
export function getRFPs(): Promise<{ data: RFP[] }> {
return axios.get('/api/v1/rfps/').then(res => {
res.data = res.data.map(formatRFPFromGet);

View File

@ -4,6 +4,7 @@
&-text {
margin-top: -0.25rem;
font-size: 0.95rem;
margin-bottom: 1em;
}
&-types {
@ -23,8 +24,7 @@
padding: 0.5rem;
margin-right: 1rem;
border-radius: 4px;
box-shadow: 0 1px 2px rgba(#000, 0.1),
0 1px 4px rgba(#000, 0.2);
box-shadow: 0 1px 2px rgba(#000, 0.1), 0 1px 4px rgba(#000, 0.2);
canvas {
display: block;
@ -110,4 +110,4 @@
font-size: 0.8rem;
opacity: 0.8;
}
}
}

View File

@ -1,4 +1,4 @@
import React from 'react';
import React, { ReactNode } from 'react';
import classnames from 'classnames';
import { Form, Input, Button, Icon, Radio, message } from 'antd';
import { RadioChangeEvent } from 'antd/lib/radio';
@ -11,6 +11,7 @@ import './PaymentInfo.less';
interface Props {
contribution?: ContributionWithAddresses | Falsy;
text?: ReactNode;
}
type SendType = 'sprout' | 'transparent';
@ -25,7 +26,7 @@ export default class PaymentInfo extends React.Component<Props, State> {
};
render() {
const { contribution } = this.props;
const { contribution, text } = this.props;
const { sendType } = this.state;
let address;
let memo;
@ -45,32 +46,27 @@ export default class PaymentInfo extends React.Component<Props, State> {
return (
<Form className="PaymentInfo" layout="vertical">
<p className="PaymentInfo-text">
Thank you for contributing! Just send using whichever method works best for you,
and we'll let you know when your contribution has been confirmed. Need help
sending?
{/* TODO: Help / FAQ page for sending */}
{' '}
<a>Click here</a>.
</p>
<div className="PaymentInfo-text">
{text || (
<>
Thank you for contributing! Just send using whichever method works best for
you, and we'll let you know when your contribution has been confirmed.
</>
)}
{/* TODO: Help / FAQ page for sending */} Need help sending? <a>Click here</a>.
</div>
<Radio.Group
className="PaymentInfo-types"
onChange={this.handleChangeSendType}
value={sendType}
>
<Radio.Button value="sprout">
Z Address (Private)
</Radio.Button>
<Radio.Button value="transparent">
T Address (Public)
</Radio.Button>
<Radio.Button value="sprout">Z Address (Private)</Radio.Button>
<Radio.Button value="transparent">T Address (Public)</Radio.Button>
</Radio.Group>
<div className="PaymentInfo-uri">
<div className={
classnames('PaymentInfo-uri-qr', !uri && 'is-loading')
}>
<div className={classnames('PaymentInfo-uri-qr', !uri && 'is-loading')}>
<span style={{ opacity: uri ? 1 : 0 }}>
<QRCode value={uri || ''} />
</span>
@ -100,7 +96,7 @@ export default class PaymentInfo extends React.Component<Props, State> {
</div>
<div className="PaymentInfo-fields-row">
<CopyInput
label="ZCash CLI command"
label="Zcash CLI command"
help="Make sure you replace YOUR_ADDRESS with your actual address"
value={cli}
/>
@ -123,33 +119,29 @@ interface CopyInputProps {
isTextarea?: boolean;
}
const CopyInput: React.SFC<CopyInputProps> = ({ label, value, help, className, isTextarea }) => (
const CopyInput: React.SFC<CopyInputProps> = ({
label,
value,
help,
className,
isTextarea,
}) => (
<Form.Item
className={classnames(
'CopyInput',
className,
isTextarea && 'is-textarea',
)}
className={classnames('CopyInput', className, isTextarea && 'is-textarea')}
label={label}
help={help}
>
{isTextarea ? (
<>
<Input.TextArea value={value} readOnly rows={3} />
<CopyToClipboard
text={value || ''}
onCopy={() => message.success('Copied!', 2)}
>
<CopyToClipboard text={value || ''} onCopy={() => message.success('Copied!', 2)}>
<Button icon="copy" />
</CopyToClipboard>
</>
) : (
<>
<Input value={value} readOnly />
<CopyToClipboard
text={value || ''}
onCopy={() => message.success('Copied!', 2)}
>
<CopyToClipboard text={value || ''} onCopy={() => message.success('Copied!', 2)}>
<Button icon="copy" />
</CopyToClipboard>
</>

View File

@ -1,17 +1,16 @@
@import '~styles/variables.less';
.CreateFinal {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
text-align: center;
max-width: 550px;
padding: 1rem;
margin: 3rem auto;
&-message {
display: flex;
justify-content: center;
align-items: center;
margin-bottom: 2rem;
.anticon {
margin-right: 1rem;
font-size: 3.2rem;
@ -28,4 +27,15 @@
text-align: left;
}
}
}
&-contribute {
border: 1px solid rgba(0, 0, 0, 0.05);
padding: 1.5rem;
}
&-staked {
margin-top: 1rem;
font-size: 1.1rem;
text-align: center;
}
}

View File

@ -5,7 +5,10 @@ import { Link } from 'react-router-dom';
import Loader from 'components/Loader';
import { createActions } from 'modules/create';
import { AppState } from 'store/reducers';
import { getProposalStakingContribution } from 'api/api';
import './Final.less';
import PaymentInfo from 'components/ContributionModal/PaymentInfo';
import { ContributionWithAddresses } from 'types';
interface StateProps {
form: AppState['create']['form'];
@ -19,13 +22,34 @@ interface DispatchProps {
type Props = StateProps & DispatchProps;
class CreateFinal extends React.Component<Props> {
const STATE = {
contribution: null as null | ContributionWithAddresses,
};
type State = typeof STATE;
class CreateFinal extends React.Component<Props, State> {
state = STATE;
componentDidMount() {
this.submit();
}
componentDidUpdate(prev: Props) {
const { submittedProposal } = this.props;
if (!prev.submittedProposal && submittedProposal) {
if (!submittedProposal.isStaked) {
this.getStakingContribution();
}
}
}
render() {
const { submittedProposal, submitError } = this.props;
const { contribution } = this.state;
const ready = submittedProposal && (submittedProposal.isStaked || contribution);
const staked = submittedProposal && submittedProposal.isStaked;
let content;
if (submitError) {
content = (
@ -40,24 +64,57 @@ class CreateFinal extends React.Component<Props> {
</div>
</div>
);
} else if (submittedProposal) {
} else if (ready) {
content = (
<div className="CreateFinal-message is-success">
<Icon type="check-circle" />
<div className="CreateFinal-message-text">
Your proposal has been submitted! Check your{' '}
<Link to={`/profile?tab=pending`}>profile's pending proposals tab</Link> to
check its status.
<>
<div className="CreateFinal-message is-success">
<Icon type="check-circle" />
{staked && (
<div className="CreateFinal-message-text">
Your proposal has been staked and submitted! Check your{' '}
<Link to={`/profile?tab=pending`}>profile's pending proposals tab</Link>{' '}
to check its status.
</div>
)}
{!staked && (
<div className="CreateFinal-message-text">
Your proposal has been submitted! Please send the staking contribution of{' '}
<b>{contribution && contribution.amount} ZEC</b> using the instructions
below.
</div>
)}
</div>
{/* TODO - remove or rework depending on design choices */}
{/* <div className="CreateFinal-message-text">
Your proposal has been submitted!{' '}
<Link to={`/proposals/${submittedProposal.proposalUrlId}`}>
Click here
</Link>
{' '}to check it out.
</div> */}
</div>
{!staked && (
<>
<div className="CreateFinal-contribute">
<PaymentInfo
text={
<>
<p>
If you cannot send the payment now, you may bring up these
instructions again by visiting your{' '}
<Link to={`/profile?tab=funded`}>profile's funded tab</Link>.
</p>
<p>
Once your payment has been sent and confirmed, you will receive an
email. Visit your{' '}
<Link to={`/profile?tab=pending`}>
profile's pending proposals tab
</Link>{' '}
at any time to check its status.
</p>
</>
}
contribution={contribution}
/>
</div>
<p className="CreateFinal-staked">
I'm finished, take me to{' '}
<Link to="/profile?tab=pending">my pending proposals</Link>!
</p>
</>
)}
</>
);
} else {
content = <Loader size="large" tip="Submitting your proposal..." />;
@ -71,6 +128,14 @@ class CreateFinal extends React.Component<Props> {
this.props.submitProposal(this.props.form);
}
};
private getStakingContribution = async () => {
const { submittedProposal } = this.props;
if (submittedProposal) {
const res = await getProposalStakingContribution(submittedProposal.proposalId);
this.setState({ contribution: res.data });
}
};
}
export default connect<StateProps, DispatchProps, {}, AppState>(

View File

@ -1,54 +0,0 @@
import React from 'react';
import { Modal, Alert } from 'antd';
import { getCreateWarnings } from 'modules/create/utils';
import { ProposalDraft } from 'types';
import './PublishWarningModal.less';
interface Props {
proposal: ProposalDraft | null;
isVisible: boolean;
handleClose(): void;
handlePublish(): void;
}
export default class PublishWarningModal extends React.Component<Props> {
render() {
const { proposal, isVisible, handleClose, handlePublish } = this.props;
const warnings = proposal ? getCreateWarnings(proposal) : [];
return (
<Modal
title={<>Confirm submit for approval</>}
visible={isVisible}
okText="Confirm submit"
cancelText="Never mind"
onOk={handlePublish}
onCancel={handleClose}
>
<div className="PublishWarningModal">
{!!warnings.length && (
<Alert
type="warning"
showIcon
message="Some fields have warnings"
description={
<>
<ul>
{warnings.map(w => (
<li key={w}>{w}</li>
))}
</ul>
<p>You can still submit, despite these warnings.</p>
</>
}
/>
)}
<p>
Are you sure you're ready to submit your proposal for approval? Once youve
done so, you won't be able to edit it.
</p>
</div>
</Modal>
);
}
}

View File

@ -1,4 +1,4 @@
.PublishWarningModal {
.SubmitWarningModal {
.ant-alert {
margin-bottom: 1rem;
@ -10,4 +10,4 @@
margin-bottom: 0;
}
}
}
}

View File

@ -0,0 +1,66 @@
import React from 'react';
import { Modal, Alert } from 'antd';
import { getCreateWarnings } from 'modules/create/utils';
import { ProposalDraft } from 'types';
import './SubmitWarningModal.less';
interface Props {
proposal: ProposalDraft | null;
isVisible: boolean;
handleClose(): void;
handleSubmit(): void;
}
export default class SubmitWarningModal extends React.Component<Props> {
render() {
const { proposal, isVisible, handleClose, handleSubmit } = this.props;
const warnings = proposal ? getCreateWarnings(proposal) : [];
const staked = proposal && proposal.isStaked;
return (
<Modal
title={<>Confirm submission</>}
visible={isVisible}
okText={staked ? 'Submit' : `I'm ready to stake`}
cancelText="Never mind"
onOk={handleSubmit}
onCancel={handleClose}
>
<div className="SubmitWarningModal">
{!!warnings.length && (
<Alert
type="warning"
showIcon
message="Some fields have warnings"
description={
<>
<ul>
{warnings.map(w => (
<li key={w}>{w}</li>
))}
</ul>
<p>You can still submit, despite these warnings.</p>
</>
}
/>
)}
{staked && (
<p>
Are you sure you're ready to submit your proposal for approval? Once youve
done so, you won't be able to edit it.
</p>
)}
{!staked && (
<p>
Are you sure you're ready to submit your proposal? You will be asked to send
a staking contribution of <b>{process.env.PROPOSAL_STAKING_AMOUNT} ZEC</b>.
Once confirmed, the proposal will be submitted for approval by site
administrators.
</p>
)}
</div>
</Modal>
);
}
}

View File

@ -14,7 +14,7 @@ import Payment from './Payment';
import Review from './Review';
import Preview from './Preview';
import Final from './Final';
import PublishWarningModal from './PublishWarningModal';
import SubmitWarningModal from './SubmitWarningModal';
import createExampleProposal from './example';
import { createActions } from 'modules/create';
import { ProposalDraft } from 'types';
@ -115,8 +115,8 @@ type Props = StateProps & DispatchProps & RouteComponentProps<any>;
interface State {
step: CREATE_STEP;
isPreviewing: boolean;
isShowingPublishWarning: boolean;
isPublishing: boolean;
isShowingSubmitWarning: boolean;
isSubmitting: boolean;
isExample: boolean;
}
@ -134,9 +134,9 @@ class CreateFlow extends React.Component<Props, State> {
this.state = {
step,
isPreviewing: false,
isPublishing: false,
isSubmitting: false,
isExample: false,
isShowingPublishWarning: false,
isShowingSubmitWarning: false,
};
this.debouncedUpdateForm = debounce(this.updateForm, 800);
this.historyUnlisten = this.props.history.listen(this.handlePop);
@ -154,7 +154,7 @@ class CreateFlow extends React.Component<Props, State> {
render() {
const { isSavingDraft } = this.props;
const { step, isPreviewing, isPublishing, isShowingPublishWarning } = this.state;
const { step, isPreviewing, isSubmitting, isShowingSubmitWarning } = this.state;
const info = STEP_INFO[step];
const currentIndex = STEP_ORDER.indexOf(step);
@ -163,7 +163,7 @@ class CreateFlow extends React.Component<Props, State> {
let content;
let showFooter = true;
if (isPublishing) {
if (isSubmitting) {
content = <Final />;
showFooter = false;
} else if (isPreviewing) {
@ -241,11 +241,11 @@ class CreateFlow extends React.Component<Props, State> {
{isSavingDraft && (
<div className="CreateFlow-draftNotification">Saving draft...</div>
)}
<PublishWarningModal
<SubmitWarningModal
proposal={this.props.form}
isVisible={isShowingPublishWarning}
isVisible={isShowingSubmitWarning}
handleClose={this.closePublishWarning}
handlePublish={this.startPublish}
handleSubmit={this.startSubmit}
/>
</div>
);
@ -274,10 +274,10 @@ class CreateFlow extends React.Component<Props, State> {
this.setState({ isPreviewing: !this.state.isPreviewing });
};
private startPublish = () => {
private startSubmit = () => {
this.setState({
isPublishing: true,
isShowingPublishWarning: false,
isSubmitting: true,
isShowingSubmitWarning: false,
});
};
@ -302,11 +302,11 @@ class CreateFlow extends React.Component<Props, State> {
};
private openPublishWarning = () => {
this.setState({ isShowingPublishWarning: true });
this.setState({ isShowingSubmitWarning: true });
};
private closePublishWarning = () => {
this.setState({ isShowingPublishWarning: false });
this.setState({ isShowingSubmitWarning: false });
};
private fillInExample = () => {

View File

@ -55,6 +55,17 @@ class ProfilePending extends React.Component<Props, State> {
</>
),
},
[STATUS.STAKING]: {
color: 'purple',
tag: 'Staking',
blurb: (
<div>
Awaiting staking contribution, you will recieve an email when staking has been
confirmed. If you staked this proposal you may check its status under the
"funded" tab.
</div>
),
},
[STATUS.PENDING]: {
color: 'orange',
tag: 'Pending',

View File

@ -142,7 +142,8 @@ export class ProposalDetail extends React.Component<Props, State> {
blurb: (
<>
Your proposal has been approved! It is currently only visible to the team.
Visit your <Link to="/profile?tab=pending">profile's pending tab</Link> to publish.
Visit your <Link to="/profile?tab=pending">profile's pending tab</Link> to
publish.
</>
),
type: 'success',
@ -151,11 +152,22 @@ export class ProposalDetail extends React.Component<Props, State> {
blurb: (
<>
Your proposal was rejected and is only visible to the team. Visit your{' '}
<Link to="/profile?tab=pending">profile's pending tab</Link> for more information.
<Link to="/profile?tab=pending">profile's pending tab</Link> for more
information.
</>
),
type: 'error',
},
[STATUS.STAKING]: {
blurb: (
<>
Your proposal is awaiting a staking contribution. Visit your{' '}
<Link to="/profile?tab=pending">profile's pending tab</Link> for more
information.
</>
),
type: 'warning',
},
} as { [key in STATUS]: { blurb: ReactNode; type: AlertProps['type'] } };
let banner = statusBanner[proposal.status];
if (isPreview) {
@ -196,11 +208,7 @@ export class ProposalDetail extends React.Component<Props, State> {
['is-expanded']: isBodyExpanded,
})}
>
{proposal ? (
<Markdown source={proposal.content} />
) : (
<Loader />
)}
{proposal ? <Markdown source={proposal.content} /> : <Loader />}
</div>
{showExpand && (
<button

View File

@ -174,6 +174,7 @@ export default function createReducer(
case types.SUBMIT_PROPOSAL_PENDING:
return {
...state,
submittedProposal: null,
isSubmitting: true,
submitError: null,
};

View File

@ -192,6 +192,7 @@ export function makeProposalPreviewFromDraft(draft: ProposalDraft): Proposal {
stage: 'preview',
category: draft.category || PROPOSAL_CATEGORY.DAPP,
team: draft.team,
isStaked: true,
milestones: draft.milestones.map((m, idx) => ({
index: idx,
title: m.title,

View File

@ -41,6 +41,8 @@ envProductionRequiredHandler(
'http://localhost:' + (process.env.PORT || 3000),
);
envProductionRequiredHandler('PROPOSAL_STAKING_AMOUNT', '0.025');
const appDirectory = fs.realpathSync(process.cwd());
process.env.NODE_PATH = (process.env.NODE_PATH || '')
.split(path.delimiter)
@ -54,6 +56,7 @@ module.exports = () => {
EXPLORER_URL: process.env.EXPLORER_URL || 'https://chain.so/zcash/',
NODE_ENV: process.env.NODE_ENV || 'development',
PORT: process.env.PORT || 3000,
PROPOSAL_STAKING_AMOUNT: process.env.PROPOSAL_STAKING_AMOUNT,
PUBLIC_HOST_URL: process.env.PUBLIC_HOST_URL,
SENTRY_DSN: process.env.SENTRY_DSN || null,
SENTRY_RELEASE: process.env.SENTRY_RELEASE || undefined,

View File

@ -159,6 +159,7 @@ export function generateProposal({
content: 'body',
stage: 'FUNDING_REQUIRED',
category: PROPOSAL_CATEGORY.COMMUNITY,
isStaked: true,
team: [
{
userid: 123,

View File

@ -34,6 +34,7 @@ export interface ProposalDraft {
team: User[];
invites: TeamInvite[];
status: STATUS;
isStaked: boolean;
}
export interface Proposal extends Omit<ProposalDraft, 'target' | 'invites'> {
@ -85,6 +86,7 @@ export interface UserProposal {
// NOTE: sync with backend/grant/proposal/models.py STATUSES
export enum STATUS {
DRAFT = 'DRAFT',
STAKING = 'STAKING',
PENDING = 'PENDING',
APPROVED = 'APPROVED',
REJECTED = 'REJECTED',