Working Refunds (#32)

* Add and use placeholder component.

* Allow debugging from truffle

* Implement refunding

* Fix tsc

* Double transaction for first refund.
This commit is contained in:
William O'Beirne 2018-09-13 23:48:01 -04:00 committed by Daniel Ternyak
parent ab66cf6ea4
commit df1160acf5
12 changed files with 350 additions and 57 deletions

View File

@ -0,0 +1,17 @@
import React from 'react';
import * as Styled from './styled';
interface Props {
title?: React.ReactNode;
subtitle?: React.ReactNode;
style?: React.CSSProperties;
}
const Placeholder: React.SFC<Props> = ({ style = {}, title, subtitle }) => (
<Styled.Container style={style}>
{title && <Styled.Title>{title}</Styled.Title>}
{subtitle && <Styled.Subtitle>{subtitle}</Styled.Subtitle>}
</Styled.Container>
);
export default Placeholder;

View File

@ -0,0 +1,26 @@
import styled from 'styled-components';
export const Container = styled.div`
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
border: 2px dashed #d9d9d9;
padding: 3rem;
border-radius: 8px;
`;
export const Title = styled.h3`
margin-bottom: 0;
color: rgba(0, 0, 0, 0.6);
font-size: 1.6rem;
& + div {
margin-top: 1rem;
}
`;
export const Subtitle = styled.div`
color: rgba(0, 0, 0, 0.4);
font-size: 1rem;
`;

View File

@ -187,30 +187,15 @@ class Milestones extends React.Component<Props> {
<>
<div style={{ display: 'flex', alignItems: 'center' }}>
{showVoteProgress && (
<div
style={{
display: 'flex',
flexDirection: 'column',
margin: '0 2rem 0.5rem 0',
textAlign: 'center',
}}
>
<Styled.ProgressContainer>
<Progress
type="dashboard"
percent={activeVoteMilestone.percentAgainstPayout}
format={p => `${p}%`}
status="exception"
/>
<div
style={{
whiteSpace: 'nowrap',
opacity: 0.6,
fontSize: '0.75rem',
}}
>
voted against payout
</div>
</div>
<Styled.ProgressText>voted against payout</Styled.ProgressText>
</Styled.ProgressContainer>
)}
<div>
{content}

View File

@ -1,56 +1,182 @@
import React from 'react';
import { Spin, Progress, Button } from 'antd';
import { connect } from 'react-redux';
import { Spin, Progress, Button, Alert } from 'antd';
import { ProposalWithCrowdFund } from 'modules/proposals/reducers';
import Web3Container, { Web3RenderProps } from 'lib/Web3Container';
import { web3Actions } from 'modules/web3';
import { AppState } from 'store/reducers';
import * as Styled from './styled';
interface OwnProps {
proposal: ProposalWithCrowdFund;
}
interface Web3Props {
web3: Web3RenderProps['web3'];
interface StateProps {
isRefundActionPending: AppState['web3']['isRefundActionPending'];
refundActionError: AppState['web3']['refundActionError'];
}
type Props = OwnProps & Web3Props;
interface ActionProps {
voteRefund: typeof web3Actions['voteRefund'];
withdrawRefund: typeof web3Actions['withdrawRefund'];
}
interface Web3Props {
web3: Web3RenderProps['web3'];
account: Web3RenderProps['accounts'][0];
}
type Props = OwnProps & StateProps & ActionProps & Web3Props;
class GovernanceRefunds extends React.Component<Props> {
render() {
const fundPct = 32;
const { proposal, account, isRefundActionPending, refundActionError } = this.props;
const { crowdFund } = proposal;
const contributor = crowdFund.contributors.find(c => c.address === account);
const isTrustee = crowdFund.trustees.includes(account);
const hasVotedForRefund = contributor && contributor.refundVote;
const hasRefunded = contributor && contributor.refunded;
const refundPct = Math.floor(
(crowdFund.amountVotingForRefund / crowdFund.target) * 100,
);
const color = refundPct < 10 ? '#1890ff' : refundPct < 50 ? '#faad14' : '#f5222d';
let text;
let button;
if (!isTrustee && contributor) {
if (refundPct < 50) {
text = `
As a funder of this project, you have the right to vote for a refund. If the
amount of funds contributed by refund voters exceeds half of the project's
total raised funds, all funders will be able to request refunds.
`;
if (hasVotedForRefund) {
button = {
text: 'Undo vote for refund',
type: 'danger',
onClick: () => this.voteRefund(false),
};
} else {
button = {
text: 'Vote for refund',
type: 'danger',
onClick: () => this.voteRefund(true),
};
}
} else {
if (hasRefunded) {
return (
<Alert
type="success"
message="Your refund has been processed"
description={`
We apologize for any inconvenience this propsal has caused you. Please
let us know if there's anything we could have done to improve your
experience.
`}
showIcon
/>
);
} else {
text = (
<>
The majority of funders have voted for a refund. Click below to receive your
refund.
{!crowdFund.isFrozen && (
<Alert
style={{ marginTop: '1rem' }}
type="info"
message={`
This will require multiple transactions to process, sorry
for the inconvenience
`}
showIcon
/>
)}
</>
);
button = {
text: 'Get your refund',
type: 'primary',
onClick: () => this.withdrawRefund(),
};
}
}
} else {
if (refundPct < 50) {
text = `
Funders can vote to request refunds. If the amount of funds contributed by
refund voters exceeds half of the funds contributed, all funders will be able
to request refunds.
`;
} else {
text = `
The funders of this project have voted for a refund. All funders can request refunds,
and the project will no longer receive any payouts.
`;
}
}
return (
<div
style={{
display: 'flex',
alignItems: 'center',
}}
>
<div style={{ textAlign: 'center', marginRight: '2rem' }}>
<Progress
type="dashboard"
percent={fundPct}
format={p => `${p}%`}
status="exception"
<>
<div style={{ display: 'flex', alignItems: 'center' }}>
<Styled.ProgressContainer stroke={color}>
<Progress type="dashboard" percent={refundPct} format={p => `${p}%`} />
<Styled.ProgressText>voted for a refund</Styled.ProgressText>
</Styled.ProgressContainer>
<div>
<p style={{ fontSize: '1rem' }}>{text}</p>
{button && (
<Button
type={button.type as any}
onClick={button.onClick}
loading={isRefundActionPending}
block
>
{button.text}
</Button>
)}
</div>
</div>
{refundActionError && (
<Alert
type="error"
message="Something went wrong!"
description={refundActionError}
style={{ margin: '1rem 0 0' }}
showIcon
/>
<p style={{ opacity: 0.6, fontSize: '0.75rem' }}>voted for a refund</p>
</div>
<div>
<p style={{ fontSize: '1rem' }}>
As a funder of this project, you have the right to vote for a refund. If the
amount of funds contributed by refund voters exceeds half of the project's
total raised funds, a refund will be issued to everyone.
</p>
<Button type="danger" block>
Vote for a Refund
</Button>
</div>
</div>
)}
</>
);
}
voteRefund = (vote: boolean) => {
this.props.voteRefund(this.props.proposal.crowdFundContract, vote);
};
withdrawRefund = () => {
const { proposal, account } = this.props;
this.props.withdrawRefund(proposal.crowdFundContract, account);
};
}
const ConnectedGovernanceRefunds = connect<StateProps, ActionProps, OwnProps, AppState>(
state => ({
isRefundActionPending: state.web3.isRefundActionPending,
refundActionError: state.web3.refundActionError,
}),
{
voteRefund: web3Actions.voteRefund,
withdrawRefund: web3Actions.withdrawRefund,
},
)(GovernanceRefunds);
export default (props: OwnProps) => (
<Web3Container
renderLoading={() => <Spin />}
render={({ web3 }: Web3RenderProps) => <GovernanceRefunds web3={web3} {...props} />}
render={({ web3, accounts }: Web3RenderProps) => (
<ConnectedGovernanceRefunds web3={web3} account={accounts[0]} {...props} />
)}
/>
);

View File

@ -2,6 +2,7 @@ import React from 'react';
import GovernanceMilestones from './Milestones';
import GovernanceRefunds from './Refunds';
import { ProposalWithCrowdFund } from 'modules/proposals/reducers';
import Placeholder from 'components/Placeholder';
import * as Styled from './styled';
interface Props {
@ -14,10 +15,14 @@ export default class ProposalGovernance extends React.Component<Props> {
if (!proposal.crowdFund.isRaiseGoalReached) {
return (
<p>
Milestone history and voting will be displayed here once the project has been
funded.
</p>
<Placeholder
style={{ minHeight: '220px' }}
title="Governance isnt available yet"
subtitle={`
Milestone history and voting will be displayed here once the
project has been funded
`}
/>
);
}

View File

@ -28,3 +28,22 @@ export const GovernanceDivider = styled.div`
export const MilestoneActionText = styled.p`
font-size: 1rem;
`;
// Shared
export const ProgressContainer = styled<{ stroke?: string }, 'div'>('div')`
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
margin: 0 2rem 0.5rem 0;
.ant-progress-circle-path {
stroke: ${p => p.stroke || 'inherit'};
}
`;
export const ProgressText = styled.div`
white-space: nowrap;
opacity: 0.6;
font-size: 0.75rem;
`;

View File

@ -16,6 +16,7 @@ export interface Contributor {
address: string;
contributionAmount: string;
refundVote: boolean;
refunded: boolean;
proportionalContribution: string;
milestoneNoVotes: boolean[];
}
@ -52,8 +53,10 @@ export interface ProposalMilestone extends Milestone {
export interface CrowdFund {
immediateFirstMilestonePayout: boolean;
balance: number;
funded: number;
target: number;
amountVotingForRefund: number;
beneficiary: string;
deadline: number;
trustees: string[];

View File

@ -301,3 +301,71 @@ export function voteMilestonePayout(
}
};
}
export function voteRefund(crowdFundContract: any, vote: boolean) {
return async (dispatch: Dispatch<any>, getState: GetState) => {
dispatch({ type: types.VOTE_REFUND_PENDING });
const state = getState();
const account = state.web3.accounts[0];
try {
await crowdFundContract.methods
.voteRefund(vote)
.send({ from: account })
.once('confirmation', async () => {
await sleep(5000);
await dispatch(fetchProposal(crowdFundContract._address));
dispatch({ type: types.VOTE_REFUND_FULFILLED });
});
} catch (err) {
dispatch({
type: types.VOTE_REFUND_REJECTED,
payload: err.message || err.toString(),
error: true,
});
}
};
}
export function withdrawRefund(crowdFundContract: any, address: string) {
return async (dispatch: Dispatch<any>, getState: GetState) => {
dispatch({ type: types.WITHDRAW_REFUND_PENDING });
const state = getState();
const account = state.web3.accounts[0];
try {
let isFrozen = await crowdFundContract.methods.frozen().call({ from: account });
if (!isFrozen) {
await new Promise(resolve => {
crowdFundContract.methods
.refund()
.send({ from: account })
.once('confirmation', async () => {
await sleep(5000);
isFrozen = await crowdFundContract.methods.frozen().call({ from: account });
resolve();
});
});
}
if (!isFrozen) {
throw new Error('Proposal isnt in a refundable state yet.');
}
await crowdFundContract.methods
.withdraw(address)
.send({ from: account })
.once('confirmation', async () => {
await sleep(5000);
await dispatch(fetchProposal(crowdFundContract._address));
dispatch({ type: types.WITHDRAW_REFUND_FULFILLED });
});
} catch (err) {
dispatch({
type: types.WITHDRAW_REFUND_REJECTED,
payload: err.message || err.toString(),
error: true,
});
}
};
}

View File

@ -27,6 +27,9 @@ export interface Web3State {
isMilestoneActionPending: boolean;
milestoneActionError: null | string;
isRefundActionPending: boolean;
refundActionError: null | string;
}
export const INITIAL_STATE: Web3State = {
@ -52,6 +55,9 @@ export const INITIAL_STATE: Web3State = {
isMilestoneActionPending: false,
milestoneActionError: null,
isRefundActionPending: false,
refundActionError: null,
};
function addContract(state: Web3State, payload: Contract) {
@ -198,6 +204,27 @@ export default (state = INITIAL_STATE, action: any): Web3State => {
isMilestoneActionPending: false,
};
case types.VOTE_REFUND_PENDING:
case types.WITHDRAW_REFUND_PENDING:
return {
...state,
isRefundActionPending: true,
refundActionError: null,
};
case types.VOTE_REFUND_FULFILLED:
case types.WITHDRAW_REFUND_FULFILLED:
return {
...state,
isRefundActionPending: false,
};
case types.VOTE_REFUND_REJECTED:
case types.WITHDRAW_REFUND_REJECTED:
return {
...state,
refundActionError: payload,
isRefundActionPending: false,
};
default:
return state;
}

View File

@ -35,6 +35,16 @@ enum web3Types {
VOTE_AGAINST_MILESTONE_PAYOUT_REJECTED = 'VOTE_AGAINST_MILESTONE_PAYOUT_REJECTED',
VOTE_AGAINST_MILESTONE_PAYOUT_PENDING = 'VOTE_AGAINST_MILESTONE_PAYOUT_PENDING',
VOTE_REFUND = 'VOTE_REFUND',
VOTE_REFUND_FULFILLED = 'VOTE_REFUND_FULFILLED',
VOTE_REFUND_REJECTED = 'VOTE_REFUND_REJECTED',
VOTE_REFUND_PENDING = 'VOTE_REFUND_PENDING',
WITHDRAW_REFUND = 'WITHDRAW_REFUND',
WITHDRAW_REFUND_FULFILLED = 'WITHDRAW_REFUND_FULFILLED',
WITHDRAW_REFUND_REJECTED = 'WITHDRAW_REFUND_REJECTED',
WITHDRAW_REFUND_PENDING = 'WITHDRAW_REFUND_PENDING',
ACCOUNTS = 'ACCOUNTS',
ACCOUNTS_FULFILLED = 'ACCOUNTS_FULFILLED',
ACCOUNTS_REJECTED = 'ACCOUNTS_REJECTED',

View File

@ -17,9 +17,11 @@ export async function getCrowdFundState(
const isRaiseGoalReached = await crowdFundContract.methods
.isRaiseGoalReached()
.call({ from: account });
const funded = isRaiseGoalReached
? target
: await web3.eth.getBalance(crowdFundContract._address);
const balance = await web3.eth.getBalance(crowdFundContract._address);
const funded = isRaiseGoalReached ? target : balance;
const amountVotingForRefund = isRaiseGoalReached
? await crowdFundContract.methods.amountVotingForRefund().call({ from: account })
: '0';
const isFrozen = await crowdFundContract.methods.frozen().call({ from: account });
const trustees = await collectArrayElements<string>(
@ -115,8 +117,13 @@ export async function getCrowdFundState(
return {
immediateFirstMilestonePayout,
// TODO: Bignumber these 4
balance: parseFloat(web3.utils.fromWei(String(balance), 'ether')),
funded: parseFloat(web3.utils.fromWei(String(funded), 'ether')),
target: parseFloat(web3.utils.fromWei(String(target), 'ether')),
amountVotingForRefund: parseFloat(
web3.utils.fromWei(String(amountVotingForRefund), 'ether'),
),
beneficiary,
deadline,
trustees,

View File

@ -12,7 +12,7 @@
"tsc": "tsc",
"link-contracts": "cd client/lib && ln -s ../../build/contracts contracts",
"ganache": "ganache-cli -b 5",
"truffle": "truffle exec ./bin/init-truffle.js && truffle console"
"truffle": "truffle exec ./bin/init-truffle.js && cd client/lib/contracts && truffle console"
},
"husky": {
"hooks": {