Backend Proposal Reads Pt. 2 (#209)
* web3 flask + read proposal * tests * use build/contracts indtead of build/abi * fail if endpoint not set * batched calls * add web3 read to GET proposal(s) endpoints * basic integration of BE crowdFund data into FE * handle dead contracts & omit on FE * allow web3-free viewing & move crowdFundContract out of redux store * upgrade flask-yolo2API to 0.2.6 * MetaMaskRequiredButton + use it in CampaignBlock * convert to tuples * farewell tuples * flter dead proposals on BE * give test_proposal_funded deadline more time
This commit is contained in:
parent
03de8c2543
commit
b177e7efa9
|
@ -8,6 +8,7 @@ from grant.comment.models import Comment, comment_schema
|
|||
from grant.milestone.models import Milestone
|
||||
from grant.user.models import User, SocialMedia, Avatar
|
||||
from grant.utils.auth import requires_sm, requires_team_member_auth
|
||||
from grant.web3.proposal import read_proposal
|
||||
from .models import Proposal, proposals_schema, proposal_schema, ProposalUpdate, proposal_update_schema, db
|
||||
|
||||
blueprint = Blueprint("proposal", __name__, url_prefix="/api/v1/proposals")
|
||||
|
@ -19,6 +20,10 @@ def get_proposal(proposal_id):
|
|||
proposal = Proposal.query.filter_by(id=proposal_id).first()
|
||||
if proposal:
|
||||
dumped_proposal = proposal_schema.dump(proposal)
|
||||
proposal_contract = read_proposal(dumped_proposal['proposal_address'])
|
||||
if not proposal_contract:
|
||||
return {"message": "Proposal retired"}, 404
|
||||
dumped_proposal['crowd_fund'] = proposal_contract
|
||||
return dumped_proposal
|
||||
else:
|
||||
return {"message": "No proposal matching id"}, 404
|
||||
|
@ -68,13 +73,17 @@ def get_proposals(stage):
|
|||
if stage:
|
||||
proposals = (
|
||||
Proposal.query.filter_by(stage=stage)
|
||||
.order_by(Proposal.date_created.desc())
|
||||
.all()
|
||||
.order_by(Proposal.date_created.desc())
|
||||
.all()
|
||||
)
|
||||
else:
|
||||
proposals = Proposal.query.order_by(Proposal.date_created.desc()).all()
|
||||
dumped_proposals = proposals_schema.dump(proposals)
|
||||
return dumped_proposals
|
||||
for p in dumped_proposals:
|
||||
proposal_contract = read_proposal(p['proposal_address'])
|
||||
p['crowd_fund'] = proposal_contract
|
||||
filtered_proposals = list(filter(lambda p: p['crowd_fund'] is not None, dumped_proposals))
|
||||
return filtered_proposals
|
||||
|
||||
|
||||
@blueprint.route("/", methods=["POST"])
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
import json
|
||||
import time
|
||||
from flask_web3 import current_web3
|
||||
from .util import batch_call, call_array
|
||||
from .util import batch_call, call_array, RpcError
|
||||
|
||||
|
||||
crowd_fund_abi = None
|
||||
|
@ -17,6 +17,7 @@ def get_crowd_fund_abi():
|
|||
|
||||
|
||||
def read_proposal(address):
|
||||
current_web3.eth.defaultAccount = current_web3.eth.accounts[0]
|
||||
crowd_fund_abi = get_crowd_fund_abi()
|
||||
contract = current_web3.eth.contract(address=address, abi=crowd_fund_abi)
|
||||
|
||||
|
@ -34,7 +35,11 @@ def read_proposal(address):
|
|||
|
||||
# batched
|
||||
calls = list(map(lambda x: [x, None], methods))
|
||||
crowd_fund = batch_call(current_web3, address, crowd_fund_abi, calls, contract)
|
||||
try:
|
||||
crowd_fund = batch_call(current_web3, address, crowd_fund_abi, calls, contract)
|
||||
# catch dead contracts here
|
||||
except RpcError:
|
||||
return None
|
||||
|
||||
# balance (sync)
|
||||
crowd_fund['balance'] = current_web3.eth.getBalance(address)
|
||||
|
|
|
@ -9,6 +9,10 @@ from web3.utils.abi import get_abi_output_types, map_abi_data
|
|||
from web3.utils.normalizers import BASE_RETURN_NORMALIZERS
|
||||
|
||||
|
||||
class RpcError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
def call_array(fn):
|
||||
results = []
|
||||
no_error = True
|
||||
|
@ -51,6 +55,8 @@ def batch_call(w3, address, abi, calls, contract):
|
|||
# this implements batched rpc calls using web3py helper methods
|
||||
# web3py doesn't support this out-of-box yet
|
||||
# issue: https://github.com/ethereum/web3.py/issues/832
|
||||
if not calls:
|
||||
return []
|
||||
if type(w3.providers[0]) is EthereumTesterProvider:
|
||||
return tester_batch(calls, contract)
|
||||
inputs = []
|
||||
|
@ -60,6 +66,9 @@ def batch_call(w3, address, abi, calls, contract):
|
|||
prepared = prepare_transaction(address, w3, name, abi, None, tx, args)
|
||||
inputs.append([prepared, 'latest'])
|
||||
responses = batch(ETHEREUM_ENDPOINT_URI, inputs)
|
||||
if 'error' in responses[0]:
|
||||
message = responses[0]['error']['message'] if 'message' in responses[0]['error'] else 'No error message found.'
|
||||
raise RpcError("rpc error: {0}".format(message))
|
||||
results = {}
|
||||
for r in zip(calls, responses):
|
||||
result = HexBytes(r[1]['result'])
|
||||
|
|
|
@ -60,4 +60,3 @@ flask-yolo2API==0.2.6
|
|||
#web3
|
||||
flask-web3==0.1.1
|
||||
web3==4.8.1
|
||||
|
||||
|
|
|
@ -58,9 +58,7 @@ class TestWeb3ProposalRead(BaseTestConfig):
|
|||
"isImmediatePayout": True
|
||||
}
|
||||
],
|
||||
"trustees": [
|
||||
current_web3.eth.accounts[0]
|
||||
],
|
||||
"trustees": [current_web3.eth.accounts[0]],
|
||||
"contributors": [],
|
||||
"target": "5000000000000000000",
|
||||
"isFrozen": False,
|
||||
|
@ -74,9 +72,7 @@ class TestWeb3ProposalRead(BaseTestConfig):
|
|||
"contributionAmount": str(c[1] * 1000000000000000000),
|
||||
"refundVote": False,
|
||||
"refunded": False,
|
||||
"milestoneNoVotes": [
|
||||
False
|
||||
]
|
||||
"milestoneNoVotes": [False]
|
||||
})
|
||||
return mock_proposal_read
|
||||
|
||||
|
@ -111,7 +107,7 @@ class TestWeb3ProposalRead(BaseTestConfig):
|
|||
deadline = proposal_read.pop('deadline')
|
||||
deadline_diff = deadline - time.time() * 1000
|
||||
self.assertGreater(60000, deadline_diff)
|
||||
self.assertGreater(deadline_diff, 58000)
|
||||
self.assertGreater(deadline_diff, 50000)
|
||||
self.maxDiff = None
|
||||
self.assertEqual(proposal_read, self.get_mock_proposal_read())
|
||||
|
||||
|
|
|
@ -74,7 +74,7 @@ const routeConfigs: RouteConfig[] = [
|
|||
},
|
||||
template: {
|
||||
title: 'Browse proposals',
|
||||
requiresWeb3: true,
|
||||
requiresWeb3: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
|
@ -85,7 +85,7 @@ const routeConfigs: RouteConfig[] = [
|
|||
},
|
||||
template: {
|
||||
title: 'Proposal',
|
||||
requiresWeb3: true,
|
||||
requiresWeb3: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
|
|
|
@ -3,25 +3,20 @@ import { Proposal, TeamMember, Update } from 'types';
|
|||
import {
|
||||
formatTeamMemberForPost,
|
||||
formatTeamMemberFromGet,
|
||||
generateProposalUrl,
|
||||
formatProposalFromGet,
|
||||
} from 'utils/api';
|
||||
import { PROPOSAL_CATEGORY } from './constants';
|
||||
|
||||
export function getProposals(): Promise<{ data: Proposal[] }> {
|
||||
return axios.get('/api/v1/proposals/').then(res => {
|
||||
res.data = res.data.map((proposal: any) => {
|
||||
proposal.team = proposal.team.map(formatTeamMemberFromGet);
|
||||
proposal.proposalUrlId = generateProposalUrl(proposal.proposalId, proposal.title);
|
||||
return proposal;
|
||||
});
|
||||
res.data = res.data.map(formatProposalFromGet);
|
||||
return res;
|
||||
});
|
||||
}
|
||||
|
||||
export function getProposal(proposalId: number | string): Promise<{ data: Proposal }> {
|
||||
return axios.get(`/api/v1/proposals/${proposalId}`).then(res => {
|
||||
res.data.team = res.data.team.map(formatTeamMemberFromGet);
|
||||
res.data.proposalUrlId = generateProposalUrl(res.data.proposalId, res.data.title);
|
||||
res.data = formatProposalFromGet(res.data);
|
||||
return res;
|
||||
});
|
||||
}
|
||||
|
|
|
@ -0,0 +1,32 @@
|
|||
.MetaMaskRequiredButton {
|
||||
display: block;
|
||||
text-align: center;
|
||||
padding: 0;
|
||||
height: 3rem;
|
||||
line-height: 3rem;
|
||||
font-size: 1.2rem;
|
||||
color: #fff;
|
||||
background: #f88500;
|
||||
border-radius: 4px;
|
||||
|
||||
&:hover {
|
||||
color: #fff;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
&-logo {
|
||||
display: inline-block;
|
||||
vertical-align: middle;
|
||||
height: 2rem;
|
||||
width: 2rem;
|
||||
padding: 0.4rem;
|
||||
border-radius: 1.25rem;
|
||||
background: rgba(255, 255, 255, 0.6);
|
||||
margin: 0 1rem 0 0;
|
||||
|
||||
& > img {
|
||||
display: block;
|
||||
height: 1.2rem;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,82 @@
|
|||
import React from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
import { AppState } from 'store/reducers';
|
||||
import { web3Actions } from 'modules/web3';
|
||||
import { Alert } from 'antd';
|
||||
import metaMaskImgSrc from 'static/images/metamask.png';
|
||||
import './index.less';
|
||||
|
||||
interface OwnProps {
|
||||
message: React.ReactNode;
|
||||
}
|
||||
|
||||
interface StateProps {
|
||||
isMissingWeb3: boolean;
|
||||
isWeb3Locked: boolean;
|
||||
isWrongNetwork: boolean;
|
||||
}
|
||||
|
||||
interface DispatchProps {
|
||||
setAccounts: typeof web3Actions['setAccounts'];
|
||||
}
|
||||
|
||||
type Props = OwnProps & StateProps & DispatchProps;
|
||||
|
||||
class MetaMaskRequiredButton extends React.PureComponent<Props> {
|
||||
render() {
|
||||
const { isMissingWeb3, isWeb3Locked, isWrongNetwork, children, message } = this.props;
|
||||
const displayMessage =
|
||||
((isMissingWeb3 || isWeb3Locked || isWrongNetwork) && message) || null;
|
||||
return (
|
||||
<>
|
||||
{displayMessage}
|
||||
{isMissingWeb3 ? (
|
||||
<a
|
||||
className="MetaMaskRequiredButton"
|
||||
href="https://metamask.io/"
|
||||
target="_blank"
|
||||
rel="noopener nofollow"
|
||||
>
|
||||
<div className="MetaMaskRequiredButton-logo">
|
||||
<img src={metaMaskImgSrc} />
|
||||
</div>
|
||||
MetaMask required
|
||||
</a>
|
||||
) : isWeb3Locked ? (
|
||||
<Alert
|
||||
type="warning"
|
||||
message={
|
||||
<>
|
||||
It looks like your MetaMask account is locked. Please unlock it and{' '}
|
||||
<a onClick={this.props.setAccounts}>click here to continue</a>.
|
||||
</>
|
||||
}
|
||||
/>
|
||||
) : isWrongNetwork ? (
|
||||
<Alert
|
||||
type="warning"
|
||||
message={
|
||||
<>
|
||||
The Grant.io smart contract is currently only supported on the{' '}
|
||||
<strong>Ropsten</strong> network. Please change your network to continue.
|
||||
</>
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
children
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default connect<StateProps, DispatchProps, OwnProps, AppState>(
|
||||
state => ({
|
||||
isMissingWeb3: state.web3.isMissingWeb3,
|
||||
isWeb3Locked: state.web3.isWeb3Locked,
|
||||
isWrongNetwork: state.web3.isWrongNetwork,
|
||||
}),
|
||||
{
|
||||
setAccounts: web3Actions.setAccounts,
|
||||
},
|
||||
)(MetaMaskRequiredButton);
|
|
@ -4,17 +4,17 @@ import { Spin, Form, Input, Button, Icon } from 'antd';
|
|||
import { ProposalWithCrowdFund } from 'types';
|
||||
import './style.less';
|
||||
import classnames from 'classnames';
|
||||
|
||||
import { fromWei } from 'utils/units';
|
||||
import { connect } from 'react-redux';
|
||||
import { compose } from 'recompose';
|
||||
import { AppState } from 'store/reducers';
|
||||
import { web3Actions } from 'modules/web3';
|
||||
import { withRouter } from 'react-router';
|
||||
import Web3Container, { Web3RenderProps } from 'lib/Web3Container';
|
||||
import ShortAddress from 'components/ShortAddress';
|
||||
import UnitDisplay from 'components/UnitDisplay';
|
||||
import { getAmountError } from 'utils/validators';
|
||||
import { CATEGORY_UI } from 'api/constants';
|
||||
import MetaMaskRequiredButton from 'components/MetaMaskRequiredButton';
|
||||
|
||||
interface OwnProps {
|
||||
proposal: ProposalWithCrowdFund;
|
||||
|
@ -29,11 +29,7 @@ interface ActionProps {
|
|||
fundCrowdFund: typeof web3Actions['fundCrowdFund'];
|
||||
}
|
||||
|
||||
interface Web3Props {
|
||||
web3: Web3RenderProps['web3'];
|
||||
}
|
||||
|
||||
type Props = OwnProps & StateProps & ActionProps & Web3Props;
|
||||
type Props = OwnProps & StateProps & ActionProps;
|
||||
|
||||
interface State {
|
||||
amountToRaise: string;
|
||||
|
@ -58,7 +54,7 @@ export class ProposalCampaignBlock extends React.Component<Props, State> {
|
|||
return;
|
||||
}
|
||||
|
||||
const { proposal, web3 } = this.props;
|
||||
const { proposal } = this.props;
|
||||
const { crowdFund } = proposal;
|
||||
const remainingTarget = crowdFund.target.sub(crowdFund.funded);
|
||||
const amount = parseFloat(value);
|
||||
|
@ -67,7 +63,7 @@ export class ProposalCampaignBlock extends React.Component<Props, State> {
|
|||
if (Number.isNaN(amount)) {
|
||||
// They're entering some garbage, they’ll work it out
|
||||
} else {
|
||||
const remainingEthNum = parseFloat(web3.utils.fromWei(remainingTarget, 'ether'));
|
||||
const remainingEthNum = parseFloat(fromWei(remainingTarget, 'ether'));
|
||||
amountError = getAmountError(amount, remainingEthNum);
|
||||
}
|
||||
|
||||
|
@ -82,7 +78,7 @@ export class ProposalCampaignBlock extends React.Component<Props, State> {
|
|||
};
|
||||
|
||||
render() {
|
||||
const { proposal, sendLoading, web3, isPreview } = this.props;
|
||||
const { proposal, sendLoading, isPreview } = this.props;
|
||||
const { amountToRaise, amountError } = this.state;
|
||||
const amountFloat = parseFloat(amountToRaise) || 0;
|
||||
let content;
|
||||
|
@ -94,7 +90,7 @@ export class ProposalCampaignBlock extends React.Component<Props, State> {
|
|||
crowdFund.isFrozen;
|
||||
const isDisabled = isFundingOver || !!amountError || !amountFloat || isPreview;
|
||||
const remainingEthNum = parseFloat(
|
||||
web3.utils.fromWei(crowdFund.target.sub(crowdFund.funded), 'ether'),
|
||||
fromWei(crowdFund.target.sub(crowdFund.funded), 'ether'),
|
||||
);
|
||||
|
||||
content = (
|
||||
|
@ -166,37 +162,51 @@ export class ProposalCampaignBlock extends React.Component<Props, State> {
|
|||
}}
|
||||
/>
|
||||
</div>
|
||||
<Form layout="vertical">
|
||||
<Form.Item
|
||||
validateStatus={amountError ? 'error' : undefined}
|
||||
help={amountError}
|
||||
style={{ marginBottom: '0.5rem', paddingBottom: 0 }}
|
||||
>
|
||||
<Input
|
||||
size="large"
|
||||
name="amountToRaise"
|
||||
type="number"
|
||||
value={amountToRaise}
|
||||
placeholder="0.5"
|
||||
min={0}
|
||||
max={remainingEthNum}
|
||||
step={0.1}
|
||||
onChange={this.handleAmountChange}
|
||||
addonAfter="ETH"
|
||||
disabled={isPreview}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Button
|
||||
onClick={this.sendTransaction}
|
||||
size="large"
|
||||
type="primary"
|
||||
disabled={isDisabled}
|
||||
loading={sendLoading}
|
||||
block
|
||||
<Form layout="vertical">
|
||||
<MetaMaskRequiredButton
|
||||
message={
|
||||
<Form.Item style={{ marginBottom: '0.5rem', paddingBottom: 0 }}>
|
||||
<Input
|
||||
size="large"
|
||||
type="number"
|
||||
placeholder="0.5"
|
||||
addonAfter="ETH"
|
||||
disabled={true}
|
||||
/>
|
||||
</Form.Item>
|
||||
}
|
||||
>
|
||||
Fund this project
|
||||
</Button>
|
||||
<Form.Item
|
||||
validateStatus={amountError ? 'error' : undefined}
|
||||
help={amountError}
|
||||
style={{ marginBottom: '0.5rem', paddingBottom: 0 }}
|
||||
>
|
||||
<Input
|
||||
size="large"
|
||||
name="amountToRaise"
|
||||
type="number"
|
||||
value={amountToRaise}
|
||||
placeholder="0.5"
|
||||
min={0}
|
||||
max={remainingEthNum}
|
||||
step={0.1}
|
||||
onChange={this.handleAmountChange}
|
||||
addonAfter="ETH"
|
||||
disabled={isPreview}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Button
|
||||
onClick={this.sendTransaction}
|
||||
size="large"
|
||||
type="primary"
|
||||
disabled={isDisabled}
|
||||
loading={sendLoading}
|
||||
block
|
||||
>
|
||||
Fund this project
|
||||
</Button>
|
||||
</MetaMaskRequiredButton>
|
||||
</Form>
|
||||
</>
|
||||
)}
|
||||
|
@ -226,21 +236,9 @@ const withConnect = connect(
|
|||
{ fundCrowdFund: web3Actions.fundCrowdFund },
|
||||
);
|
||||
|
||||
const ConnectedProposalCampaignBlock = compose<Props, OwnProps & Web3Props>(
|
||||
const ConnectedProposalCampaignBlock = compose<Props, OwnProps>(
|
||||
withRouter,
|
||||
withConnect,
|
||||
)(ProposalCampaignBlock);
|
||||
|
||||
export default (props: OwnProps) => (
|
||||
<Web3Container
|
||||
renderLoading={() => (
|
||||
<div className="ProposalCampaignBlock Proposal-top-side-block">
|
||||
<h1 className="Proposal-top-main-block-title">Campaign</h1>
|
||||
<div className="Proposal-top-main-block">
|
||||
<Spin />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
render={({ web3 }) => <ConnectedProposalCampaignBlock {...props} web3={web3} />}
|
||||
/>
|
||||
);
|
||||
export default ConnectedProposalCampaignBlock;
|
||||
|
|
|
@ -18,12 +18,11 @@ import ContributorsTab from './Contributors';
|
|||
// import CommunityTab from './Community';
|
||||
import UpdateModal from './UpdateModal';
|
||||
import CancelModal from './CancelModal';
|
||||
import './style.less';
|
||||
import classnames from 'classnames';
|
||||
import { withRouter } from 'react-router';
|
||||
import Web3Container from 'lib/Web3Container';
|
||||
import { web3Actions } from 'modules/web3';
|
||||
import SocialShare from 'components/SocialShare';
|
||||
import './style.less';
|
||||
|
||||
interface OwnProps {
|
||||
proposalId: number;
|
||||
|
@ -32,17 +31,14 @@ interface OwnProps {
|
|||
|
||||
interface StateProps {
|
||||
proposal: ProposalWithCrowdFund | null;
|
||||
account: string | null;
|
||||
}
|
||||
|
||||
interface DispatchProps {
|
||||
fetchProposal: proposalActions.TFetchProposal;
|
||||
}
|
||||
|
||||
interface Web3Props {
|
||||
account: string;
|
||||
}
|
||||
|
||||
type Props = StateProps & DispatchProps & Web3Props & OwnProps;
|
||||
type Props = StateProps & DispatchProps & OwnProps;
|
||||
|
||||
interface State {
|
||||
isBodyExpanded: boolean;
|
||||
|
@ -95,7 +91,7 @@ export class ProposalDetail extends React.Component<Props, State> {
|
|||
return <Spin />;
|
||||
} else {
|
||||
const { crowdFund } = proposal;
|
||||
const isTrustee = crowdFund.trustees.includes(account);
|
||||
const isTrustee = !!account && crowdFund.trustees.includes(account);
|
||||
const isContributor = !!crowdFund.contributors.find(c => c.address === account);
|
||||
const hasBeenFunded = crowdFund.isRaiseGoalReached;
|
||||
const isProposalActive = !hasBeenFunded && crowdFund.deadline > Date.now();
|
||||
|
@ -251,6 +247,7 @@ export class ProposalDetail extends React.Component<Props, State> {
|
|||
function mapStateToProps(state: AppState, ownProps: OwnProps) {
|
||||
return {
|
||||
proposal: getProposal(state, ownProps.proposalId),
|
||||
account: (state.web3.accounts.length && state.web3.accounts[0]) || null,
|
||||
};
|
||||
}
|
||||
|
||||
|
@ -263,22 +260,9 @@ const withConnect = connect<StateProps, DispatchProps, OwnProps, AppState>(
|
|||
mapDispatchToProps,
|
||||
);
|
||||
|
||||
const ConnectedProposal = compose<Props, OwnProps & Web3Props>(
|
||||
const ConnectedProposal = compose<Props, OwnProps>(
|
||||
withRouter,
|
||||
withConnect,
|
||||
)(ProposalDetail);
|
||||
|
||||
export default (props: OwnProps) => (
|
||||
<Web3Container
|
||||
renderLoading={() => (
|
||||
<div className="Proposal">
|
||||
<div className="Proposal-top">
|
||||
<div className="Proposal-top-main">
|
||||
<Spin />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
render={({ accounts }) => <ConnectedProposal account={accounts[0]} {...props} />}
|
||||
/>
|
||||
);
|
||||
export default ConnectedProposal;
|
||||
|
|
|
@ -1,23 +1,15 @@
|
|||
import React from 'react';
|
||||
import classnames from 'classnames';
|
||||
import { Progress, Icon, Spin } from 'antd';
|
||||
import { Progress, Icon } from 'antd';
|
||||
import moment from 'moment';
|
||||
import { Redirect } from 'react-router-dom';
|
||||
import { CATEGORY_UI } from 'api/constants';
|
||||
import { ProposalWithCrowdFund } from 'types';
|
||||
import './style.less';
|
||||
import { Dispatch, bindActionCreators } from 'redux';
|
||||
import * as web3Actions from 'modules/web3/actions';
|
||||
import { AppState } from 'store/reducers';
|
||||
import { connect } from 'react-redux';
|
||||
import UserAvatar from 'components/UserAvatar';
|
||||
import UnitDisplay from 'components/UnitDisplay';
|
||||
import './style.less';
|
||||
|
||||
interface Props extends ProposalWithCrowdFund {
|
||||
web3: AppState['web3']['web3'];
|
||||
}
|
||||
|
||||
export class ProposalCard extends React.Component<Props> {
|
||||
export class ProposalCard extends React.Component<ProposalWithCrowdFund> {
|
||||
state = { redirect: '' };
|
||||
render() {
|
||||
if (this.state.redirect) {
|
||||
|
@ -29,84 +21,66 @@ export class ProposalCard extends React.Component<Props> {
|
|||
proposalUrlId,
|
||||
category,
|
||||
dateCreated,
|
||||
web3,
|
||||
crowdFund,
|
||||
team,
|
||||
} = this.props;
|
||||
|
||||
if (!web3) {
|
||||
return <Spin />;
|
||||
} else {
|
||||
return (
|
||||
<div
|
||||
className="ProposalCard"
|
||||
onClick={() => this.setState({ redirect: `/proposals/${proposalUrlId}` })}
|
||||
>
|
||||
<h3 className="ProposalCard-title">{title}</h3>
|
||||
<div className="ProposalCard-funding">
|
||||
<div className="ProposalCard-funding-raised">
|
||||
<UnitDisplay value={crowdFund.funded} symbol="ETH" /> <small>raised</small>{' '}
|
||||
of <UnitDisplay value={crowdFund.target} symbol="ETH" /> goal
|
||||
</div>
|
||||
<div
|
||||
className={classnames({
|
||||
['ProposalCard-funding-percent']: true,
|
||||
['is-funded']: crowdFund.percentFunded >= 100,
|
||||
})}
|
||||
>
|
||||
{crowdFund.percentFunded}%
|
||||
</div>
|
||||
return (
|
||||
<div
|
||||
className="ProposalCard"
|
||||
onClick={() => this.setState({ redirect: `/proposals/${proposalUrlId}` })}
|
||||
>
|
||||
<h3 className="ProposalCard-title">{title}</h3>
|
||||
<div className="ProposalCard-funding">
|
||||
<div className="ProposalCard-funding-raised">
|
||||
<UnitDisplay value={crowdFund.funded} symbol="ETH" /> <small>raised</small> of{' '}
|
||||
<UnitDisplay value={crowdFund.target} symbol="ETH" /> goal
|
||||
</div>
|
||||
<Progress
|
||||
percent={crowdFund.percentFunded}
|
||||
status={crowdFund.percentFunded >= 100 ? 'success' : 'active'}
|
||||
showInfo={false}
|
||||
/>
|
||||
|
||||
<div className="ProposalCard-team">
|
||||
<div className="ProposalCard-team-name">
|
||||
{team[0].name} {team.length > 1 && <small>+{team.length - 1} other</small>}
|
||||
</div>
|
||||
<div className="ProposalCard-team-avatars">
|
||||
{[...team].reverse().map((u, idx) => (
|
||||
<UserAvatar
|
||||
key={idx}
|
||||
className="ProposalCard-team-avatars-avatar"
|
||||
user={u}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="ProposalCard-address">{proposalAddress}</div>
|
||||
|
||||
<div className="ProposalCard-info">
|
||||
<div
|
||||
className="ProposalCard-info-category"
|
||||
style={{ color: CATEGORY_UI[category].color }}
|
||||
>
|
||||
<Icon type={CATEGORY_UI[category].icon} /> {CATEGORY_UI[category].label}
|
||||
</div>
|
||||
<div className="ProposalCard-info-created">
|
||||
{moment(dateCreated * 1000).fromNow()}
|
||||
</div>
|
||||
<div
|
||||
className={classnames({
|
||||
['ProposalCard-funding-percent']: true,
|
||||
['is-funded']: crowdFund.percentFunded >= 100,
|
||||
})}
|
||||
>
|
||||
{crowdFund.percentFunded}%
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
<Progress
|
||||
percent={crowdFund.percentFunded}
|
||||
status={crowdFund.percentFunded >= 100 ? 'success' : 'active'}
|
||||
showInfo={false}
|
||||
/>
|
||||
|
||||
<div className="ProposalCard-team">
|
||||
<div className="ProposalCard-team-name">
|
||||
{team[0].name} {team.length > 1 && <small>+{team.length - 1} other</small>}
|
||||
</div>
|
||||
<div className="ProposalCard-team-avatars">
|
||||
{[...team].reverse().map((u, idx) => (
|
||||
<UserAvatar
|
||||
key={idx}
|
||||
className="ProposalCard-team-avatars-avatar"
|
||||
user={u}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="ProposalCard-address">{proposalAddress}</div>
|
||||
|
||||
<div className="ProposalCard-info">
|
||||
<div
|
||||
className="ProposalCard-info-category"
|
||||
style={{ color: CATEGORY_UI[category].color }}
|
||||
>
|
||||
<Icon type={CATEGORY_UI[category].icon} /> {CATEGORY_UI[category].label}
|
||||
</div>
|
||||
<div className="ProposalCard-info-created">
|
||||
{moment(dateCreated * 1000).fromNow()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function mapDispatchToProps(dispatch: Dispatch) {
|
||||
return bindActionCreators(web3Actions, dispatch);
|
||||
}
|
||||
|
||||
function mapStateToProps(state: AppState) {
|
||||
return {
|
||||
web3: state.web3.web3,
|
||||
};
|
||||
}
|
||||
|
||||
export default connect(
|
||||
mapStateToProps,
|
||||
mapDispatchToProps,
|
||||
)(ProposalCard);
|
||||
export default ProposalCard;
|
||||
|
|
|
@ -5,11 +5,10 @@ import { getProposals } from 'modules/proposals/selectors';
|
|||
import { ProposalWithCrowdFund } from 'types';
|
||||
import { bindActionCreators, Dispatch } from 'redux';
|
||||
import { AppState } from 'store/reducers';
|
||||
import { Input, Divider, Spin, Drawer, Icon, Button } from 'antd';
|
||||
import { Input, Divider, Drawer, Icon, Button } from 'antd';
|
||||
import ProposalResults from './Results';
|
||||
import ProposalFilters, { Filters } from './Filters';
|
||||
import { PROPOSAL_SORT } from 'api/constants';
|
||||
import Web3Container from 'lib/Web3Container';
|
||||
import './style.less';
|
||||
|
||||
type ProposalSortFn = (p1: ProposalWithCrowdFund, p2: ProposalWithCrowdFund) => number;
|
||||
|
@ -246,6 +245,4 @@ const ConnectedProposals = connect(
|
|||
mapDispatchToProps,
|
||||
)(Proposals);
|
||||
|
||||
export default () => (
|
||||
<Web3Container renderLoading={() => <Spin />} render={() => <ConnectedProposals />} />
|
||||
);
|
||||
export default ConnectedProposals;
|
||||
|
|
|
@ -0,0 +1,24 @@
|
|||
import Web3 from 'web3';
|
||||
import getContractInstance from './getContract';
|
||||
import CrowdFund from 'lib/contracts/CrowdFund.json';
|
||||
|
||||
const contractCache = {} as { [key: string]: any };
|
||||
|
||||
export async function getCrowdFundContract(web3: Web3 | null, deployedAddress: string) {
|
||||
if (!web3) {
|
||||
throw new Error('getCrowdFundAddress: web3 was null but is required!');
|
||||
}
|
||||
if (!contractCache[deployedAddress]) {
|
||||
try {
|
||||
contractCache[deployedAddress] = await getContractInstance(
|
||||
web3,
|
||||
CrowdFund,
|
||||
deployedAddress,
|
||||
);
|
||||
} catch (e) {
|
||||
console.error(`Could not lookup crowdFund contract @ ${deployedAddress}: `, e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return contractCache[deployedAddress];
|
||||
}
|
|
@ -10,7 +10,6 @@ const resolveWeb3 = (resolve: (web3: Web3) => void, reject: (err: Error) => void
|
|||
}
|
||||
|
||||
let { web3 } = window as Web3Window;
|
||||
const localProvider = `http://localhost:8545`;
|
||||
|
||||
// To test what it's like to not have web3, uncomment the reject. Otherwise
|
||||
// localProvider will always kick in.
|
||||
|
@ -19,10 +18,6 @@ const resolveWeb3 = (resolve: (web3: Web3) => void, reject: (err: Error) => void
|
|||
if (typeof web3 !== 'undefined') {
|
||||
console.info(`Injected web3 detected.`);
|
||||
web3 = new Web3(web3.currentProvider);
|
||||
} else if (process.env.NODE_ENV !== 'production') {
|
||||
console.info(`No web3 instance injected, using Local web3.`);
|
||||
const provider = new Web3.providers.HttpProvider(localProvider);
|
||||
web3 = new Web3(provider);
|
||||
} else {
|
||||
return reject(new Error('No web3 instance available'));
|
||||
}
|
||||
|
|
|
@ -254,6 +254,5 @@ export function makeProposalPreviewFromForm(
|
|||
isFrozen: false,
|
||||
isRaiseGoalReached: false,
|
||||
},
|
||||
crowdFundContract: null,
|
||||
};
|
||||
}
|
||||
|
|
|
@ -6,64 +6,16 @@ import {
|
|||
getProposalUpdates,
|
||||
} from 'api/api';
|
||||
import { Dispatch } from 'redux';
|
||||
import Web3 from 'web3';
|
||||
import { ProposalWithCrowdFund, Proposal, Comment } from 'types';
|
||||
import { ProposalWithCrowdFund, Comment } from 'types';
|
||||
import { signData } from 'modules/web3/actions';
|
||||
import getContract from 'lib/getContract';
|
||||
import CrowdFund from 'lib/contracts/CrowdFund.json';
|
||||
import { getCrowdFundState } from 'web3interact/crowdFund';
|
||||
|
||||
async function getMergedCrowdFundProposal(
|
||||
proposal: Proposal,
|
||||
web3: Web3,
|
||||
account: string,
|
||||
) {
|
||||
const crowdFundContract = await getContract(web3, CrowdFund, proposal.proposalAddress);
|
||||
const crowdFundData = {
|
||||
crowdFundContract,
|
||||
crowdFund: await getCrowdFundState(crowdFundContract, account, web3),
|
||||
};
|
||||
|
||||
for (let i = 0; i < crowdFundData.crowdFund.milestones.length; i++) {
|
||||
proposal.milestones[i] = {
|
||||
...proposal.milestones[i],
|
||||
...crowdFundData.crowdFund.milestones[i],
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
...crowdFundData,
|
||||
...proposal,
|
||||
};
|
||||
}
|
||||
|
||||
// valid as defined by crowdFund contract existing on current network
|
||||
export async function getValidProposals(
|
||||
proposals: { data: Proposal[] },
|
||||
web3: Web3,
|
||||
account: string,
|
||||
) {
|
||||
return (await Promise.all(
|
||||
proposals.data.map(async (proposal: Proposal) => {
|
||||
try {
|
||||
return await getMergedCrowdFundProposal(proposal, web3, account);
|
||||
} catch (e) {
|
||||
console.error('Could not lookup crowdFund contract', e);
|
||||
}
|
||||
}),
|
||||
// remove proposals that except since they cannot be retrieved via getContract
|
||||
)).filter(Boolean);
|
||||
}
|
||||
|
||||
export type TFetchProposals = typeof fetchProposals;
|
||||
export function fetchProposals() {
|
||||
return (dispatch: Dispatch<any>, getState: any) => {
|
||||
const state = getState();
|
||||
return (dispatch: Dispatch<any>) => {
|
||||
return dispatch({
|
||||
type: types.PROPOSALS_DATA,
|
||||
payload: async () => {
|
||||
const proposals = await getProposals();
|
||||
return getValidProposals(proposals, state.web3.web3, state.web3.accounts[0]);
|
||||
return (await getProposals()).data;
|
||||
},
|
||||
});
|
||||
};
|
||||
|
@ -71,17 +23,11 @@ export function fetchProposals() {
|
|||
|
||||
export type TFetchProposal = typeof fetchProposal;
|
||||
export function fetchProposal(proposalId: ProposalWithCrowdFund['proposalId']) {
|
||||
return (dispatch: Dispatch<any>, getState: any) => {
|
||||
const state = getState();
|
||||
return (dispatch: Dispatch<any>) => {
|
||||
dispatch({
|
||||
type: types.PROPOSAL_DATA,
|
||||
payload: async () => {
|
||||
const proposal = await getProposal(proposalId);
|
||||
return await getMergedCrowdFundProposal(
|
||||
proposal.data,
|
||||
state.web3.web3,
|
||||
state.web3.accounts[0],
|
||||
);
|
||||
return (await getProposal(proposalId)).data;
|
||||
},
|
||||
});
|
||||
};
|
||||
|
|
|
@ -9,6 +9,7 @@ import { fetchProposal, fetchProposals } from 'modules/proposals/actions';
|
|||
import { PROPOSAL_CATEGORY } from 'api/constants';
|
||||
import { AppState } from 'store/reducers';
|
||||
import { Wei } from 'utils/units';
|
||||
import { getCrowdFundContract } from 'lib/crowdFundContracts';
|
||||
import { TeamMember, AuthSignatureData, ProposalWithCrowdFund } from 'types';
|
||||
|
||||
type GetState = () => AppState;
|
||||
|
@ -201,7 +202,12 @@ export function requestMilestonePayout(proposal: ProposalWithCrowdFund, index: n
|
|||
});
|
||||
const state = getState();
|
||||
const account = state.web3.accounts[0];
|
||||
const { crowdFundContract, proposalId } = proposal;
|
||||
const { proposalAddress, proposalId } = proposal;
|
||||
const crowdFundContract = await getCrowdFundContract(
|
||||
state.web3.web3,
|
||||
proposalAddress,
|
||||
);
|
||||
|
||||
try {
|
||||
await crowdFundContract.methods
|
||||
.requestMilestonePayout(index)
|
||||
|
@ -231,7 +237,11 @@ export function payMilestonePayout(proposal: ProposalWithCrowdFund, index: numbe
|
|||
});
|
||||
const state = getState();
|
||||
const account = state.web3.accounts[0];
|
||||
const { crowdFundContract, proposalId } = proposal;
|
||||
const { proposalAddress, proposalId } = proposal;
|
||||
const crowdFundContract = await getCrowdFundContract(
|
||||
state.web3.web3,
|
||||
proposalAddress,
|
||||
);
|
||||
|
||||
try {
|
||||
await crowdFundContract.methods
|
||||
|
@ -265,7 +275,8 @@ export function fundCrowdFund(proposal: ProposalWithCrowdFund, value: number | s
|
|||
const state = getState();
|
||||
const web3 = state.web3.web3;
|
||||
const account = state.web3.accounts[0];
|
||||
const { crowdFundContract, proposalId } = proposal;
|
||||
const { proposalAddress, proposalId } = proposal;
|
||||
const crowdFundContract = await getCrowdFundContract(web3, proposalAddress);
|
||||
|
||||
try {
|
||||
if (!web3) {
|
||||
|
@ -301,7 +312,11 @@ export function voteMilestonePayout(
|
|||
dispatch({ type: types.VOTE_AGAINST_MILESTONE_PAYOUT_PENDING });
|
||||
const state = getState();
|
||||
const account = state.web3.accounts[0];
|
||||
const { crowdFundContract, proposalId } = proposal;
|
||||
const { proposalAddress, proposalId } = proposal;
|
||||
const crowdFundContract = await getCrowdFundContract(
|
||||
state.web3.web3,
|
||||
proposalAddress,
|
||||
);
|
||||
|
||||
try {
|
||||
await crowdFundContract.methods
|
||||
|
@ -328,7 +343,11 @@ export function voteRefund(proposal: ProposalWithCrowdFund, vote: boolean) {
|
|||
dispatch({ type: types.VOTE_REFUND_PENDING });
|
||||
const state = getState();
|
||||
const account = state.web3.accounts[0];
|
||||
const { crowdFundContract, proposalId } = proposal;
|
||||
const { proposalAddress, proposalId } = proposal;
|
||||
const crowdFundContract = await getCrowdFundContract(
|
||||
state.web3.web3,
|
||||
proposalAddress,
|
||||
);
|
||||
|
||||
try {
|
||||
await crowdFundContract.methods
|
||||
|
@ -377,7 +396,11 @@ export function triggerRefund(proposal: ProposalWithCrowdFund) {
|
|||
dispatch({ type: types.WITHDRAW_REFUND_PENDING });
|
||||
const state = getState();
|
||||
const account = state.web3.accounts[0];
|
||||
const { crowdFundContract, proposalId } = proposal;
|
||||
const { proposalAddress, proposalId } = proposal;
|
||||
const crowdFundContract = await getCrowdFundContract(
|
||||
state.web3.web3,
|
||||
proposalAddress,
|
||||
);
|
||||
|
||||
try {
|
||||
await freezeContract(crowdFundContract, account);
|
||||
|
@ -398,7 +421,11 @@ export function withdrawRefund(proposal: ProposalWithCrowdFund, address: string)
|
|||
dispatch({ type: types.WITHDRAW_REFUND_PENDING });
|
||||
const state = getState();
|
||||
const account = state.web3.accounts[0];
|
||||
const { crowdFundContract, proposalId } = proposal;
|
||||
const { proposalAddress, proposalId } = proposal;
|
||||
const crowdFundContract = await getCrowdFundContract(
|
||||
state.web3.web3,
|
||||
proposalAddress,
|
||||
);
|
||||
|
||||
try {
|
||||
await freezeContract(crowdFundContract, account);
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
import { TeamMember } from 'types';
|
||||
import BN from 'bn.js';
|
||||
import { TeamMember, CrowdFund, ProposalWithCrowdFund } from 'types';
|
||||
import { socialAccountsToUrls, socialUrlsToAccounts } from 'utils/social';
|
||||
|
||||
export function formatTeamMemberForPost(user: TeamMember) {
|
||||
|
@ -27,6 +28,38 @@ export function formatTeamMemberFromGet(user: any): TeamMember {
|
|||
};
|
||||
}
|
||||
|
||||
export function formatCrowdFundFromGet(crowdFund: CrowdFund): CrowdFund {
|
||||
const bnKeys = ['amountVotingForRefund', 'balance', 'funded', 'target'] as Array<
|
||||
keyof CrowdFund
|
||||
>;
|
||||
bnKeys.forEach(k => {
|
||||
crowdFund[k] = new BN(crowdFund[k] as string);
|
||||
});
|
||||
crowdFund.milestones = crowdFund.milestones.map(ms => {
|
||||
ms.amount = new BN(ms.amount);
|
||||
ms.amountAgainstPayout = new BN(ms.amountAgainstPayout);
|
||||
return ms;
|
||||
});
|
||||
crowdFund.contributors = crowdFund.contributors.map(c => {
|
||||
c.contributionAmount = new BN(c.contributionAmount);
|
||||
return c;
|
||||
});
|
||||
return crowdFund;
|
||||
}
|
||||
|
||||
export function formatProposalFromGet(proposal: ProposalWithCrowdFund) {
|
||||
for (let i = 0; i < proposal.crowdFund.milestones.length; i++) {
|
||||
proposal.milestones[i] = {
|
||||
...proposal.milestones[i],
|
||||
...proposal.crowdFund.milestones[i],
|
||||
};
|
||||
}
|
||||
proposal.team = proposal.team.map(formatTeamMemberFromGet);
|
||||
proposal.proposalUrlId = generateProposalUrl(proposal.proposalId, proposal.title);
|
||||
proposal.crowdFund = formatCrowdFundFromGet(proposal.crowdFund);
|
||||
return proposal;
|
||||
}
|
||||
|
||||
// TODO: i18n on case-by-case basis
|
||||
export function generateProposalUrl(id: number, title: string) {
|
||||
const slug = title
|
||||
|
|
|
@ -212,13 +212,13 @@ export function getProposalWithCrowdFund({
|
|||
amountVotingForRefund: new BN(0),
|
||||
percentVotingForRefund: 0,
|
||||
},
|
||||
crowdFundContract: {},
|
||||
};
|
||||
|
||||
const props = {
|
||||
sendLoading: false,
|
||||
fundCrowdFund,
|
||||
web3: new Web3(),
|
||||
isMissingWeb3: false,
|
||||
proposal,
|
||||
...proposal, // yeah...
|
||||
};
|
||||
|
|
|
@ -46,7 +46,6 @@ export interface Proposal {
|
|||
|
||||
export interface ProposalWithCrowdFund extends Proposal {
|
||||
crowdFund: CrowdFund;
|
||||
crowdFundContract: any;
|
||||
}
|
||||
|
||||
export interface ProposalComments {
|
||||
|
|
Loading…
Reference in New Issue