From 7620d7f577ef78cd9025d2d725b7cd61751106a0 Mon Sep 17 00:00:00 2001 From: Will O'Beirne Date: Mon, 4 Feb 2019 17:50:11 -0500 Subject: [PATCH 01/12] Fix create step query --- frontend/client/components/CreateFlow/index.tsx | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/frontend/client/components/CreateFlow/index.tsx b/frontend/client/components/CreateFlow/index.tsx index 54647a7b..8ee05e16 100644 --- a/frontend/client/components/CreateFlow/index.tsx +++ b/frontend/client/components/CreateFlow/index.tsx @@ -127,9 +127,10 @@ class CreateFlow extends React.Component { constructor(props: Props) { super(props); const searchValues = qs.parse(props.location.search); + const queryStep = searchValues.step ? searchValues.step.toUpperCase() : null; const step = - searchValues.step && CREATE_STEP[searchValues.step] - ? (CREATE_STEP[searchValues.step] as CREATE_STEP) + queryStep && CREATE_STEP[queryStep] + ? (CREATE_STEP[queryStep] as CREATE_STEP) : CREATE_STEP.BASICS; this.state = { step, @@ -142,10 +143,6 @@ class CreateFlow extends React.Component { this.historyUnlisten = this.props.history.listen(this.handlePop); } - componentDidMount() { - console.warn('TODO - implement RESET_CROWDFUND if necessary'); - } - componentWillUnmount() { if (this.historyUnlisten) { this.historyUnlisten(); From c7e18d19b03e51ee5430850b86c7a41e98da9ef1 Mon Sep 17 00:00:00 2001 From: Will O'Beirne Date: Tue, 5 Feb 2019 00:52:09 -0500 Subject: [PATCH 02/12] Add basic regex validation for addresses to frontend. --- frontend/client/modules/create/utils.ts | 4 ++-- frontend/client/utils/validators.ts | 16 ++++++++++++++-- 2 files changed, 16 insertions(+), 4 deletions(-) diff --git a/frontend/client/modules/create/utils.ts b/frontend/client/modules/create/utils.ts index d4c5fdd8..7491f05f 100644 --- a/frontend/client/modules/create/utils.ts +++ b/frontend/client/modules/create/utils.ts @@ -1,6 +1,6 @@ import { ProposalDraft, CreateMilestone, STATUS } from 'types'; import { User } from 'types'; -import { getAmountError } from 'utils/validators'; +import { getAmountError, isValidAddress } from 'utils/validators'; import { MILESTONE_STATE, Proposal } from 'types'; import { Zat, toZat } from 'utils/units'; import { ONE_DAY } from 'utils/time'; @@ -81,7 +81,7 @@ export function getCreateErrors( } // Payout address - if (!payoutAddress) { + if (payoutAddress && !isValidAddress(payoutAddress)) { errors.payoutAddress = 'That doesn’t look like a valid address'; } diff --git a/frontend/client/utils/validators.ts b/frontend/client/utils/validators.ts index 67b9e323..c2f0e9f6 100644 --- a/frontend/client/utils/validators.ts +++ b/frontend/client/utils/validators.ts @@ -17,7 +17,19 @@ export function isValidEmail(email: string): boolean { return /\S+@\S+\.\S+/.test(email); } +// Uses simple regex to validate addresses, doesn't check checksum or network export function isValidAddress(address: string): boolean { - console.warn('TODO - implement utils.isValidAddress', address); - return true; + // T address + if (/^t[a-zA-Z0-9]{34}$/.test(address)) { + return true; + } + // Sprout address + if (/^z?[a-zA-Z0-9]{94}$/.test(address)) { + return true; + } + // Sapling address + if (/^z(s)?(reg)?(testsapling)?[a-zA-Z0-9]{76}$/.test(address)) { + return true; + } + return false; } From df4b077a4ac7b820db1841022c6e094020c2719f Mon Sep 17 00:00:00 2001 From: Will O'Beirne Date: Tue, 5 Feb 2019 01:24:17 -0500 Subject: [PATCH 03/12] Check with zcash node if address is valid before final proposal submission. --- backend/grant/proposal/models.py | 5 +++++ blockchain/src/node.ts | 8 ++++++++ blockchain/src/server/index.ts | 13 +++++++++++++ frontend/client/components/CreateFlow/Final.tsx | 12 ++++++++---- frontend/client/components/CreateFlow/example.ts | 13 +++++++++---- frontend/client/components/CreateFlow/index.tsx | 14 ++++++-------- frontend/client/modules/create/utils.ts | 2 +- frontend/client/utils/validators.ts | 2 +- 8 files changed, 51 insertions(+), 18 deletions(-) diff --git a/backend/grant/proposal/models.py b/backend/grant/proposal/models.py index 2ec8aaa9..7a348e06 100644 --- a/backend/grant/proposal/models.py +++ b/backend/grant/proposal/models.py @@ -212,6 +212,11 @@ class Proposal(db.Model): if not hasattr(self, field): raise ValidationException("Proposal must have a {}".format(field)) + # Check with node that the address is kosher + res = blockchain_get('/validate/address', {'address': self.payout_address}) + if not res['valid']: + raise ValidationException("Payout address is not a valid Zcash address") + # Then run through regular validation Proposal.validate(vars(self)) diff --git a/blockchain/src/node.ts b/blockchain/src/node.ts index 8991267b..127f6081 100644 --- a/blockchain/src/node.ts +++ b/blockchain/src/node.ts @@ -102,6 +102,12 @@ export interface DisclosedPayment { message?: string; } +// Actually comes back with a bunch of args, but this is all we need +export interface ValidationResponse { + isvalid: boolean; +} + + // TODO: Type all methods with signatures from // https://github.com/zcash/zcash/blob/master/doc/payment-api.md interface ZCashNode { @@ -113,6 +119,7 @@ interface ZCashNode { (numberOrHash: string | number, verbosity: 0): Promise; } gettransaction: (txid: string) => Promise; + validateaddress: (address: string) => Promise; z_getbalance: (address: string, minConf?: number) => Promise; z_getnewaddress: (type?: 'sprout' | 'sapling') => Promise; z_listaddresses: () => Promise; @@ -120,6 +127,7 @@ interface ZCashNode { z_importviewingkey: (key: string, rescan?: 'yes' | 'no' | 'whenkeyisnew', startHeight?: number) => Promise; z_exportviewingkey: (zaddr: string) => Promise; z_validatepaymentdisclosure: (disclosure: string) => Promise; + z_validateaddress: (address: string) => Promise; } export const rpcOptions = { diff --git a/blockchain/src/server/index.ts b/blockchain/src/server/index.ts index 11f150fc..568d501b 100644 --- a/blockchain/src/server/index.ts +++ b/blockchain/src/server/index.ts @@ -101,6 +101,19 @@ app.post('/contribution/disclosure', async (req, res) => { } }); +app.get('/validate/address', async (req, res) => { + const { address } = req.query; + const [tRes, zRes] = await Promise.all([ + node.validateaddress(address as string), + node.z_validateaddress(address as string), + ]); + return res.json({ + data: { + valid: tRes.isvalid || zRes.isvalid, + }, + }); +}); + // Error handler after all routes to catch thrown exceptions app.use(errorHandlerMiddleware); diff --git a/frontend/client/components/CreateFlow/Final.tsx b/frontend/client/components/CreateFlow/Final.tsx index 1bf45510..e5adadc4 100644 --- a/frontend/client/components/CreateFlow/Final.tsx +++ b/frontend/client/components/CreateFlow/Final.tsx @@ -10,6 +10,10 @@ import './Final.less'; import PaymentInfo from 'components/ContributionModal/PaymentInfo'; import { ContributionWithAddresses } from 'types'; +interface OwnProps { + goBack(): void; +} + interface StateProps { form: AppState['create']['form']; submittedProposal: AppState['create']['submittedProposal']; @@ -20,7 +24,7 @@ interface DispatchProps { submitProposal: typeof createActions['submitProposal']; } -type Props = StateProps & DispatchProps; +type Props = OwnProps & StateProps & DispatchProps; const STATE = { contribution: null as null | ContributionWithAddresses, @@ -44,7 +48,7 @@ class CreateFinal extends React.Component { } render() { - const { submittedProposal, submitError } = this.props; + const { submittedProposal, submitError, goBack } = this.props; const { contribution } = this.state; const ready = submittedProposal && (submittedProposal.isStaked || contribution); @@ -60,7 +64,7 @@ class CreateFinal extends React.Component { Something went wrong during creation
{submitError}
- Click here to try again. + Click here to go back to the form and try again. ); @@ -138,7 +142,7 @@ class CreateFinal extends React.Component { }; } -export default connect( +export default connect( (state: AppState) => ({ form: state.create.form, submittedProposal: state.create.submittedProposal, diff --git a/frontend/client/components/CreateFlow/example.ts b/frontend/client/components/CreateFlow/example.ts index 7c5be266..eaa0c935 100644 --- a/frontend/client/components/CreateFlow/example.ts +++ b/frontend/client/components/CreateFlow/example.ts @@ -1,15 +1,20 @@ import { PROPOSAL_CATEGORY } from 'api/constants'; import { ProposalDraft } from 'types'; +import { typedKeys } from 'utils/ts'; -const createExampleProposal = (payoutAddress: string): Partial => { +const createExampleProposal = (): Partial => { + const cats = Object.keys(PROPOSAL_CATEGORY); + const category = cats[Math.floor(Math.random() * cats.length)] as PROPOSAL_CATEGORY; return { title: 'Grant.io T-Shirts', brief: "The most stylish wear, sporting your favorite brand's logo", - category: PROPOSAL_CATEGORY.COMMUNITY, + category, content: '![](https://i.imgur.com/aQagS0D.png)\n\nWe all know it, Grant.io is the bee\'s knees. But wouldn\'t it be great if you could show all your friends and family how much you love it? Well that\'s what we\'re here to offer today.\n\n# What We\'re Building\n\nWhy, T-Shirts of course! These beautiful shirts made out of 100% cotton and laser printed for long lasting goodness come from American Apparel. We\'ll be offering them in 4 styles:\n\n* Crew neck (wrinkled)\n* Crew neck (straight)\n* Scoop neck (fitted)\n* V neck (fitted)\n\nShirt sizings will be as follows:\n\n| Size | S | M | L | XL |\n|--------|-----|-----|-----|------|\n| **Width** | 18" | 20" | 22" | 24" |\n| **Length** | 28" | 29" | 30" | 31" |\n\n# Who We Are\n\nWe are the team behind grant.io. In addition to our software engineering experience, we have over 78 years of T-Shirt printing expertise combined. Sometimes I wake up at night and realize I was printing shirts in my dreams. Weird, man.\n\n# Expense Breakdown\n\n* $1,000 - A professional designer will hand-craft each letter on the shirt.\n* $500 - We\'ll get the shirt printed from 5 different factories and choose the best quality one.\n* $3,000 - The full run of prints, with 20 smalls, 20 mediums, and 20 larges.\n* $500 - Pizza. Lots of pizza.\n\n**Total**: $5,000', target: '5', - payoutAddress, + // Testnet address, assumes you wouldn't use this in production + payoutAddress: + 'ztfFV7AqJqBm1EcWvP3oktZUMnp91ygfduE6ZQqGWENM1CpRKJLMZp2kgChnJVc6CbKSZ4mS37iNaiDwcatxjZcfoi2g7E8', milestones: [ { title: 'Initial Funding', @@ -36,7 +41,7 @@ const createExampleProposal = (payoutAddress: string): Partial => immediatePayout: false, }, ], - deadlineDuration: 300 + deadlineDuration: 300, }; }; diff --git a/frontend/client/components/CreateFlow/index.tsx b/frontend/client/components/CreateFlow/index.tsx index 8ee05e16..2b1cf881 100644 --- a/frontend/client/components/CreateFlow/index.tsx +++ b/frontend/client/components/CreateFlow/index.tsx @@ -103,7 +103,6 @@ interface StateProps { form: AppState['create']['form']; isSavingDraft: AppState['create']['isSavingDraft']; hasSavedDraft: AppState['create']['hasSavedDraft']; - accounts: string[]; } interface DispatchProps { @@ -161,7 +160,7 @@ class CreateFlow extends React.Component { let content; let showFooter = true; if (isSubmitting) { - content = ; + content = ; showFooter = false; } else if (isPreviewing) { content = ; @@ -308,11 +307,12 @@ class CreateFlow extends React.Component { this.setState({ isShowingSubmitWarning: false }); }; - private fillInExample = () => { - const { accounts } = this.props; - const [payoutAddress] = accounts; + private cancelSubmit = () => { + this.setState({ isSubmitting: false }); + }; - this.updateForm(createExampleProposal(payoutAddress)); + private fillInExample = () => { + this.updateForm(createExampleProposal()); setTimeout(() => { this.setState({ isExample: true, @@ -324,12 +324,10 @@ class CreateFlow extends React.Component { const withConnect = connect( (state: AppState) => { - console.warn('TODO - remove/refactor accounts'); return { form: state.create.form, isSavingDraft: state.create.isSavingDraft, hasSavedDraft: state.create.hasSavedDraft, - accounts: ['notanaccount'], }; }, { diff --git a/frontend/client/modules/create/utils.ts b/frontend/client/modules/create/utils.ts index 7491f05f..5aecb576 100644 --- a/frontend/client/modules/create/utils.ts +++ b/frontend/client/modules/create/utils.ts @@ -82,7 +82,7 @@ export function getCreateErrors( // Payout address if (payoutAddress && !isValidAddress(payoutAddress)) { - errors.payoutAddress = 'That doesn’t look like a valid address'; + errors.payoutAddress = 'That doesn’t look like a valid zcash address'; } // Milestones diff --git a/frontend/client/utils/validators.ts b/frontend/client/utils/validators.ts index c2f0e9f6..d0ebe46e 100644 --- a/frontend/client/utils/validators.ts +++ b/frontend/client/utils/validators.ts @@ -24,7 +24,7 @@ export function isValidAddress(address: string): boolean { return true; } // Sprout address - if (/^z?[a-zA-Z0-9]{94}$/.test(address)) { + if (/^z[a-zA-Z0-9]{94}$/.test(address)) { return true; } // Sapling address From fdc293185b0806cf4784109a40de5adc957a1214 Mon Sep 17 00:00:00 2001 From: Will O'Beirne Date: Tue, 5 Feb 2019 01:26:05 -0500 Subject: [PATCH 04/12] tsc --- frontend/client/components/CreateFlow/example.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/frontend/client/components/CreateFlow/example.ts b/frontend/client/components/CreateFlow/example.ts index eaa0c935..2876621a 100644 --- a/frontend/client/components/CreateFlow/example.ts +++ b/frontend/client/components/CreateFlow/example.ts @@ -1,6 +1,5 @@ import { PROPOSAL_CATEGORY } from 'api/constants'; import { ProposalDraft } from 'types'; -import { typedKeys } from 'utils/ts'; const createExampleProposal = (): Partial => { const cats = Object.keys(PROPOSAL_CATEGORY); From a2ddcf07c4ab6e5f99254d3dbcf2817c65182288 Mon Sep 17 00:00:00 2001 From: Will O'Beirne Date: Tue, 5 Feb 2019 06:17:07 -0500 Subject: [PATCH 05/12] Mock requests where needed. Come up with a function that mocks all blockchain requests. --- backend/grant/proposal/models.py | 1 + backend/tests/admin/test_api.py | 8 +-- backend/tests/config.py | 4 +- backend/tests/proposal/test_api.py | 51 ++++++++++++++----- .../tests/proposal/test_contribution_api.py | 14 ++--- backend/tests/test_data.py | 16 ++++++ 6 files changed, 65 insertions(+), 29 deletions(-) diff --git a/backend/grant/proposal/models.py b/backend/grant/proposal/models.py index 7a348e06..49e8e8da 100644 --- a/backend/grant/proposal/models.py +++ b/backend/grant/proposal/models.py @@ -214,6 +214,7 @@ class Proposal(db.Model): # Check with node that the address is kosher res = blockchain_get('/validate/address', {'address': self.payout_address}) + print(res) if not res['valid']: raise ValidationException("Payout address is not a valid Zcash address") diff --git a/backend/tests/admin/test_api.py b/backend/tests/admin/test_api.py index 02345475..e6f8a068 100644 --- a/backend/tests/admin/test_api.py +++ b/backend/tests/admin/test_api.py @@ -3,7 +3,7 @@ from grant.utils.admin import generate_admin_password_hash from mock import patch from ..config import BaseProposalCreatorConfig -from ..mocks import mock_request +from ..test_data import mock_blockchain_api_requests plaintext_mock_password = "p4ssw0rd" @@ -96,7 +96,8 @@ class TestAdminAPI(BaseProposalCreatorConfig): resp = self.app.put(f"/api/v1/admin/proposals/{self.proposal.id}", data={"contributionMatching": 2}) self.assert400(resp) - def test_approve_proposal(self): + @patch('requests.get', side_effect=mock_blockchain_api_requests) + def test_approve_proposal(self, mock_get): self.login_admin() # proposal needs to be PENDING @@ -110,7 +111,8 @@ class TestAdminAPI(BaseProposalCreatorConfig): self.assert200(resp) self.assertEqual(resp.json["status"], ProposalStatus.APPROVED) - def test_reject_proposal(self): + @patch('requests.get', side_effect=mock_blockchain_api_requests) + def test_reject_proposal(self, mock_get): self.login_admin() # proposal needs to be PENDING diff --git a/backend/tests/config.py b/backend/tests/config.py index 5ccb4569..d72f45c0 100644 --- a/backend/tests/config.py +++ b/backend/tests/config.py @@ -9,7 +9,7 @@ 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, mock_contribution_addresses +from .test_data import test_user, test_other_user, test_proposal, mock_blockchain_api_requests class BaseTestConfig(TestCase): @@ -148,7 +148,7 @@ class BaseProposalCreatorConfig(BaseUserConfig): proposal_reminder = ProposalReminder(self.proposal.id) proposal_reminder.make_task() - @patch('requests.get', side_effect=mock_contribution_addresses) + @patch('requests.get', side_effect=mock_blockchain_api_requests) def stake_proposal(self, mock_get): # 1. submit self.proposal.submit_for_approval() diff --git a/backend/tests/proposal/test_api.py b/backend/tests/proposal/test_api.py index d19cadb8..f436fe40 100644 --- a/backend/tests/proposal/test_api.py +++ b/backend/tests/proposal/test_api.py @@ -6,7 +6,15 @@ from grant.proposal.models import Proposal from grant.utils.enums import ProposalStatus from ..config import BaseProposalCreatorConfig -from ..test_data import test_proposal, mock_contribution_addresses +from ..test_data import test_proposal, mock_blockchain_api_requests, mock_invalid_address + + +# Used when a test mocks request.get in multiple ways +def mock_contribution_addresses_and_valid_address(path): + if path == '/contribution/addresses': + return mock_valid_address + else: + return mock_contribution_addresses class TestProposalAPI(BaseProposalCreatorConfig): @@ -68,29 +76,39 @@ class TestProposalAPI(BaseProposalCreatorConfig): self.assert404(resp) # /submit_for_approval - def test_proposal_draft_submit_for_approval(self): + @patch('requests.get', side_effect=mock_blockchain_api_requests) + def test_proposal_draft_submit_for_approval(self, mock_get): 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): + @patch('requests.get', side_effect=mock_blockchain_api_requests) + def test_no_auth_proposal_draft_submit_for_approval(self, mock_get): resp = self.app.put("/api/v1/proposals/{}/submit_for_approval".format(self.proposal.id)) self.assert401(resp) - def test_invalid_proposal_draft_submit_for_approval(self): + @patch('requests.get', side_effect=mock_blockchain_api_requests) + def test_invalid_proposal_draft_submit_for_approval(self, mock_get): self.login_default_user() resp = self.app.put("/api/v1/proposals/12345/submit_for_approval") self.assert404(resp) - def test_invalid_status_proposal_draft_submit_for_approval(self): + @patch('requests.get', side_effect=mock_blockchain_api_requests) + def test_invalid_status_proposal_draft_submit_for_approval(self, mock_get): self.login_default_user() self.proposal.status = ProposalStatus.PENDING # should be ProposalStatus.DRAFT resp = self.app.put("/api/v1/proposals/{}/submit_for_approval".format(self.proposal.id)) self.assert400(resp) + @patch('requests.get', side_effect=mock_invalid_address) + def test_invalid_address_proposal_draft_submit_for_approval(self, mock_get): + self.login_default_user() + 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) + @patch('requests.get', side_effect=mock_blockchain_api_requests) def test_proposal_stake(self, mock_get): self.login_default_user() self.proposal.status = ProposalStatus.STAKING @@ -99,14 +117,14 @@ class TestProposalAPI(BaseProposalCreatorConfig): self.assert200(resp) self.assertEquals(resp.json['amount'], str(PROPOSAL_STAKING_AMOUNT)) - @patch('requests.get', side_effect=mock_contribution_addresses) + @patch('requests.get', side_effect=mock_blockchain_api_requests) 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) + @patch('requests.get', side_effect=mock_blockchain_api_requests) def test_proposal_stake_bad_status(self, mock_get): self.login_default_user() self.proposal.status = ProposalStatus.PENDING # should be staking @@ -114,7 +132,7 @@ class TestProposalAPI(BaseProposalCreatorConfig): print(resp) self.assert400(resp) - @patch('requests.get', side_effect=mock_contribution_addresses) + @patch('requests.get', side_effect=mock_blockchain_api_requests) def test_proposal_stake_funded(self, mock_get): self.login_default_user() # fake stake contribution with confirmation @@ -124,29 +142,34 @@ class TestProposalAPI(BaseProposalCreatorConfig): self.assert404(resp) # /publish - def test_publish_proposal_approved(self): + @patch('requests.get', side_effect=mock_blockchain_api_requests) + def test_publish_proposal_approved(self, mock_get): self.login_default_user() # 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) - def test_no_auth_publish_proposal(self): + @patch('requests.get', side_effect=mock_blockchain_api_requests) + def test_no_auth_publish_proposal(self, mock_get): resp = self.app.put("/api/v1/proposals/{}/publish".format(self.proposal.id)) self.assert401(resp) - def test_invalid_proposal_publish_proposal(self): + @patch('requests.get', side_effect=mock_blockchain_api_requests) + def test_invalid_proposal_publish_proposal(self, mock_get): self.login_default_user() resp = self.app.put("/api/v1/proposals/12345/publish") self.assert404(resp) - def test_invalid_status_proposal_publish_proposal(self): + @patch('requests.get', side_effect=mock_blockchain_api_requests) + def test_invalid_status_proposal_publish_proposal(self, mock_get): self.login_default_user() self.proposal.status = ProposalStatus.PENDING # should be ProposalStatus.APPROVED resp = self.app.put("/api/v1/proposals/{}/publish".format(self.proposal.id)) self.assert400(resp) - def test_not_verified_email_address_publish_proposal(self): + @patch('requests.get', side_effect=mock_blockchain_api_requests) + def test_not_verified_email_address_publish_proposal(self, mock_get): self.login_default_user() self.mark_user_not_verified() self.proposal.status = "DRAFT" diff --git a/backend/tests/proposal/test_contribution_api.py b/backend/tests/proposal/test_contribution_api.py index 04f51d15..8a606f85 100644 --- a/backend/tests/proposal/test_contribution_api.py +++ b/backend/tests/proposal/test_contribution_api.py @@ -4,18 +4,12 @@ from mock import patch 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_blockchain_api_requests from ..mocks import mock_request -mock_contribution_addresses = mock_request({ - 'transparent': 't123', - 'sprout': 'z123', - 'memo': '123', -}) - class TestProposalContributionAPI(BaseProposalCreatorConfig): - @patch('requests.get', side_effect=mock_contribution_addresses) + @patch('requests.get', side_effect=mock_blockchain_api_requests) def test_create_proposal_contribution(self, mock_blockchain_get): self.login_default_user() @@ -31,7 +25,7 @@ class TestProposalContributionAPI(BaseProposalCreatorConfig): self.assertStatus(post_res, 201) - @patch('requests.get', side_effect=mock_contribution_addresses) + @patch('requests.get', side_effect=mock_blockchain_api_requests) def test_create_duplicate_contribution(self, mock_blockchain_get): self.login_default_user() @@ -55,7 +49,7 @@ class TestProposalContributionAPI(BaseProposalCreatorConfig): self.assert200(dupe_res) self.assertEqual(dupe_res.json['id'], post_res.json['id']) - @patch('requests.get', side_effect=mock_contribution_addresses) + @patch('requests.get', side_effect=mock_blockchain_api_requests) def test_get_proposal_contribution(self, mock_blockchain_get): self.login_default_user() diff --git a/backend/tests/test_data.py b/backend/tests/test_data.py index b43bc24d..ebf22952 100644 --- a/backend/tests/test_data.py +++ b/backend/tests/test_data.py @@ -64,3 +64,19 @@ mock_contribution_addresses = mock_request({ 'sprout': 'z123', 'memo': '123', }) + +mock_valid_address = mock_request({ + 'valid': True, +}) + +mock_invalid_address = mock_request({ + 'valid': False, +}) + + +def mock_blockchain_api_requests(path, **kwargs): + if '/contribution/addresses' in path: + return mock_contribution_addresses() + if '/validate/address' in path: + return mock_valid_address() + raise Exception('No mock data defined for path {}'.format(path)) From a7fb3bcd816a5ff3f376a041e1584b5d26382983 Mon Sep 17 00:00:00 2001 From: Will O'Beirne Date: Tue, 5 Feb 2019 15:16:39 -0500 Subject: [PATCH 06/12] Remove print --- backend/grant/proposal/models.py | 1 - 1 file changed, 1 deletion(-) diff --git a/backend/grant/proposal/models.py b/backend/grant/proposal/models.py index 49e8e8da..7a348e06 100644 --- a/backend/grant/proposal/models.py +++ b/backend/grant/proposal/models.py @@ -214,7 +214,6 @@ class Proposal(db.Model): # Check with node that the address is kosher res = blockchain_get('/validate/address', {'address': self.payout_address}) - print(res) if not res['valid']: raise ValidationException("Payout address is not a valid Zcash address") From 6cd6520fbc6ede2b8da0e7b1ea32a8b986666991 Mon Sep 17 00:00:00 2001 From: Will O'Beirne Date: Tue, 5 Feb 2019 15:56:07 -0500 Subject: [PATCH 07/12] Use datePublished for deadline wherever possible. Disable past dates during milestone create, disable field when immediate payout is set. --- frontend/client/components/CreateFlow/Milestones.tsx | 12 ++++++++++++ .../components/Proposal/CampaignBlock/index.tsx | 5 +++-- .../components/Proposals/ProposalCard/index.tsx | 9 +++------ frontend/types/proposal.ts | 3 ++- 4 files changed, 20 insertions(+), 9 deletions(-) diff --git a/frontend/client/components/CreateFlow/Milestones.tsx b/frontend/client/components/CreateFlow/Milestones.tsx index 9e642f98..6bf4a54a 100644 --- a/frontend/client/components/CreateFlow/Milestones.tsx +++ b/frontend/client/components/CreateFlow/Milestones.tsx @@ -163,6 +163,15 @@ const MilestoneFields = ({ format="MMMM YYYY" allowClear={false} onChange={(_, dateEstimated) => onChange(index, { ...milestone, dateEstimated })} + disabled={milestone.immediatePayout} + disabledDate={current => + current + ? current < + moment() + .subtract(1, 'month') + .endOf('month') + : false + } /> diff --git a/frontend/client/components/Proposal/CampaignBlock/index.tsx b/frontend/client/components/Proposal/CampaignBlock/index.tsx index 775dffee..d935f62b 100644 --- a/frontend/client/components/Proposal/CampaignBlock/index.tsx +++ b/frontend/client/components/Proposal/CampaignBlock/index.tsx @@ -49,8 +49,9 @@ export class ProposalCampaignBlock extends React.Component { let content; if (proposal) { const { target, funded, percentFunded } = proposal; + const datePublished = proposal.datePublished || Date.now() / 1000; const isRaiseGoalReached = funded.gte(target); - const deadline = (proposal.dateCreated + proposal.deadlineDuration) * 1000; + const deadline = (datePublished + proposal.deadlineDuration) * 1000; // TODO: Get values from proposal console.warn('TODO: Get isFrozen from proposal data'); const isFrozen = false; @@ -66,7 +67,7 @@ export class ProposalCampaignBlock extends React.Component {
Started
- {moment(proposal.datePublished * 1000).fromNow()} + {moment(datePublished * 1000).fromNow()}
)} diff --git a/frontend/client/components/Proposals/ProposalCard/index.tsx b/frontend/client/components/Proposals/ProposalCard/index.tsx index f2f99fc1..2df2aa7b 100644 --- a/frontend/client/components/Proposals/ProposalCard/index.tsx +++ b/frontend/client/components/Proposals/ProposalCard/index.tsx @@ -19,6 +19,7 @@ export class ProposalCard extends React.Component { proposalAddress, proposalUrlId, category, + datePublished, dateCreated, team, target, @@ -28,11 +29,7 @@ export class ProposalCard extends React.Component { } = this.props; return ( - + {contributionMatching > 0 && (
@@ -77,7 +74,7 @@ export class ProposalCard extends React.Component {
{proposalAddress}
- +
); } diff --git a/frontend/types/proposal.ts b/frontend/types/proposal.ts index 5200cab7..f2fec571 100644 --- a/frontend/types/proposal.ts +++ b/frontend/types/proposal.ts @@ -47,7 +47,8 @@ export interface Proposal extends Omit { percentFunded: number; contributionMatching: number; milestones: ProposalMilestone[]; - datePublished: number; + datePublished: number | null; + dateApproved: number | null; } export interface TeamInviteWithProposal extends TeamInvite { From f0303b26b4991be7f9cff89675149bb8a9a5c844 Mon Sep 17 00:00:00 2001 From: Will O'Beirne Date: Tue, 5 Feb 2019 16:05:16 -0500 Subject: [PATCH 08/12] Set valid dates on auto-create. --- frontend/client/components/CreateFlow/example.ts | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/frontend/client/components/CreateFlow/example.ts b/frontend/client/components/CreateFlow/example.ts index 2876621a..474fc789 100644 --- a/frontend/client/components/CreateFlow/example.ts +++ b/frontend/client/components/CreateFlow/example.ts @@ -1,3 +1,4 @@ +import moment from 'moment'; import { PROPOSAL_CATEGORY } from 'api/constants'; import { ProposalDraft } from 'types'; @@ -19,7 +20,9 @@ const createExampleProposal = (): Partial => { title: 'Initial Funding', content: 'This will be used to pay for a professional designer to hand-craft each letter on the shirt.', - dateEstimated: 'October 2018', + dateEstimated: moment() + .add(1, 'month') + .format('MMMM YYYY'), payoutPercent: '30', immediatePayout: true, }, @@ -27,7 +30,9 @@ const createExampleProposal = (): Partial => { title: 'Test Prints', content: "We'll get test prints from 5 different factories and choose the highest quality shirts. Once we've decided, we'll order a full batch of prints.", - dateEstimated: 'November 2018', + dateEstimated: moment() + .add(2, 'month') + .format('MMMM YYYY'), payoutPercent: '20', immediatePayout: false, }, @@ -35,7 +40,9 @@ const createExampleProposal = (): Partial => { title: 'All Shirts Printed', content: "All of the shirts have been printed, hooray! They'll be given out at conferences and meetups.", - dateEstimated: 'December 2018', + dateEstimated: moment() + .add(3, 'month') + .format('MMMM YYYY'), payoutPercent: '50', immediatePayout: false, }, From 3b8d96ac514af7eed5b5eabedb5012cd3848e8f8 Mon Sep 17 00:00:00 2001 From: Will O'Beirne Date: Tue, 5 Feb 2019 16:05:45 -0500 Subject: [PATCH 09/12] Fix isImmediatePayout non-existant property on milestones. --- frontend/client/components/Proposal/Milestones/index.tsx | 4 ++-- frontend/client/modules/create/utils.ts | 2 +- frontend/stories/props.tsx | 2 -- frontend/types/milestone.ts | 3 +-- 4 files changed, 4 insertions(+), 7 deletions(-) diff --git a/frontend/client/components/Proposal/Milestones/index.tsx b/frontend/client/components/Proposal/Milestones/index.tsx index 1dec2d86..2748d501 100644 --- a/frontend/client/components/Proposal/Milestones/index.tsx +++ b/frontend/client/components/Proposal/Milestones/index.tsx @@ -121,7 +121,7 @@ class ProposalMilestones extends React.Component { message={ The team was awarded {reward}{' '} - {milestone.isImmediatePayout + {milestone.immediatePayout ? 'as an initial payout' : // TODO: Add property for payout date on milestones `on ${moment().format('MMM Do, YYYY')}`} @@ -164,7 +164,7 @@ class ProposalMilestones extends React.Component { const statuses = (
- {!milestone.isImmediatePayout && ( + {!milestone.immediatePayout && (
Estimate: {estimatedDate}
diff --git a/frontend/client/modules/create/utils.ts b/frontend/client/modules/create/utils.ts index 5aecb576..9ba03af1 100644 --- a/frontend/client/modules/create/utils.ts +++ b/frontend/client/modules/create/utils.ts @@ -183,6 +183,7 @@ export function makeProposalPreviewFromDraft(draft: ProposalDraft): Proposal { payoutAddress: '0x0', dateCreated: Date.now() / 1000, datePublished: Date.now() / 1000, + dateApproved: Date.now() / 1000, deadlineDuration: 86400 * 60, target: toZat(draft.target), funded: Zat('0'), @@ -198,7 +199,6 @@ export function makeProposalPreviewFromDraft(draft: ProposalDraft): Proposal { amount: toZat(target * (parseInt(m.payoutPercent, 10) / 100)), dateEstimated: m.dateEstimated, immediatePayout: m.immediatePayout, - isImmediatePayout: m.immediatePayout, isPaid: false, payoutPercent: m.payoutPercent.toString(), state: MILESTONE_STATE.WAITING, diff --git a/frontend/stories/props.tsx b/frontend/stories/props.tsx index cd5293c4..3c4a00aa 100644 --- a/frontend/stories/props.tsx +++ b/frontend/stories/props.tsx @@ -117,7 +117,6 @@ export function generateProposal({ state: MILESTONE_STATE.WAITING, amount: amountBn, isPaid: false, - isImmediatePayout: true, payoutPercent: '33', }; return { ...defaults, ...overrides }; @@ -128,7 +127,6 @@ export function generateProposal({ index: i, title: genMilestoneTitle(), immediatePayout: i === 0, - isImmediatePayout: i === 0, payoutRequestVoteDeadline: i !== 0 ? Date.now() + 3600000 : 0, payoutPercent: '' + (1 / milestoneCount) * 100, }; diff --git a/frontend/types/milestone.ts b/frontend/types/milestone.ts index 49cbad12..962141b3 100644 --- a/frontend/types/milestone.ts +++ b/frontend/types/milestone.ts @@ -12,12 +12,11 @@ export interface Milestone { state: MILESTONE_STATE; amount: Zat; isPaid: boolean; - isImmediatePayout: boolean; + immediatePayout: boolean; } export interface ProposalMilestone extends Milestone { content: string; - immediatePayout: boolean; dateEstimated: string; payoutPercent: string; title: string; From bd740bd9fa663d37c19dd0eba73637072597eef2 Mon Sep 17 00:00:00 2001 From: Will O'Beirne Date: Tue, 5 Feb 2019 16:06:45 -0500 Subject: [PATCH 10/12] Fix tsc --- frontend/stories/props.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/frontend/stories/props.tsx b/frontend/stories/props.tsx index 3c4a00aa..598924ed 100644 --- a/frontend/stories/props.tsx +++ b/frontend/stories/props.tsx @@ -147,6 +147,7 @@ export function generateProposal({ payoutAddress: 'z123', dateCreated: created / 1000, datePublished: created / 1000, + dateApproved: created / 1000, deadlineDuration: 86400 * 60, target: amountBn, funded: fundedBn, From a3a23921350669aae613946c24251e62384f6fbd Mon Sep 17 00:00:00 2001 From: Will O'Beirne Date: Tue, 5 Feb 2019 16:25:58 -0500 Subject: [PATCH 11/12] Convert milestone dates to unix timestamps to be inline with the rest of our entities. --- backend/grant/milestone/models.py | 10 ++++++++++ frontend/client/components/CreateFlow/Milestones.tsx | 10 ++++++---- frontend/client/components/CreateFlow/Review.tsx | 2 +- frontend/client/components/CreateFlow/example.ts | 6 +++--- .../client/components/Proposal/Milestones/index.tsx | 2 +- frontend/stories/props.tsx | 9 +++++---- frontend/types/milestone.ts | 4 ++-- 7 files changed, 28 insertions(+), 15 deletions(-) diff --git a/backend/grant/milestone/models.py b/backend/grant/milestone/models.py index fc3a2e3a..e1f62d12 100644 --- a/backend/grant/milestone/models.py +++ b/backend/grant/milestone/models.py @@ -2,6 +2,7 @@ import datetime from grant.extensions import ma, db from grant.utils.exceptions import ValidationException +from grant.utils.misc import dt_to_unix NOT_REQUESTED = 'NOT_REQUESTED' ONGOING_VOTE = 'ONGOING_VOTE' @@ -64,6 +65,15 @@ class MilestoneSchema(ma.Schema): "date_created", ) + date_created = ma.Method("get_date_created") + date_estimated = ma.Method("get_date_estimated") + + def get_date_created(self, obj): + return dt_to_unix(obj.date_created) + + def get_date_estimated(self, obj): + return dt_to_unix(obj.date_estimated) if obj.date_estimated else None + milestone_schema = MilestoneSchema() milestones_schema = MilestoneSchema(many=True) diff --git a/frontend/client/components/CreateFlow/Milestones.tsx b/frontend/client/components/CreateFlow/Milestones.tsx index 6bf4a54a..aca5d948 100644 --- a/frontend/client/components/CreateFlow/Milestones.tsx +++ b/frontend/client/components/CreateFlow/Milestones.tsx @@ -18,7 +18,7 @@ const DEFAULT_STATE: State = { { title: '', content: '', - dateEstimated: '', + dateEstimated: moment().unix(), payoutPercent: '100', immediatePayout: false, }, @@ -159,10 +159,12 @@ const MilestoneFields = ({ onChange(index, { ...milestone, dateEstimated })} + onChange={time => onChange(index, { ...milestone, dateEstimated: time.unix() })} disabled={milestone.immediatePayout} disabledDate={current => current @@ -196,7 +198,7 @@ const MilestoneFields = ({ ...milestone, immediatePayout: ev.target.checked, dateEstimated: ev.target.checked - ? moment().format('MMMM YYYY') + ? moment().unix() : milestone.dateEstimated, }) } diff --git a/frontend/client/components/CreateFlow/Review.tsx b/frontend/client/components/CreateFlow/Review.tsx index 92971393..6d7cc901 100644 --- a/frontend/client/components/CreateFlow/Review.tsx +++ b/frontend/client/components/CreateFlow/Review.tsx @@ -182,7 +182,7 @@ const ReviewMilestones = ({
{m.title}
- {moment(m.dateEstimated, 'MMMM YYYY').format('MMMM YYYY')} + {moment(m.dateEstimated * 1000).format('MMMM YYYY')} {' – '} {m.payoutPercent}% of funds
diff --git a/frontend/client/components/CreateFlow/example.ts b/frontend/client/components/CreateFlow/example.ts index 474fc789..31e4a6ae 100644 --- a/frontend/client/components/CreateFlow/example.ts +++ b/frontend/client/components/CreateFlow/example.ts @@ -22,7 +22,7 @@ const createExampleProposal = (): Partial => { 'This will be used to pay for a professional designer to hand-craft each letter on the shirt.', dateEstimated: moment() .add(1, 'month') - .format('MMMM YYYY'), + .unix(), payoutPercent: '30', immediatePayout: true, }, @@ -32,7 +32,7 @@ const createExampleProposal = (): Partial => { "We'll get test prints from 5 different factories and choose the highest quality shirts. Once we've decided, we'll order a full batch of prints.", dateEstimated: moment() .add(2, 'month') - .format('MMMM YYYY'), + .unix(), payoutPercent: '20', immediatePayout: false, }, @@ -42,7 +42,7 @@ const createExampleProposal = (): Partial => { "All of the shirts have been printed, hooray! They'll be given out at conferences and meetups.", dateEstimated: moment() .add(3, 'month') - .format('MMMM YYYY'), + .unix(), payoutPercent: '50', immediatePayout: false, }, diff --git a/frontend/client/components/Proposal/Milestones/index.tsx b/frontend/client/components/Proposal/Milestones/index.tsx index 2748d501..9fbe18b4 100644 --- a/frontend/client/components/Proposal/Milestones/index.tsx +++ b/frontend/client/components/Proposal/Milestones/index.tsx @@ -98,7 +98,7 @@ class ProposalMilestones extends React.Component { : milestoneStateToStepState[milestone.state]; const className = this.state.step === i ? 'is-active' : 'is-inactive'; - const estimatedDate = moment(milestone.dateEstimated).format('MMMM YYYY'); + const estimatedDate = moment(milestone.dateEstimated * 1000).format('MMMM YYYY'); const reward = ( ); diff --git a/frontend/stories/props.tsx b/frontend/stories/props.tsx index 598924ed..ac60ef25 100644 --- a/frontend/stories/props.tsx +++ b/frontend/stories/props.tsx @@ -8,6 +8,7 @@ import { } from 'types'; import { PROPOSAL_CATEGORY } from 'api/constants'; import BN from 'bn.js'; +import moment from 'moment'; const oneZec = new BN('100000000'); @@ -101,17 +102,17 @@ export function generateProposal({ const genMilestone = ( overrides: Partial = {}, ): ProposalMilestone => { - const now = new Date(); if (overrides.index) { - const estimate = new Date(now.setMonth(now.getMonth() + overrides.index)); - overrides.dateEstimated = estimate.toISOString(); + overrides.dateEstimated = moment() + .add(overrides.index, 'month') + .unix(); } const defaults: ProposalMilestone = { title: 'Milestone A', content: `Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.`, - dateEstimated: '2018-10-01T00:00:00+00:00', + dateEstimated: moment().unix(), immediatePayout: true, index: 0, state: MILESTONE_STATE.WAITING, diff --git a/frontend/types/milestone.ts b/frontend/types/milestone.ts index 962141b3..46661931 100644 --- a/frontend/types/milestone.ts +++ b/frontend/types/milestone.ts @@ -13,11 +13,11 @@ export interface Milestone { amount: Zat; isPaid: boolean; immediatePayout: boolean; + dateEstimated: number; } export interface ProposalMilestone extends Milestone { content: string; - dateEstimated: string; payoutPercent: string; title: string; } @@ -25,7 +25,7 @@ export interface ProposalMilestone extends Milestone { export interface CreateMilestone { title: string; content: string; - dateEstimated: string; + dateEstimated: number; payoutPercent: string; immediatePayout: boolean; } From a1283f24ebcd83b983e846121238cc1e36985d13 Mon Sep 17 00:00:00 2001 From: Will O'Beirne Date: Tue, 5 Feb 2019 20:45:57 -0500 Subject: [PATCH 12/12] Allow topping off of partial stake contributions. Fix float innaccuracy errors in python. Allow staking proposals to be deleted. --- backend/grant/proposal/models.py | 19 +++-- backend/grant/proposal/views.py | 5 +- backend/grant/settings.py | 3 +- .../staking_contribution_confirmed.html | 7 +- .../emails/staking_contribution_confirmed.txt | 6 +- backend/tests/config.py | 2 +- backend/tests/proposal/test_api.py | 2 +- .../components/ContributionModal/index.tsx | 40 +++++----- .../components/Profile/ProfilePending.tsx | 74 ++++++++++++++++--- 9 files changed, 109 insertions(+), 49 deletions(-) diff --git a/backend/grant/proposal/models.py b/backend/grant/proposal/models.py index 7a348e06..3c1db9f5 100644 --- a/backend/grant/proposal/models.py +++ b/backend/grant/proposal/models.py @@ -2,6 +2,7 @@ import datetime from functools import reduce from sqlalchemy import func, or_ from sqlalchemy.ext.hybrid import hybrid_property +from decimal import Decimal from grant.comment.models import Comment from grant.email.send import send_email @@ -263,7 +264,7 @@ class Proposal(db.Model): self.deadline_duration = deadline_duration Proposal.validate(vars(self)) - def create_contribution(self, user_id: int, amount: float): + def create_contribution(self, user_id: int, amount): contribution = ProposalContribution( proposal_id=self.id, user_id=user_id, @@ -275,18 +276,16 @@ class Proposal(db.Model): def get_staking_contribution(self, user_id: int): contribution = None - remaining = PROPOSAL_STAKING_AMOUNT - float(self.contributed) + remaining = PROPOSAL_STAKING_AMOUNT - Decimal(self.contributed) # check funding if remaining > 0: - # find pending contribution for any user - # (always use full staking amout so we can find it) + # find pending contribution for any user of remaining amount 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) + contribution = self.create_contribution(user_id, str(remaining.normalize())) return contribution @@ -345,14 +344,14 @@ class Proposal(db.Model): contributions = ProposalContribution.query \ .filter_by(proposal_id=self.id, status=ContributionStatus.CONFIRMED) \ .all() - funded = reduce(lambda prev, c: prev + float(c.amount), contributions, 0) + funded = reduce(lambda prev, c: prev + Decimal(c.amount), contributions, 0) return str(funded) @hybrid_property def funded(self): - target = float(self.target) + target = Decimal(self.target) # apply matching multiplier - funded = float(self.contributed) * (1 + self.contribution_matching) + funded = Decimal(self.contributed) * Decimal(1 + self.contribution_matching) # if funded > target, just set as target if funded > target: return str(target) @@ -361,7 +360,7 @@ class Proposal(db.Model): @hybrid_property def is_staked(self): - return float(self.contributed) >= PROPOSAL_STAKING_AMOUNT + return Decimal(self.contributed) >= PROPOSAL_STAKING_AMOUNT class ProposalSchema(ma.Schema): diff --git a/backend/grant/proposal/views.py b/backend/grant/proposal/views.py index a872aafb..16aeb148 100644 --- a/backend/grant/proposal/views.py +++ b/backend/grant/proposal/views.py @@ -237,6 +237,7 @@ def delete_proposal(proposal_id): ProposalStatus.PENDING, ProposalStatus.APPROVED, ProposalStatus.REJECTED, + ProposalStatus.STAKING, ] status = g.current_proposal.status if status not in deleteable_statuses: @@ -482,7 +483,7 @@ def post_contribution_confirmation(contribution_id, to, amount, 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: + if contribution.proposal.is_staked: # Decimal(contribution.proposal.contributed) >= PROPOSAL_STAKING_AMOUNT: contribution.proposal.status = ProposalStatus.PENDING db.session.add(contribution.proposal) db.session.commit() @@ -493,7 +494,7 @@ def post_contribution_confirmation(contribution_id, to, amount, txid): 'proposal': contribution.proposal, 'tx_explorer_url': f'{EXPLORER_URL}transactions/{txid}', 'fully_staked': contribution.proposal.is_staked, - 'stake_target': PROPOSAL_STAKING_AMOUNT + 'stake_target': str(PROPOSAL_STAKING_AMOUNT.normalize()), }) else: diff --git a/backend/grant/settings.py b/backend/grant/settings.py index d91908f3..47a1c820 100644 --- a/backend/grant/settings.py +++ b/backend/grant/settings.py @@ -7,6 +7,7 @@ For local development, use a .env file to set environment variables. """ from environs import Env +from decimal import Decimal env = Env() env.read_env() @@ -54,7 +55,7 @@ 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") +PROPOSAL_STAKING_AMOUNT = Decimal(env.str("PROPOSAL_STAKING_AMOUNT")) UI = { 'NAME': 'ZF Grants', diff --git a/backend/grant/templates/emails/staking_contribution_confirmed.html b/backend/grant/templates/emails/staking_contribution_confirmed.html index e742e005..a1b7fe93 100644 --- a/backend/grant/templates/emails/staking_contribution_confirmed.html +++ b/backend/grant/templates/emails/staking_contribution_confirmed.html @@ -5,9 +5,10 @@ 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. + {{ args.contribution.amount }} ZEC of the required + {{ args.stake_target}} ZEC. + You can send the remaining amount by going to your profile's "Pending" tab, + and clicking the "Stake" button next to the proposal. {% endif %} You can view your transaction below:

diff --git a/backend/grant/templates/emails/staking_contribution_confirmed.txt b/backend/grant/templates/emails/staking_contribution_confirmed.txt index 54afffd0..d562fc3d 100644 --- a/backend/grant/templates/emails/staking_contribution_confirmed.txt +++ b/backend/grant/templates/emails/staking_contribution_confirmed.txt @@ -3,9 +3,9 @@ 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. +{{ args.contribution.amount }} ZEC of the required {{ args.stake_target}} ZEC. +You can send the remaining amount by going to your profile's "Pending" tab, +and clicking the "Stake" button next to the proposal. {% endif %} You can view your transaction here: diff --git a/backend/tests/config.py b/backend/tests/config.py index d72f45c0..c9b6bcfc 100644 --- a/backend/tests/config.py +++ b/backend/tests/config.py @@ -155,7 +155,7 @@ class BaseProposalCreatorConfig(BaseUserConfig): # 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)) + contribution.confirm(tx_id='tx', amount=str(PROPOSAL_STAKING_AMOUNT.normalize())) db.session.add(contribution) db.session.commit() contribution = self.proposal.get_staking_contribution(self.user.id) diff --git a/backend/tests/proposal/test_api.py b/backend/tests/proposal/test_api.py index db2228c8..dff7a2c9 100644 --- a/backend/tests/proposal/test_api.py +++ b/backend/tests/proposal/test_api.py @@ -115,7 +115,7 @@ class TestProposalAPI(BaseProposalCreatorConfig): 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)) + self.assertEquals(resp.json['amount'], str(PROPOSAL_STAKING_AMOUNT.normalize())) @patch('requests.get', side_effect=mock_blockchain_api_requests) def test_proposal_stake_no_auth(self, mock_get): diff --git a/frontend/client/components/ContributionModal/index.tsx b/frontend/client/components/ContributionModal/index.tsx index 6155bb38..fe327a86 100644 --- a/frontend/client/components/ContributionModal/index.tsx +++ b/frontend/client/components/ContributionModal/index.tsx @@ -8,10 +8,12 @@ import PaymentInfo from './PaymentInfo'; interface OwnProps { isVisible: boolean; + contribution?: ContributionWithAddresses | Falsy; proposalId?: number; contributionId?: number; amount?: string; hasNoButtons?: boolean; + text?: React.ReactNode; handleClose(): void; } @@ -30,22 +32,32 @@ export default class ContributionModal extends React.Component { error: null, }; + constructor(props: Props) { + super(props); + if (props.contribution) { + this.state = { + ...this.state, + contribution: props.contribution, + }; + } + } + componentWillUpdate(nextProps: Props) { - const { isVisible, proposalId, contributionId } = nextProps; + const { isVisible, proposalId, contributionId, contribution } = nextProps; // When modal is opened and proposalId is provided or changed if (isVisible && proposalId) { - if ( - this.props.isVisible !== isVisible || - proposalId !== this.props.proposalId - ) { + if (this.props.isVisible !== isVisible || proposalId !== this.props.proposalId) { this.fetchAddresses(proposalId, contributionId); } } - + // If contribution is provided + if (contribution !== this.props.contribution) { + this.setState({ contribution: contribution || null }); + } } render() { - const { isVisible, handleClose, hasNoButtons } = this.props; + const { isVisible, handleClose, hasNoButtons, text } = this.props; const { hasSent, contribution, error } = this.state; let content; @@ -68,7 +80,7 @@ export default class ContributionModal extends React.Component { if (error) { content = error; } else { - content = ; + content = ; } } @@ -89,22 +101,16 @@ export default class ContributionModal extends React.Component { ); } - private async fetchAddresses( - proposalId: number, - contributionId?: number, - ) { + private async fetchAddresses(proposalId: number, contributionId?: number) { try { let res; if (contributionId) { res = await getProposalContribution(proposalId, contributionId); } else { - res = await postProposalContribution( - proposalId, - this.props.amount || '0', - ); + res = await postProposalContribution(proposalId, this.props.amount || '0'); } this.setState({ contribution: res.data }); - } catch(err) { + } catch (err) { this.setState({ error: err.message }); } } diff --git a/frontend/client/components/Profile/ProfilePending.tsx b/frontend/client/components/Profile/ProfilePending.tsx index 460c3972..972a3ccb 100644 --- a/frontend/client/components/Profile/ProfilePending.tsx +++ b/frontend/client/components/Profile/ProfilePending.tsx @@ -1,15 +1,17 @@ import React, { ReactNode } from 'react'; import { Link } from 'react-router-dom'; import { Button, Popconfirm, message, Tag } from 'antd'; -import { UserProposal, STATUS } from 'types'; +import { UserProposal, STATUS, ContributionWithAddresses } from 'types'; +import ContributionModal from 'components/ContributionModal'; +import { getProposalStakingContribution } from 'api/api'; import { deletePendingProposal, publishPendingProposal } from 'modules/users/actions'; -import './ProfilePending.less'; import { connect } from 'react-redux'; import { AppState } from 'store/reducers'; +import './ProfilePending.less'; interface OwnProps { proposal: UserProposal; - onPublish: (id: UserProposal['proposalId']) => void; + onPublish(id: UserProposal['proposalId']): void; } interface StateProps { @@ -23,18 +25,24 @@ interface DispatchProps { type Props = OwnProps & StateProps & DispatchProps; -const STATE = { - isDeleting: false, - isPublishing: false, -}; - -type State = typeof STATE; +interface State { + isDeleting: boolean; + isPublishing: boolean; + isLoadingStake: boolean; + stakeContribution: ContributionWithAddresses | null; +} class ProfilePending extends React.Component { - state = STATE; + state: State = { + isDeleting: false, + isPublishing: false, + isLoadingStake: false, + stakeContribution: null, + }; + render() { const { status, title, proposalId, rejectReason } = this.props.proposal; - const { isDeleting, isPublishing } = this.state; + const { isDeleting, isPublishing, isLoadingStake, stakeContribution } = this.state; const isDisableActions = isDeleting || isPublishing; @@ -105,6 +113,15 @@ class ProfilePending extends React.Component { )} + {STATUS.STAKING === status && ( + + )} {
+ + {STATUS.STAKING && ( + + Please send the staking contribution of{' '} + {stakeContribution && stakeContribution.amount} ZEC using the + instructions below. Once your payment has been sent and confirmed, you + will receive an email. +

+ } + /> + )}
); } @@ -152,6 +185,25 @@ class ProfilePending extends React.Component { this.setState({ isDeleting: false }); } }; + + private openStakingModal = async () => { + try { + this.setState({ isLoadingStake: true }); + const res = await getProposalStakingContribution(this.props.proposal.proposalId); + this.setState({ stakeContribution: res.data }, () => { + this.setState({ isLoadingStake: false }); + }); + } catch (err) { + message.error(err.message, 3); + this.setState({ isLoadingStake: false }); + } + }; + + private closeStakingModal = () => + this.setState({ + isLoadingStake: false, + stakeContribution: null, + }); } export default connect(