Merge pull request #159 from grant-project/stake-topoff

Allow topping off of partial stake contributions. Fix float innaccura…
This commit is contained in:
Daniel Ternyak 2019-02-06 18:53:42 -06:00 committed by GitHub
commit b8bc21d5da
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 109 additions and 49 deletions

View File

@ -2,6 +2,7 @@ import datetime
from functools import reduce from functools import reduce
from sqlalchemy import func, or_ from sqlalchemy import func, or_
from sqlalchemy.ext.hybrid import hybrid_property from sqlalchemy.ext.hybrid import hybrid_property
from decimal import Decimal
from grant.comment.models import Comment from grant.comment.models import Comment
from grant.email.send import send_email from grant.email.send import send_email
@ -263,7 +264,7 @@ class Proposal(db.Model):
self.deadline_duration = deadline_duration self.deadline_duration = deadline_duration
Proposal.validate(vars(self)) Proposal.validate(vars(self))
def create_contribution(self, user_id: int, amount: float): def create_contribution(self, user_id: int, amount):
contribution = ProposalContribution( contribution = ProposalContribution(
proposal_id=self.id, proposal_id=self.id,
user_id=user_id, user_id=user_id,
@ -275,18 +276,16 @@ class Proposal(db.Model):
def get_staking_contribution(self, user_id: int): def get_staking_contribution(self, user_id: int):
contribution = None contribution = None
remaining = PROPOSAL_STAKING_AMOUNT - float(self.contributed) remaining = PROPOSAL_STAKING_AMOUNT - Decimal(self.contributed)
# check funding # check funding
if remaining > 0: if remaining > 0:
# find pending contribution for any user # find pending contribution for any user of remaining amount
# (always use full staking amout so we can find it)
contribution = ProposalContribution.query.filter_by( contribution = ProposalContribution.query.filter_by(
proposal_id=self.id, proposal_id=self.id,
amount=str(PROPOSAL_STAKING_AMOUNT),
status=PENDING, status=PENDING,
).first() ).first()
if not contribution: if not contribution:
contribution = self.create_contribution(user_id, PROPOSAL_STAKING_AMOUNT) contribution = self.create_contribution(user_id, str(remaining.normalize()))
return contribution return contribution
@ -345,14 +344,14 @@ class Proposal(db.Model):
contributions = ProposalContribution.query \ contributions = ProposalContribution.query \
.filter_by(proposal_id=self.id, status=ContributionStatus.CONFIRMED) \ .filter_by(proposal_id=self.id, status=ContributionStatus.CONFIRMED) \
.all() .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) return str(funded)
@hybrid_property @hybrid_property
def funded(self): def funded(self):
target = float(self.target) target = Decimal(self.target)
# apply matching multiplier # 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, just set as target
if funded > target: if funded > target:
return str(target) return str(target)
@ -361,7 +360,7 @@ class Proposal(db.Model):
@hybrid_property @hybrid_property
def is_staked(self): def is_staked(self):
return float(self.contributed) >= PROPOSAL_STAKING_AMOUNT return Decimal(self.contributed) >= PROPOSAL_STAKING_AMOUNT
class ProposalSchema(ma.Schema): class ProposalSchema(ma.Schema):

View File

@ -237,6 +237,7 @@ def delete_proposal(proposal_id):
ProposalStatus.PENDING, ProposalStatus.PENDING,
ProposalStatus.APPROVED, ProposalStatus.APPROVED,
ProposalStatus.REJECTED, ProposalStatus.REJECTED,
ProposalStatus.STAKING,
] ]
status = g.current_proposal.status status = g.current_proposal.status
if status not in deleteable_statuses: 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: if contribution.proposal.status == ProposalStatus.STAKING:
# fully staked, set status PENDING & notify user # 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 contribution.proposal.status = ProposalStatus.PENDING
db.session.add(contribution.proposal) db.session.add(contribution.proposal)
db.session.commit() db.session.commit()
@ -493,7 +494,7 @@ def post_contribution_confirmation(contribution_id, to, amount, txid):
'proposal': contribution.proposal, 'proposal': contribution.proposal,
'tx_explorer_url': f'{EXPLORER_URL}transactions/{txid}', 'tx_explorer_url': f'{EXPLORER_URL}transactions/{txid}',
'fully_staked': contribution.proposal.is_staked, 'fully_staked': contribution.proposal.is_staked,
'stake_target': PROPOSAL_STAKING_AMOUNT 'stake_target': str(PROPOSAL_STAKING_AMOUNT.normalize()),
}) })
else: else:

View File

@ -7,6 +7,7 @@ For local development, use a .env file to set
environment variables. environment variables.
""" """
from environs import Env from environs import Env
from decimal import Decimal
env = Env() env = Env()
env.read_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/") 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 = { UI = {
'NAME': 'ZF Grants', 'NAME': 'ZF Grants',

View File

@ -5,9 +5,10 @@
will now be forwarded to administrators for approval. will now be forwarded to administrators for approval.
{% else %} {% else %}
<strong>{{ args.proposal.title }}</strong> has been partially staked for <strong>{{ args.proposal.title }}</strong> has been partially staked for
<strong>{{ args.contribution.amount }} ZEC</strong>. This is not enough to <strong>{{ args.contribution.amount }} ZEC</strong> of the required
fully stake the proposal. You must send at least <strong>{{ args.stake_target}} ZEC</strong>.
<strong>{{ args.stake_target }} ZEC</strong>. You can send the remaining amount by going to your profile's "Pending" tab,
and clicking the "Stake" button next to the proposal.
{% endif %} {% endif %}
You can view your transaction below: You can view your transaction below:
</p> </p>

View File

@ -3,9 +3,9 @@
Your proposal will now be forwarded to administrators for approval. Your proposal will now be forwarded to administrators for approval.
{% else %} {% else %}
{{ args.proposal.title }} has been partially staked for {{ args.proposal.title }} has been partially staked for
{{ args.contribution.amount }} ZEC. This is not enough to {{ args.contribution.amount }} ZEC of the required {{ args.stake_target}} ZEC.
fully stake the proposal. You must send at least You can send the remaining amount by going to your profile's "Pending" tab,
{{ args.stake_target }} ZEC. and clicking the "Stake" button next to the proposal.
{% endif %} {% endif %}
You can view your transaction here: You can view your transaction here:

View File

@ -155,7 +155,7 @@ class BaseProposalCreatorConfig(BaseUserConfig):
# 2. get staking contribution # 2. get staking contribution
contribution = self.proposal.get_staking_contribution(self.user.id) contribution = self.proposal.get_staking_contribution(self.user.id)
# 3. fake a confirmation # 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.add(contribution)
db.session.commit() db.session.commit()
contribution = self.proposal.get_staking_contribution(self.user.id) contribution = self.proposal.get_staking_contribution(self.user.id)

View File

@ -115,7 +115,7 @@ class TestProposalAPI(BaseProposalCreatorConfig):
resp = self.app.get(f"/api/v1/proposals/{self.proposal.id}/stake") resp = self.app.get(f"/api/v1/proposals/{self.proposal.id}/stake")
print(resp) print(resp)
self.assert200(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) @patch('requests.get', side_effect=mock_blockchain_api_requests)
def test_proposal_stake_no_auth(self, mock_get): def test_proposal_stake_no_auth(self, mock_get):

View File

@ -8,10 +8,12 @@ import PaymentInfo from './PaymentInfo';
interface OwnProps { interface OwnProps {
isVisible: boolean; isVisible: boolean;
contribution?: ContributionWithAddresses | Falsy;
proposalId?: number; proposalId?: number;
contributionId?: number; contributionId?: number;
amount?: string; amount?: string;
hasNoButtons?: boolean; hasNoButtons?: boolean;
text?: React.ReactNode;
handleClose(): void; handleClose(): void;
} }
@ -30,22 +32,32 @@ export default class ContributionModal extends React.Component<Props, State> {
error: null, error: null,
}; };
constructor(props: Props) {
super(props);
if (props.contribution) {
this.state = {
...this.state,
contribution: props.contribution,
};
}
}
componentWillUpdate(nextProps: Props) { componentWillUpdate(nextProps: Props) {
const { isVisible, proposalId, contributionId } = nextProps; const { isVisible, proposalId, contributionId, contribution } = nextProps;
// When modal is opened and proposalId is provided or changed // When modal is opened and proposalId is provided or changed
if (isVisible && proposalId) { if (isVisible && proposalId) {
if ( if (this.props.isVisible !== isVisible || proposalId !== this.props.proposalId) {
this.props.isVisible !== isVisible ||
proposalId !== this.props.proposalId
) {
this.fetchAddresses(proposalId, contributionId); this.fetchAddresses(proposalId, contributionId);
} }
} }
// If contribution is provided
if (contribution !== this.props.contribution) {
this.setState({ contribution: contribution || null });
}
} }
render() { render() {
const { isVisible, handleClose, hasNoButtons } = this.props; const { isVisible, handleClose, hasNoButtons, text } = this.props;
const { hasSent, contribution, error } = this.state; const { hasSent, contribution, error } = this.state;
let content; let content;
@ -68,7 +80,7 @@ export default class ContributionModal extends React.Component<Props, State> {
if (error) { if (error) {
content = error; content = error;
} else { } else {
content = <PaymentInfo contribution={contribution} />; content = <PaymentInfo contribution={contribution} text={text} />;
} }
} }
@ -89,22 +101,16 @@ export default class ContributionModal extends React.Component<Props, State> {
); );
} }
private async fetchAddresses( private async fetchAddresses(proposalId: number, contributionId?: number) {
proposalId: number,
contributionId?: number,
) {
try { try {
let res; let res;
if (contributionId) { if (contributionId) {
res = await getProposalContribution(proposalId, contributionId); res = await getProposalContribution(proposalId, contributionId);
} else { } else {
res = await postProposalContribution( res = await postProposalContribution(proposalId, this.props.amount || '0');
proposalId,
this.props.amount || '0',
);
} }
this.setState({ contribution: res.data }); this.setState({ contribution: res.data });
} catch(err) { } catch (err) {
this.setState({ error: err.message }); this.setState({ error: err.message });
} }
} }

View File

@ -1,15 +1,17 @@
import React, { ReactNode } from 'react'; import React, { ReactNode } from 'react';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { Button, Popconfirm, message, Tag } from 'antd'; 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 { deletePendingProposal, publishPendingProposal } from 'modules/users/actions';
import './ProfilePending.less';
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import { AppState } from 'store/reducers'; import { AppState } from 'store/reducers';
import './ProfilePending.less';
interface OwnProps { interface OwnProps {
proposal: UserProposal; proposal: UserProposal;
onPublish: (id: UserProposal['proposalId']) => void; onPublish(id: UserProposal['proposalId']): void;
} }
interface StateProps { interface StateProps {
@ -23,18 +25,24 @@ interface DispatchProps {
type Props = OwnProps & StateProps & DispatchProps; type Props = OwnProps & StateProps & DispatchProps;
const STATE = { interface State {
isDeleting: false, isDeleting: boolean;
isPublishing: false, isPublishing: boolean;
}; isLoadingStake: boolean;
stakeContribution: ContributionWithAddresses | null;
type State = typeof STATE; }
class ProfilePending extends React.Component<Props, State> { class ProfilePending extends React.Component<Props, State> {
state = STATE; state: State = {
isDeleting: false,
isPublishing: false,
isLoadingStake: false,
stakeContribution: null,
};
render() { render() {
const { status, title, proposalId, rejectReason } = this.props.proposal; const { status, title, proposalId, rejectReason } = this.props.proposal;
const { isDeleting, isPublishing } = this.state; const { isDeleting, isPublishing, isLoadingStake, stakeContribution } = this.state;
const isDisableActions = isDeleting || isPublishing; const isDisableActions = isDeleting || isPublishing;
@ -105,6 +113,15 @@ class ProfilePending extends React.Component<Props, State> {
</Button> </Button>
</Link> </Link>
)} )}
{STATUS.STAKING === status && (
<Button
type="primary"
loading={isLoadingStake}
onClick={this.openStakingModal}
>
Stake
</Button>
)}
<Popconfirm <Popconfirm
key="delete" key="delete"
@ -116,6 +133,22 @@ class ProfilePending extends React.Component<Props, State> {
</Button> </Button>
</Popconfirm> </Popconfirm>
</div> </div>
{STATUS.STAKING && (
<ContributionModal
isVisible={!!stakeContribution}
contribution={stakeContribution}
handleClose={this.closeStakingModal}
text={
<p>
Please send the staking contribution of{' '}
<b>{stakeContribution && stakeContribution.amount} ZEC</b> using the
instructions below. Once your payment has been sent and confirmed, you
will receive an email.
</p>
}
/>
)}
</div> </div>
); );
} }
@ -152,6 +185,25 @@ class ProfilePending extends React.Component<Props, State> {
this.setState({ isDeleting: false }); 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<StateProps, DispatchProps, OwnProps, AppState>( export default connect<StateProps, DispatchProps, OwnProps, AppState>(