From f8f3bd1707295040a64b6babf41607832a3e9c7d Mon Sep 17 00:00:00 2001 From: AMStrix Date: Thu, 31 Jan 2019 16:56:16 -0600 Subject: [PATCH] Proposal staking (#134) * BE: proposal//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 --- README.md | 2 +- admin/src/types.ts | 1 + admin/src/util/statuses.ts | 7 ++ backend/.env.example | 3 + backend/grant/email/send.py | 16 +++ backend/grant/proposal/models.py | 44 ++++++++- backend/grant/proposal/views.py | 77 ++++++++++----- backend/grant/settings.py | 8 +- .../emails/contribution_confirmed.html | 20 ++-- .../emails/contribution_confirmed.txt | 2 +- .../staking_contribution_confirmed.html | 24 +++++ .../emails/staking_contribution_confirmed.txt | 14 +++ backend/grant/user/views.py | 9 +- backend/grant/utils/enums.py | 11 +++ backend/tests/admin/test_api.py | 14 ++- backend/tests/config.py | 23 ++++- backend/tests/proposal/test_api.py | 44 ++++++++- .../tests/proposal/test_contribution_api.py | 35 ++----- backend/tests/test_data.py | 7 ++ blockchain/.env.example | 2 +- blockchain/README.md | 2 +- frontend/.env.example | 3 + frontend/README.md | 2 +- frontend/client/api/api.ts | 13 ++- .../ContributionModal/PaymentInfo.less | 6 +- .../ContributionModal/PaymentInfo.tsx | 60 +++++------ .../client/components/CreateFlow/Final.less | 24 +++-- .../client/components/CreateFlow/Final.tsx | 99 +++++++++++++++---- .../CreateFlow/PublishWarningModal.tsx | 54 ---------- ...ningModal.less => SubmitWarningModal.less} | 4 +- .../CreateFlow/SubmitWarningModal.tsx | 66 +++++++++++++ .../client/components/CreateFlow/index.tsx | 30 +++--- .../components/Profile/ProfilePending.tsx | 11 +++ frontend/client/components/Proposal/index.tsx | 22 +++-- frontend/client/modules/create/reducers.ts | 1 + frontend/client/modules/create/utils.ts | 1 + frontend/config/env.js | 3 + frontend/stories/props.tsx | 1 + frontend/types/proposal.ts | 2 + 39 files changed, 533 insertions(+), 234 deletions(-) create mode 100644 backend/grant/templates/emails/staking_contribution_confirmed.html create mode 100644 backend/grant/templates/emails/staking_contribution_confirmed.txt delete mode 100644 frontend/client/components/CreateFlow/PublishWarningModal.tsx rename frontend/client/components/CreateFlow/{PublishWarningModal.less => SubmitWarningModal.less} (85%) create mode 100644 frontend/client/components/CreateFlow/SubmitWarningModal.tsx diff --git a/README.md b/README.md index 912d9732..09f399f2 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/admin/src/types.ts b/admin/src/types.ts index 85399b06..df538742 100644 --- a/admin/src/types.ts +++ b/admin/src/types.ts @@ -21,6 +21,7 @@ export enum PROPOSAL_STATUS { REJECTED = 'REJECTED', LIVE = 'LIVE', DELETED = 'DELETED', + STAKING = 'STAKING', } export interface Proposal { proposalId: number; diff --git a/admin/src/util/statuses.ts b/admin/src/util/statuses.ts index 5acb530b..948dfbb2 100644 --- a/admin/src/util/statuses.ts +++ b/admin/src/util/statuses.ts @@ -52,6 +52,13 @@ export const PROPOSAL_STATUSES: Array> = [ 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> = [ diff --git a/backend/.env.example b/backend/.env.example index 8fc31e09..230144c4 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -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 diff --git a/backend/grant/email/send.py b/backend/grant/email/send.py index 49806bde..3baf364a 100644 --- a/backend/grant/email/send.py +++ b/backend/grant/email/send.py @@ -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, diff --git a/backend/grant/proposal/models.py b/backend/grant/proposal/models.py index 8c25d347..b2fa07cb 100644 --- a/backend/grant/proposal/models.py +++ b/backend/grant/proposal/models.py @@ -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", diff --git a/backend/grant/proposal/views.py b/backend/grant/proposal/views.py index e5d1eb08..ca79cf52 100644 --- a/backend/grant/proposal/views.py +++ b/backend/grant/proposal/views.py @@ -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("//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("//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 diff --git a/backend/grant/settings.py b/backend/grant/settings.py index b515b703..d91908f3 100644 --- a/backend/grant/settings.py +++ b/backend/grant/settings.py @@ -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', } diff --git a/backend/grant/templates/emails/contribution_confirmed.html b/backend/grant/templates/emails/contribution_confirmed.html index d0f5eccf..d01027e7 100644 --- a/backend/grant/templates/emails/contribution_confirmed.html +++ b/backend/grant/templates/emails/contribution_confirmed.html @@ -1,20 +1,16 @@

- Your {{ args.contribution.amount }} ZEC contribution has - been confirmed! {{ args.proposal.title}} has been updated - to reflect your funding, and your account will now show your contribution. - You can view your transaction below: + Your {{ args.contribution.amount }} ZEC contribution has been + confirmed! {{ args.proposal.title }} has been updated to + reflect your funding, and your account will now show your contribution. You + can view your transaction below:

- - {{ args.tx_explorer_url }} - + + {{ args.tx_explorer_url }} +

- Thank you for your help in improving the ZCash ecosystem. + Thank you for your help in improving the Zcash ecosystem.

diff --git a/backend/grant/templates/emails/contribution_confirmed.txt b/backend/grant/templates/emails/contribution_confirmed.txt index 88a048c6..70be024a 100644 --- a/backend/grant/templates/emails/contribution_confirmed.txt +++ b/backend/grant/templates/emails/contribution_confirmed.txt @@ -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. \ No newline at end of file +Thank you for your help in improving the Zcash ecosystem. \ No newline at end of file diff --git a/backend/grant/templates/emails/staking_contribution_confirmed.html b/backend/grant/templates/emails/staking_contribution_confirmed.html new file mode 100644 index 00000000..e742e005 --- /dev/null +++ b/backend/grant/templates/emails/staking_contribution_confirmed.html @@ -0,0 +1,24 @@ +

+ {% 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 below: +

+ +

+ + {{ args.tx_explorer_url }} + +

+ +

+ Thank you for your help in improving the Zcash ecosystem. +

+ diff --git a/backend/grant/templates/emails/staking_contribution_confirmed.txt b/backend/grant/templates/emails/staking_contribution_confirmed.txt new file mode 100644 index 00000000..54afffd0 --- /dev/null +++ b/backend/grant/templates/emails/staking_contribution_confirmed.txt @@ -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. \ No newline at end of file diff --git a/backend/grant/user/views.py b/backend/grant/user/views.py index d395e466..4dff1b08 100644 --- a/backend/grant/user/views.py +++ b/backend/grant/user/views.py @@ -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, diff --git a/backend/grant/utils/enums.py b/backend/grant/utils/enums.py index c37c058e..649a3a84 100644 --- a/backend/grant/utils/enums.py +++ b/backend/grant/utils/enums.py @@ -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() diff --git a/backend/tests/admin/test_api.py b/backend/tests/admin/test_api.py index 8253d284..02345475 100644 --- a/backend/tests/admin/test_api.py +++ b/backend/tests/admin/test_api.py @@ -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), diff --git a/backend/tests/config.py b/backend/tests/config.py index 84fc8451..5ccb4569 100644 --- a/backend/tests/config.py +++ b/backend/tests/config.py @@ -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() \ No newline at end of file + 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 diff --git a/backend/tests/proposal/test_api.py b/backend/tests/proposal/test_api.py index 31894cec..d19cadb8 100644 --- a/backend/tests/proposal/test_api.py +++ b/backend/tests/proposal/test_api.py @@ -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) diff --git a/backend/tests/proposal/test_contribution_api.py b/backend/tests/proposal/test_contribution_api.py index 9f00467d..04f51d15 100644 --- a/backend/tests/proposal/test_contribution_api.py +++ b/backend/tests/proposal/test_contribution_api.py @@ -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 diff --git a/backend/tests/test_data.py b/backend/tests/test_data.py index 2e2faede..b43bc24d 100644 --- a/backend/tests/test_data.py +++ b/backend/tests/test_data.py @@ -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', +}) diff --git a/blockchain/.env.example b/blockchain/.env.example index 9539c117..45443f6a 100644 --- a/blockchain/.env.example +++ b/blockchain/.env.example @@ -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" diff --git a/blockchain/README.md b/blockchain/README.md index f3eef234..5b1e8678 100755 --- a/blockchain/README.md +++ b/blockchain/README.md @@ -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 diff --git a/frontend/.env.example b/frontend/.env.example index eb4528ba..1b76f568 100644 --- a/frontend/.env.example +++ b/frontend/.env.example @@ -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 diff --git a/frontend/README.md b/frontend/README.md index 63eb6f28..8d271d26 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -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 diff --git a/frontend/client/api/api.ts b/frontend/client/api/api.ts index 9064dfd1..e77e071a 100644 --- a/frontend/client/api/api.ts +++ b/frontend/client/api/api.ts @@ -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); diff --git a/frontend/client/components/ContributionModal/PaymentInfo.less b/frontend/client/components/ContributionModal/PaymentInfo.less index de120fe8..d819671b 100644 --- a/frontend/client/components/ContributionModal/PaymentInfo.less +++ b/frontend/client/components/ContributionModal/PaymentInfo.less @@ -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; } -} \ No newline at end of file +} diff --git a/frontend/client/components/ContributionModal/PaymentInfo.tsx b/frontend/client/components/ContributionModal/PaymentInfo.tsx index 720c83c5..9d555c14 100644 --- a/frontend/client/components/ContributionModal/PaymentInfo.tsx +++ b/frontend/client/components/ContributionModal/PaymentInfo.tsx @@ -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 { }; 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 { return (
-

- 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 */} - {' '} - Click here. -

+
+ {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? Click here. +
- - Z Address (Private) - - - T Address (Public) - + Z Address (Private) + T Address (Public)
-
+
@@ -100,7 +96,7 @@ export default class PaymentInfo extends React.Component {
@@ -123,33 +119,29 @@ interface CopyInputProps { isTextarea?: boolean; } -const CopyInput: React.SFC = ({ label, value, help, className, isTextarea }) => ( +const CopyInput: React.SFC = ({ + label, + value, + help, + className, + isTextarea, +}) => ( {isTextarea ? ( <> - message.success('Copied!', 2)} - > + message.success('Copied!', 2)}>
); - } else if (submittedProposal) { + } else if (ready) { content = ( -
- -
- Your proposal has been submitted! Check your{' '} - profile's pending proposals tab to - check its status. + <> +
+ + {staked && ( +
+ Your proposal has been staked and submitted! Check your{' '} + profile's pending proposals tab{' '} + to check its status. +
+ )} + {!staked && ( +
+ Your proposal has been submitted! Please send the staking contribution of{' '} + {contribution && contribution.amount} ZEC using the instructions + below. +
+ )}
- {/* TODO - remove or rework depending on design choices */} - {/*
- Your proposal has been submitted!{' '} - - Click here - - {' '}to check it out. -
*/} -
+ {!staked && ( + <> +
+ +

+ If you cannot send the payment now, you may bring up these + instructions again by visiting your{' '} + profile's funded tab. +

+

+ Once your payment has been sent and confirmed, you will receive an + email. Visit your{' '} + + profile's pending proposals tab + {' '} + at any time to check its status. +

+ + } + contribution={contribution} + /> +
+

+ I'm finished, take me to{' '} + my pending proposals! +

+ + )} + ); } else { content = ; @@ -71,6 +128,14 @@ class CreateFinal extends React.Component { 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( diff --git a/frontend/client/components/CreateFlow/PublishWarningModal.tsx b/frontend/client/components/CreateFlow/PublishWarningModal.tsx deleted file mode 100644 index 786465c7..00000000 --- a/frontend/client/components/CreateFlow/PublishWarningModal.tsx +++ /dev/null @@ -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 { - render() { - const { proposal, isVisible, handleClose, handlePublish } = this.props; - const warnings = proposal ? getCreateWarnings(proposal) : []; - - return ( - Confirm submit for approval} - visible={isVisible} - okText="Confirm submit" - cancelText="Never mind" - onOk={handlePublish} - onCancel={handleClose} - > -
- {!!warnings.length && ( - -
    - {warnings.map(w => ( -
  • {w}
  • - ))} -
-

You can still submit, despite these warnings.

- - } - /> - )} -

- Are you sure you're ready to submit your proposal for approval? Once you’ve - done so, you won't be able to edit it. -

-
-
- ); - } -} diff --git a/frontend/client/components/CreateFlow/PublishWarningModal.less b/frontend/client/components/CreateFlow/SubmitWarningModal.less similarity index 85% rename from frontend/client/components/CreateFlow/PublishWarningModal.less rename to frontend/client/components/CreateFlow/SubmitWarningModal.less index c475d354..ef9bae2e 100644 --- a/frontend/client/components/CreateFlow/PublishWarningModal.less +++ b/frontend/client/components/CreateFlow/SubmitWarningModal.less @@ -1,4 +1,4 @@ -.PublishWarningModal { +.SubmitWarningModal { .ant-alert { margin-bottom: 1rem; @@ -10,4 +10,4 @@ margin-bottom: 0; } } -} \ No newline at end of file +} diff --git a/frontend/client/components/CreateFlow/SubmitWarningModal.tsx b/frontend/client/components/CreateFlow/SubmitWarningModal.tsx new file mode 100644 index 00000000..f2f077fa --- /dev/null +++ b/frontend/client/components/CreateFlow/SubmitWarningModal.tsx @@ -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 { + render() { + const { proposal, isVisible, handleClose, handleSubmit } = this.props; + const warnings = proposal ? getCreateWarnings(proposal) : []; + + const staked = proposal && proposal.isStaked; + + return ( + Confirm submission} + visible={isVisible} + okText={staked ? 'Submit' : `I'm ready to stake`} + cancelText="Never mind" + onOk={handleSubmit} + onCancel={handleClose} + > +
+ {!!warnings.length && ( + +
    + {warnings.map(w => ( +
  • {w}
  • + ))} +
+

You can still submit, despite these warnings.

+ + } + /> + )} + {staked && ( +

+ Are you sure you're ready to submit your proposal for approval? Once you’ve + done so, you won't be able to edit it. +

+ )} + {!staked && ( +

+ Are you sure you're ready to submit your proposal? You will be asked to send + a staking contribution of {process.env.PROPOSAL_STAKING_AMOUNT} ZEC. + Once confirmed, the proposal will be submitted for approval by site + administrators. +

+ )} +
+
+ ); + } +} diff --git a/frontend/client/components/CreateFlow/index.tsx b/frontend/client/components/CreateFlow/index.tsx index d2009d26..5a2c2079 100644 --- a/frontend/client/components/CreateFlow/index.tsx +++ b/frontend/client/components/CreateFlow/index.tsx @@ -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; 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 { 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 { 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 { let content; let showFooter = true; - if (isPublishing) { + if (isSubmitting) { content = ; showFooter = false; } else if (isPreviewing) { @@ -241,11 +241,11 @@ class CreateFlow extends React.Component { {isSavingDraft && (
Saving draft...
)} -
); @@ -274,10 +274,10 @@ class CreateFlow extends React.Component { 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 { }; private openPublishWarning = () => { - this.setState({ isShowingPublishWarning: true }); + this.setState({ isShowingSubmitWarning: true }); }; private closePublishWarning = () => { - this.setState({ isShowingPublishWarning: false }); + this.setState({ isShowingSubmitWarning: false }); }; private fillInExample = () => { diff --git a/frontend/client/components/Profile/ProfilePending.tsx b/frontend/client/components/Profile/ProfilePending.tsx index 457aa2cc..460c3972 100644 --- a/frontend/client/components/Profile/ProfilePending.tsx +++ b/frontend/client/components/Profile/ProfilePending.tsx @@ -55,6 +55,17 @@ class ProfilePending extends React.Component { ), }, + [STATUS.STAKING]: { + color: 'purple', + tag: 'Staking', + blurb: ( +
+ 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. +
+ ), + }, [STATUS.PENDING]: { color: 'orange', tag: 'Pending', diff --git a/frontend/client/components/Proposal/index.tsx b/frontend/client/components/Proposal/index.tsx index b0b422e3..9ebed2fb 100644 --- a/frontend/client/components/Proposal/index.tsx +++ b/frontend/client/components/Proposal/index.tsx @@ -142,7 +142,8 @@ export class ProposalDetail extends React.Component { blurb: ( <> Your proposal has been approved! It is currently only visible to the team. - Visit your profile's pending tab to publish. + Visit your profile's pending tab to + publish. ), type: 'success', @@ -151,11 +152,22 @@ export class ProposalDetail extends React.Component { blurb: ( <> Your proposal was rejected and is only visible to the team. Visit your{' '} - profile's pending tab for more information. + profile's pending tab for more + information. ), type: 'error', }, + [STATUS.STAKING]: { + blurb: ( + <> + Your proposal is awaiting a staking contribution. Visit your{' '} + profile's pending tab 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 { ['is-expanded']: isBodyExpanded, })} > - {proposal ? ( - - ) : ( - - )} + {proposal ? : }
{showExpand && (