Merge pull request #159 from grant-project/stake-topoff
Allow topping off of partial stake contributions. Fix float innaccura…
This commit is contained in:
commit
b8bc21d5da
|
@ -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):
|
||||||
|
|
|
@ -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:
|
||||||
|
|
|
@ -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',
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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:
|
||||||
|
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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):
|
||||||
|
|
|
@ -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 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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>(
|
||||||
|
|
Loading…
Reference in New Issue