User Auth Conversion (#19)
This commit is contained in:
parent
163ff62433
commit
50cc377b48
|
@ -1,2 +1,3 @@
|
||||||
.idea
|
.idea
|
||||||
contract/build
|
.DS_Store
|
||||||
|
.vscode
|
16
.travis.yml
16
.travis.yml
|
@ -15,22 +15,6 @@ matrix:
|
||||||
before_install:
|
before_install:
|
||||||
- cd backend/
|
- cd backend/
|
||||||
- cp .env.example .env
|
- cp .env.example .env
|
||||||
env:
|
|
||||||
- CROWD_FUND_URL=https://eip-712.herokuapp.com/contract/crowd-fund CROWD_FUND_FACTORY_URL=https://eip-712.herokuapp.com/contract/factory
|
|
||||||
install: pip install -r requirements/dev.txt
|
install: pip install -r requirements/dev.txt
|
||||||
script:
|
script:
|
||||||
- flask test
|
- flask test
|
||||||
# Contracts
|
|
||||||
- language: node_js
|
|
||||||
node_js: 8.13.0
|
|
||||||
before_install:
|
|
||||||
- cd contract/
|
|
||||||
install: yarn && yarn add global truffle ganache-cli@6.1.8
|
|
||||||
before_script:
|
|
||||||
- ganache-cli > /dev/null &
|
|
||||||
- sleep 10
|
|
||||||
script:
|
|
||||||
- yarn run test
|
|
||||||
env:
|
|
||||||
- CROWD_FUND_URL=https://eip-712.herokuapp.com/contract/crowd-fund
|
|
||||||
CROWD_FUND_FACTORY_URL=https://eip-712.herokuapp.com/contract/factory
|
|
||||||
|
|
23
README.md
23
README.md
|
@ -1,20 +1,12 @@
|
||||||
# (ALPHA) Grant.io Mono Repo
|
# ZCash Grant System
|
||||||
|
|
||||||
This is a collection of the various services and components that make up [Grant.io](http://grant.io).
|
This is a collection of the various services and components that make up the ZCash Grant System.
|
||||||
|
|
||||||
[Grant.io](http://grant.io) is under heavy development, and is not considered stable. Use at your own risk!
|
|
||||||
|
|
||||||
### Setup
|
### Setup
|
||||||
__________________
|
|
||||||
|
|
||||||
##### Docker
|
|
||||||
To get setup quickly, simply use docker-compose to spin up the necessary services
|
|
||||||
|
|
||||||
TBD
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
##### Locally
|
##### Locally
|
||||||
Alternatively, run the backend and front-end services locally.
|
|
||||||
|
|
||||||
Instructions for each respective component can be found in:
|
Instructions for each respective component can be found in:
|
||||||
|
|
||||||
|
@ -24,17 +16,12 @@ Instructions for each respective component can be found in:
|
||||||
|
|
||||||
We currently only offer instructions for unix based systems. Windows may or may not be compatible.
|
We currently only offer instructions for unix based systems. Windows may or may not be compatible.
|
||||||
|
|
||||||
|
|
||||||
### Testing
|
### Testing
|
||||||
|
|
||||||
To run tests across all components simultaneously, use the following command
|
To run tests across all components simultaneously, use the following command
|
||||||
|
|
||||||
TBD
|
TBD
|
||||||
|
|
||||||
|
|
||||||
### Deployment
|
### Deployment
|
||||||
|
|
||||||
TBD
|
TBD
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -11,8 +11,6 @@ This is the admin component of [Grant.io](http://grant.io).
|
||||||
yarn
|
yarn
|
||||||
```
|
```
|
||||||
|
|
||||||
1. Make sure ganache is running and contracts have been built for the dev network (if frontend dev is running this should be the case).
|
|
||||||
|
|
||||||
1. Run the webpack build for the admin ui:
|
1. Run the webpack build for the admin ui:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|
|
@ -97,7 +97,6 @@
|
||||||
"tslint-react": "^3.6.0",
|
"tslint-react": "^3.6.0",
|
||||||
"typescript": "3.0.3",
|
"typescript": "3.0.3",
|
||||||
"url-loader": "^1.1.1",
|
"url-loader": "^1.1.1",
|
||||||
"web3": "^1.0.0-beta.34",
|
|
||||||
"webpack": "^4.19.0",
|
"webpack": "^4.19.0",
|
||||||
"webpack-cli": "^3.1.0",
|
"webpack-cli": "^3.1.0",
|
||||||
"webpack-dev-server": "^3.1.8",
|
"webpack-dev-server": "^3.1.8",
|
||||||
|
@ -107,7 +106,6 @@
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/bn.js": "4.11.1",
|
"@types/bn.js": "4.11.1",
|
||||||
"@types/ethereumjs-util": "5.2.0",
|
"@types/ethereumjs-util": "5.2.0",
|
||||||
"@types/query-string": "6.1.0",
|
"@types/query-string": "6.1.0"
|
||||||
"@types/web3": "1.0.3"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -14,17 +14,8 @@ class Home extends React.Component {
|
||||||
<div className="Home">
|
<div className="Home">
|
||||||
<h1>Home</h1>
|
<h1>Home</h1>
|
||||||
<div>isLoggedIn: {JSON.stringify(store.isLoggedIn)}</div>
|
<div>isLoggedIn: {JSON.stringify(store.isLoggedIn)}</div>
|
||||||
<div>web3 enabled: {JSON.stringify(store.web3Enabled)}</div>
|
|
||||||
<div>web3 type: {store.web3Type}</div>
|
|
||||||
<div>ethereum network: {store.ethNetId}</div>
|
|
||||||
<div>ethereum account: {store.ethAccount}</div>
|
|
||||||
<div>CrowdFundFactory: {store.crowdFundFactoryDefinitionStatus}</div>
|
|
||||||
{userCount > -1 && (
|
|
||||||
<>
|
|
||||||
<div>user count: {userCount}</div>
|
<div>user count: {userCount}</div>
|
||||||
<div>proposal count: {proposalCount}</div>
|
<div>proposal count: {proposalCount}</div>
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -18,16 +18,6 @@
|
||||||
left: 216px;
|
left: 216px;
|
||||||
z-index: 5;
|
z-index: 5;
|
||||||
background: white;
|
background: white;
|
||||||
|
|
||||||
&-status {
|
|
||||||
position: fixed;
|
|
||||||
top: 0.2rem;
|
|
||||||
right: 0.2rem;
|
|
||||||
color: white;
|
|
||||||
padding: 0 0.4rem;
|
|
||||||
border-radius: 0.5rem;
|
|
||||||
background: rgba(0, 0, 0, 0.25);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
&-proposal {
|
&-proposal {
|
||||||
|
@ -114,83 +104,5 @@
|
||||||
margin: 0.2rem 0 0.2rem 0.4rem;
|
margin: 0.2rem 0 0.2rem 0.4rem;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
&-contract {
|
|
||||||
&-method {
|
|
||||||
opacity: 0.7;
|
|
||||||
}
|
|
||||||
|
|
||||||
&-array {
|
|
||||||
margin-left: 0.7rem;
|
|
||||||
margin-bottom: 0.2rem;
|
|
||||||
|
|
||||||
&-milestones,
|
|
||||||
&-contributors {
|
|
||||||
display: flex;
|
|
||||||
|
|
||||||
& span {
|
|
||||||
opacity: 0.7;
|
|
||||||
}
|
|
||||||
|
|
||||||
& > div {
|
|
||||||
margin-right: 0.5rem;
|
|
||||||
padding: 0.2rem;
|
|
||||||
border: 1px solid rgba(0, 0, 0, 0.1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
&-status {
|
|
||||||
display: inline-block;
|
|
||||||
width: 0.6rem;
|
|
||||||
height: 0.6rem;
|
|
||||||
border-radius: 0.3rem;
|
|
||||||
margin-right: 0.2rem;
|
|
||||||
|
|
||||||
&.is-unloaded {
|
|
||||||
background-color: rgb(197, 197, 197);
|
|
||||||
}
|
|
||||||
&.is-loading {
|
|
||||||
background-color: rgb(241, 177, 0);
|
|
||||||
}
|
|
||||||
&.is-waiting {
|
|
||||||
background-color: rgb(0, 226, 230);
|
|
||||||
}
|
|
||||||
&.is-loaded {
|
|
||||||
background-color: rgb(0, 165, 25);
|
|
||||||
}
|
|
||||||
&.is-error {
|
|
||||||
background-color: rgb(194, 0, 0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
&-method {
|
|
||||||
margin-top: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
&-inputs {
|
|
||||||
display: inline-block;
|
|
||||||
min-width: 300px;
|
|
||||||
background: rgba(0, 0, 0, 0.1);
|
|
||||||
padding: 0.23rem;
|
|
||||||
border-radius: 0.2rem;
|
|
||||||
margin-right: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
&-input {
|
|
||||||
margin-right: 0.5rem;
|
|
||||||
|
|
||||||
&.is-wei,
|
|
||||||
&.is-string,
|
|
||||||
&.is-integer {
|
|
||||||
width: 13rem;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
& .ant-alert {
|
|
||||||
max-width: 600px;
|
|
||||||
margin: 0.5rem 0 0.5rem 0.8rem;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,17 +1,11 @@
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { view } from 'react-easy-state';
|
import { view } from 'react-easy-state';
|
||||||
import { Icon, Button, Popover, InputNumber, Checkbox, Alert, Input } from 'antd';
|
import { Icon, Button, Popover } from 'antd';
|
||||||
import { RouteComponentProps, withRouter } from 'react-router';
|
import { RouteComponentProps, withRouter } from 'react-router';
|
||||||
import Showdown from 'showdown';
|
import Showdown from 'showdown';
|
||||||
import moment from 'moment';
|
import moment from 'moment';
|
||||||
import store from 'src/store';
|
import store from 'src/store';
|
||||||
import {
|
import { Proposal } from 'src/types';
|
||||||
Proposal,
|
|
||||||
Contract,
|
|
||||||
ContractMethod as TContractMethod,
|
|
||||||
ContractMilestone,
|
|
||||||
ContractContributor,
|
|
||||||
} from 'src/types';
|
|
||||||
import './index.less';
|
import './index.less';
|
||||||
import Field from 'components/Field';
|
import Field from 'components/Field';
|
||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
|
@ -52,9 +46,6 @@ class ProposalsNaked extends React.Component<Props> {
|
||||||
icon="reload"
|
icon="reload"
|
||||||
onClick={() => store.fetchProposals()}
|
onClick={() => store.fetchProposals()}
|
||||||
/>
|
/>
|
||||||
<div className="Proposals-controls-status">
|
|
||||||
{store.crowdFundGeneralStatus}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<ProposalItem key={singleProposal.proposalId} {...singleProposal} />
|
<ProposalItem key={singleProposal.proposalId} {...singleProposal} />
|
||||||
</div>
|
</div>
|
||||||
|
@ -68,7 +59,6 @@ class ProposalsNaked extends React.Component<Props> {
|
||||||
<div className="Proposals">
|
<div className="Proposals">
|
||||||
<div className="Proposals-controls">
|
<div className="Proposals-controls">
|
||||||
<Button title="refresh" icon="reload" onClick={() => store.fetchProposals()} />
|
<Button title="refresh" icon="reload" onClick={() => store.fetchProposals()} />
|
||||||
<div className="Proposals-controls-status">{store.crowdFundGeneralStatus}</div>
|
|
||||||
</div>
|
</div>
|
||||||
{proposals.length === 0 && <div>no proposals</div>}
|
{proposals.length === 0 && <div>no proposals</div>}
|
||||||
{proposals.length > 0 &&
|
{proposals.length > 0 &&
|
||||||
|
@ -191,34 +181,6 @@ class ProposalItemNaked extends React.Component<Proposal> {
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
{!store.web3Enabled && <Field title="web3" value={'UNAVAILABLE'} />}
|
|
||||||
{store.web3Enabled && (
|
|
||||||
<Field
|
|
||||||
title={`web3 (${p.contractStatus || 'not loaded'})`}
|
|
||||||
value={
|
|
||||||
<div className="Proposals-proposal-contract">
|
|
||||||
<Button
|
|
||||||
icon="reload"
|
|
||||||
size="small"
|
|
||||||
title="refresh contract"
|
|
||||||
onClick={() => store.populateProposalContract(p.proposalId)}
|
|
||||||
>
|
|
||||||
refresh contract
|
|
||||||
</Button>
|
|
||||||
{Object.keys(p.contract)
|
|
||||||
.map(k => k as keyof Contract)
|
|
||||||
.map(k => (
|
|
||||||
<ContractMethod
|
|
||||||
key={k}
|
|
||||||
proposalId={p.proposalId}
|
|
||||||
name={k}
|
|
||||||
{...p.contract[k]}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
@ -229,182 +191,5 @@ class ProposalItemNaked extends React.Component<Proposal> {
|
||||||
}
|
}
|
||||||
const ProposalItem = view(ProposalItemNaked);
|
const ProposalItem = view(ProposalItemNaked);
|
||||||
|
|
||||||
// tslint:disable-next-line:max-classes-per-file
|
|
||||||
class ContractMethodNaked extends React.Component<
|
|
||||||
TContractMethod & { proposalId: number; name: string }
|
|
||||||
> {
|
|
||||||
state = {};
|
|
||||||
render() {
|
|
||||||
const { name, value, status, type, format } = this.props;
|
|
||||||
const isObj = typeof value === 'object' && value !== null;
|
|
||||||
const isArray = Array.isArray(value);
|
|
||||||
const fmt = (val: any) => {
|
|
||||||
if (val && format === 'time') {
|
|
||||||
const asNumber = Number(val) * 1000;
|
|
||||||
return `${moment(asNumber).format()} (${moment(asNumber).fromNow()})`;
|
|
||||||
} else if (val && format === 'duration') {
|
|
||||||
const asNumber = Number(val) * 1000;
|
|
||||||
return `${asNumber} (${moment.duration(asNumber).humanize()})`;
|
|
||||||
}
|
|
||||||
return value;
|
|
||||||
};
|
|
||||||
if (type === 'send') {
|
|
||||||
return <ContractMethodSend {...this.props} />;
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<div className={`Proposals-proposal-contract-status is-${status || ''}`} />
|
|
||||||
<span className="Proposals-proposal-contract-method">{name}:</span>
|
|
||||||
{(!isObj && <span> {fmt(value)}</span>) || (
|
|
||||||
<div className="Proposals-proposal-contract-array">
|
|
||||||
{isArray &&
|
|
||||||
name !== 'milestones' &&
|
|
||||||
name !== 'contributors' &&
|
|
||||||
(value as string[]).map((x, i) => (
|
|
||||||
<div key={x}>
|
|
||||||
{i}: {x}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
{isArray &&
|
|
||||||
name === 'milestones' && (
|
|
||||||
<div className="Proposals-proposal-contract-array-milestones">
|
|
||||||
{(value as ContractMilestone[]).map((cm, idx) => (
|
|
||||||
<div key={idx}>
|
|
||||||
<div>
|
|
||||||
<span>paid:</span> {JSON.stringify(cm.paid)}
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<span>amount:</span> {cm.amount}
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<span>payoutRequestVoteDeadline:</span>{' '}
|
|
||||||
{Number(cm.payoutRequestVoteDeadline) < 2
|
|
||||||
? cm.payoutRequestVoteDeadline
|
|
||||||
: moment(Number(cm.payoutRequestVoteDeadline) * 1000).fromNow()}
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<span>amountVotingAgainstPayout:</span>{' '}
|
|
||||||
{cm.amountVotingAgainstPayout}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{isArray &&
|
|
||||||
name === 'contributors' && (
|
|
||||||
<div className="Proposals-proposal-contract-array-contributors">
|
|
||||||
{(value as ContractContributor[]).map(c => (
|
|
||||||
<div key={c.address}>
|
|
||||||
<div>
|
|
||||||
<span>address:</span> {c.address}
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<span>milestoneNoVotes:</span>{' '}
|
|
||||||
{JSON.stringify(c.milestoneNoVotes)}
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<span>contributionAmount:</span> {c.contributionAmount}
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<span>refundVote:</span> {JSON.stringify(c.refundVote)}
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<span>refunded:</span> {JSON.stringify(c.refunded)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const ContractMethod = view(ContractMethodNaked);
|
|
||||||
|
|
||||||
// tslint:disable-next-line:max-classes-per-file
|
|
||||||
class ContractMethodSendNaked extends React.Component<
|
|
||||||
TContractMethod & { proposalId: number; name: string }
|
|
||||||
> {
|
|
||||||
state = {
|
|
||||||
args: this.props.input.map(i => (i.type === 'boolean' ? false : '')) as any[],
|
|
||||||
};
|
|
||||||
render() {
|
|
||||||
const { name, status, input, proposalId, error } = this.props;
|
|
||||||
return (
|
|
||||||
<div className="Proposals-proposal-contract-method">
|
|
||||||
<div className={`Proposals-proposal-contract-status is-${status || ''}`} />
|
|
||||||
<div className="Proposals-proposal-contract-inputs">
|
|
||||||
{input.length === 0 && 'no input'}
|
|
||||||
{input.map(
|
|
||||||
(x, idx) =>
|
|
||||||
((x.type === 'wei' || x.type === 'integer') && (
|
|
||||||
<InputNumber
|
|
||||||
size="small"
|
|
||||||
key={x.name}
|
|
||||||
name={x.name}
|
|
||||||
placeholder={`${x.name} (${x.type})`}
|
|
||||||
onChange={val => {
|
|
||||||
const args = [...this.state.args];
|
|
||||||
args[idx] = val;
|
|
||||||
this.setState({ args });
|
|
||||||
}}
|
|
||||||
value={this.state.args[idx]}
|
|
||||||
className={`Proposals-proposal-contract-input is-${x.type || ''}`}
|
|
||||||
/>
|
|
||||||
)) ||
|
|
||||||
(x.type === 'string' && (
|
|
||||||
<Input
|
|
||||||
size="small"
|
|
||||||
key={x.name}
|
|
||||||
name={x.name}
|
|
||||||
placeholder={`${x.name} (${x.type})`}
|
|
||||||
onChange={evt => {
|
|
||||||
const args = [...this.state.args];
|
|
||||||
args[idx] = evt.currentTarget.value;
|
|
||||||
this.setState({ args });
|
|
||||||
}}
|
|
||||||
value={this.state.args[idx]}
|
|
||||||
className={`Proposals-proposal-contract-input is-${x.type || ''}`}
|
|
||||||
/>
|
|
||||||
)) || (
|
|
||||||
<Checkbox
|
|
||||||
key={x.name}
|
|
||||||
onChange={evt => {
|
|
||||||
const args = [...this.state.args];
|
|
||||||
args[idx] = evt.target.checked;
|
|
||||||
this.setState({ args });
|
|
||||||
}}
|
|
||||||
value={this.state.args[idx]}
|
|
||||||
className={`Proposals-proposal-contract-input is-${x.type || ''}`}
|
|
||||||
>
|
|
||||||
{x.name}
|
|
||||||
</Checkbox>
|
|
||||||
),
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
icon="arrow-right"
|
|
||||||
size="default"
|
|
||||||
loading={status === 'loading' || status === 'waiting'}
|
|
||||||
onClick={() =>
|
|
||||||
store.proposalContractSend(
|
|
||||||
proposalId,
|
|
||||||
name as keyof Contract,
|
|
||||||
input,
|
|
||||||
this.state.args,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{name}
|
|
||||||
</Button>
|
|
||||||
{error && <Alert message={error} type="error" closable={true} />}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const ContractMethodSend = view(ContractMethodSendNaked);
|
|
||||||
|
|
||||||
const Proposals = withRouter(view(ProposalsNaked));
|
const Proposals = withRouter(view(ProposalsNaked));
|
||||||
export default Proposals;
|
export default Proposals;
|
||||||
|
|
|
@ -1,13 +1,6 @@
|
||||||
import { cloneDeep } from 'lodash';
|
|
||||||
import Web3 from 'web3';
|
|
||||||
import { store } from 'react-easy-state';
|
import { store } from 'react-easy-state';
|
||||||
import axios, { AxiosError } from 'axios';
|
import axios, { AxiosError } from 'axios';
|
||||||
import { User, Proposal, INITIAL_CONTRACT, Contract, ContractMethodInput } from './types';
|
import { User, Proposal } from './types';
|
||||||
import {
|
|
||||||
initializeWeb3,
|
|
||||||
populateProposalContract,
|
|
||||||
proposalContractSend,
|
|
||||||
} from './web3helper';
|
|
||||||
|
|
||||||
// API
|
// API
|
||||||
const api = axios.create({
|
const api = axios.create({
|
||||||
|
@ -50,7 +43,6 @@ async function deleteUser(id: string) {
|
||||||
|
|
||||||
async function fetchProposals() {
|
async function fetchProposals() {
|
||||||
const { data } = await api.get('/admin/proposals');
|
const { data } = await api.get('/admin/proposals');
|
||||||
data.forEach((p: Proposal) => (p.contract = cloneDeep(INITIAL_CONTRACT)));
|
|
||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -73,12 +65,6 @@ const app = store({
|
||||||
users: [] as User[],
|
users: [] as User[],
|
||||||
proposalsFetched: false,
|
proposalsFetched: false,
|
||||||
proposals: [] as Proposal[],
|
proposals: [] as Proposal[],
|
||||||
web3Type: '',
|
|
||||||
web3Enabled: false,
|
|
||||||
ethNetId: -1,
|
|
||||||
ethAccount: '',
|
|
||||||
crowdFundFactoryDefinitionStatus: '',
|
|
||||||
crowdFundGeneralStatus: 'idle',
|
|
||||||
|
|
||||||
removeGeneralError(i: number) {
|
removeGeneralError(i: number) {
|
||||||
app.generalError.splice(i, 1);
|
app.generalError.splice(i, 1);
|
||||||
|
@ -144,24 +130,6 @@ const app = store({
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
async populateProposalContract(proposalId: number) {
|
|
||||||
console.log(proposalId);
|
|
||||||
if (web3) {
|
|
||||||
await populateProposalContract(app, web3, proposalId);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
async proposalContractSend(
|
|
||||||
proposalId: number,
|
|
||||||
methodName: keyof Contract,
|
|
||||||
inputs: ContractMethodInput[],
|
|
||||||
args: any[],
|
|
||||||
) {
|
|
||||||
if (web3) {
|
|
||||||
await proposalContractSend(app, web3, proposalId, methodName, inputs, args);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
async deleteProposal(id: number) {
|
async deleteProposal(id: number) {
|
||||||
try {
|
try {
|
||||||
await deleteProposal(id);
|
await deleteProposal(id);
|
||||||
|
@ -188,8 +156,5 @@ function handleApiError(e: AxiosError) {
|
||||||
app.checkLogin();
|
app.checkLogin();
|
||||||
window.setInterval(app.checkLogin, 10000);
|
window.setInterval(app.checkLogin, 10000);
|
||||||
|
|
||||||
let web3: null | Web3 = null;
|
|
||||||
initializeWeb3(app).then(x => (web3 = x));
|
|
||||||
|
|
||||||
export type TApp = typeof app;
|
export type TApp = typeof app;
|
||||||
export default app;
|
export default app;
|
||||||
|
|
|
@ -23,7 +23,6 @@ export interface Proposal {
|
||||||
team: User[];
|
team: User[];
|
||||||
comments: Comment[];
|
comments: Comment[];
|
||||||
contractStatus: string;
|
contractStatus: string;
|
||||||
contract: Contract;
|
|
||||||
}
|
}
|
||||||
export interface Comment {
|
export interface Comment {
|
||||||
commentId: string;
|
commentId: string;
|
||||||
|
@ -41,89 +40,3 @@ export interface User {
|
||||||
proposals: Proposal[];
|
proposals: Proposal[];
|
||||||
comments: Comment[];
|
comments: Comment[];
|
||||||
}
|
}
|
||||||
|
|
||||||
// web3 contract
|
|
||||||
export const INITIAL_CONTRACT_CONTRIBUTOR = {
|
|
||||||
address: '',
|
|
||||||
milestoneNoVotes: [] as string[],
|
|
||||||
contributionAmount: '',
|
|
||||||
refundVote: false,
|
|
||||||
refunded: false,
|
|
||||||
};
|
|
||||||
export type ContractContributor = typeof INITIAL_CONTRACT_CONTRIBUTOR;
|
|
||||||
export const INITIAL_CONTRACT_MILESTONE = {
|
|
||||||
amount: '',
|
|
||||||
payoutRequestVoteDeadline: '',
|
|
||||||
amountVotingAgainstPayout: '',
|
|
||||||
paid: '',
|
|
||||||
};
|
|
||||||
export type ContractMilestone = typeof INITIAL_CONTRACT_MILESTONE;
|
|
||||||
export interface ContractMethodInput {
|
|
||||||
type: string;
|
|
||||||
name: string;
|
|
||||||
}
|
|
||||||
export const INITIAL_CONTRACT_METHOD = {
|
|
||||||
updated: '',
|
|
||||||
status: 'unloaded',
|
|
||||||
value: '' as string | string[] | ContractMilestone[] | ContractContributor[],
|
|
||||||
type: '',
|
|
||||||
input: [] as ContractMethodInput[],
|
|
||||||
error: '',
|
|
||||||
format: '',
|
|
||||||
};
|
|
||||||
export type ContractMethod = typeof INITIAL_CONTRACT_METHOD;
|
|
||||||
export const INITIAL_CONTRACT = {
|
|
||||||
isCallerTrustee: { ...INITIAL_CONTRACT_METHOD },
|
|
||||||
immediateFirstMilestonePayout: { ...INITIAL_CONTRACT_METHOD },
|
|
||||||
beneficiary: { ...INITIAL_CONTRACT_METHOD },
|
|
||||||
amountRaised: { ...INITIAL_CONTRACT_METHOD },
|
|
||||||
raiseGoal: { ...INITIAL_CONTRACT_METHOD },
|
|
||||||
isRaiseGoalReached: { ...INITIAL_CONTRACT_METHOD },
|
|
||||||
amountVotingForRefund: { ...INITIAL_CONTRACT_METHOD },
|
|
||||||
milestoneVotingPeriod: { ...INITIAL_CONTRACT_METHOD, format: 'duration' },
|
|
||||||
deadline: { ...INITIAL_CONTRACT_METHOD, format: 'time' },
|
|
||||||
isFailed: { ...INITIAL_CONTRACT_METHOD },
|
|
||||||
getBalance: { ...INITIAL_CONTRACT_METHOD, type: 'eth' },
|
|
||||||
frozen: { ...INITIAL_CONTRACT_METHOD },
|
|
||||||
getFreezeReason: { ...INITIAL_CONTRACT_METHOD },
|
|
||||||
trustees: { ...INITIAL_CONTRACT_METHOD, type: 'array' },
|
|
||||||
contributorList: { ...INITIAL_CONTRACT_METHOD, type: 'array' },
|
|
||||||
contributors: { ...INITIAL_CONTRACT_METHOD, type: 'deep' },
|
|
||||||
milestones: { ...INITIAL_CONTRACT_METHOD, type: 'array' },
|
|
||||||
contribute: {
|
|
||||||
...INITIAL_CONTRACT_METHOD,
|
|
||||||
type: 'send',
|
|
||||||
input: [{ name: 'value', type: 'wei' }],
|
|
||||||
},
|
|
||||||
refund: {
|
|
||||||
...INITIAL_CONTRACT_METHOD,
|
|
||||||
type: 'send',
|
|
||||||
input: [],
|
|
||||||
},
|
|
||||||
withdraw: {
|
|
||||||
...INITIAL_CONTRACT_METHOD,
|
|
||||||
type: 'send',
|
|
||||||
input: [{ name: 'address', type: 'string' }],
|
|
||||||
},
|
|
||||||
requestMilestonePayout: {
|
|
||||||
...INITIAL_CONTRACT_METHOD,
|
|
||||||
type: 'send',
|
|
||||||
input: [{ name: 'index', type: 'integer' }],
|
|
||||||
},
|
|
||||||
voteMilestonePayout: {
|
|
||||||
...INITIAL_CONTRACT_METHOD,
|
|
||||||
type: 'send',
|
|
||||||
input: [{ name: 'index', type: 'integer' }, { name: 'vote', type: 'boolean' }],
|
|
||||||
},
|
|
||||||
payMilestonePayout: {
|
|
||||||
...INITIAL_CONTRACT_METHOD,
|
|
||||||
type: 'send',
|
|
||||||
input: [{ name: 'index', type: 'integer' }],
|
|
||||||
},
|
|
||||||
voteRefund: {
|
|
||||||
...INITIAL_CONTRACT_METHOD,
|
|
||||||
type: 'send',
|
|
||||||
input: [{ name: 'vote', type: 'boolean' }],
|
|
||||||
},
|
|
||||||
};
|
|
||||||
export type Contract = typeof INITIAL_CONTRACT;
|
|
||||||
|
|
|
@ -1,227 +0,0 @@
|
||||||
import { pick } from 'lodash';
|
|
||||||
import Web3 from 'web3';
|
|
||||||
import { TransactionObject } from 'web3/eth/types';
|
|
||||||
import EthContract from 'web3/eth/contract';
|
|
||||||
import { TApp } from './store';
|
|
||||||
import CrowdFundFactory from 'contracts/contracts/CrowdFundFactory.json';
|
|
||||||
import CrowdFund from 'contracts/contracts/CrowdFund.json';
|
|
||||||
import {
|
|
||||||
Proposal,
|
|
||||||
Contract,
|
|
||||||
INITIAL_CONTRACT,
|
|
||||||
ContractMilestone,
|
|
||||||
ContractContributor,
|
|
||||||
ContractMethodInput,
|
|
||||||
INITIAL_CONTRACT_CONTRIBUTOR,
|
|
||||||
INITIAL_CONTRACT_MILESTONE,
|
|
||||||
} from './types';
|
|
||||||
|
|
||||||
type Web3Method<T> = (index: number) => TransactionObject<T>;
|
|
||||||
|
|
||||||
export async function initializeWeb3(app: TApp): Promise<null | Web3> {
|
|
||||||
let web3 = (window as any).web3;
|
|
||||||
if (web3) {
|
|
||||||
app.web3Type = 'injected';
|
|
||||||
web3 = new Web3(web3.currentProvider);
|
|
||||||
} else if (process.env.NODE_ENV !== 'production') {
|
|
||||||
const localProviderString = 'http://localhost:8545';
|
|
||||||
const provider = new Web3.providers.HttpProvider(localProviderString);
|
|
||||||
web3 = new Web3(provider);
|
|
||||||
app.web3Type = 'local - ' + localProviderString;
|
|
||||||
} else {
|
|
||||||
console.error('No web3 detected!');
|
|
||||||
app.web3Enabled = false;
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
app.ethNetId = await web3.eth.net.getId();
|
|
||||||
getAccount(app, web3);
|
|
||||||
window.setInterval(() => getAccount(app, web3), 10000);
|
|
||||||
checkCrowdFundFactory(app, web3);
|
|
||||||
app.web3Enabled = true;
|
|
||||||
return web3;
|
|
||||||
} catch (e) {
|
|
||||||
if (e.message && e.message.startsWith('Invalid JSON RPC response:')) {
|
|
||||||
console.warn('Unable to interact with web3. Web3 will be disabled.');
|
|
||||||
} else {
|
|
||||||
console.error('There was a problem interacting with web3.', e);
|
|
||||||
}
|
|
||||||
app.web3Enabled = false;
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function getAccount(app: TApp, web3: Web3) {
|
|
||||||
await web3.eth.getAccounts((_, accounts) => {
|
|
||||||
app.ethAccount = (accounts.length && accounts[0]) || '';
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function checkCrowdFundFactory(app: TApp, web3: Web3) {
|
|
||||||
web3.eth.net.getId((_, netId) => {
|
|
||||||
const networks = Object.keys((CrowdFundFactory as any).networks).join(', ');
|
|
||||||
if (!(CrowdFundFactory as any).networks[netId]) {
|
|
||||||
app.crowdFundFactoryDefinitionStatus = `network mismatch (has ${networks})`;
|
|
||||||
} else {
|
|
||||||
app.crowdFundFactoryDefinitionStatus = `loaded, has correct network (${networks})`;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function proposalContractSend(
|
|
||||||
app: TApp,
|
|
||||||
web3: Web3,
|
|
||||||
proposalId: number,
|
|
||||||
methodName: keyof Contract,
|
|
||||||
inputs: ContractMethodInput[],
|
|
||||||
args: any[],
|
|
||||||
) {
|
|
||||||
const storeProposal = app.proposals.find(p => p.proposalId === proposalId);
|
|
||||||
if (storeProposal) {
|
|
||||||
const { proposalAddress } = storeProposal;
|
|
||||||
await getAccount(app, web3);
|
|
||||||
const storeMethod = storeProposal.contract[methodName];
|
|
||||||
const contract = new web3.eth.Contract(CrowdFund.abi, proposalAddress);
|
|
||||||
app.crowdFundGeneralStatus = `calling (${storeProposal.title}).${methodName}...`;
|
|
||||||
try {
|
|
||||||
console.log(args);
|
|
||||||
storeMethod.status = 'loading';
|
|
||||||
storeMethod.error = '';
|
|
||||||
if (inputs.length === 1 && inputs[0].name === 'value') {
|
|
||||||
await contract.methods[methodName]()
|
|
||||||
.send({
|
|
||||||
from: app.ethAccount,
|
|
||||||
value: args[0],
|
|
||||||
})
|
|
||||||
.once('transactionHash', () => {
|
|
||||||
storeMethod.status = 'waiting';
|
|
||||||
})
|
|
||||||
.once('confirmation', () => {
|
|
||||||
storeMethod.status = 'loaded';
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
await contract.methods[methodName](...args)
|
|
||||||
.send({ from: app.ethAccount })
|
|
||||||
.once('transactionHash', () => {
|
|
||||||
storeMethod.status = 'waiting';
|
|
||||||
})
|
|
||||||
.once('confirmation', () => {
|
|
||||||
storeMethod.status = 'loaded';
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
console.error(e);
|
|
||||||
storeMethod.error = e.message || e.toString();
|
|
||||||
storeMethod.status = 'error';
|
|
||||||
}
|
|
||||||
app.crowdFundGeneralStatus = `idle`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function populateProposalContract(
|
|
||||||
app: TApp,
|
|
||||||
web3: Web3,
|
|
||||||
proposalId: number,
|
|
||||||
) {
|
|
||||||
const storeProposal = app.proposals.find(p => p.proposalId === proposalId);
|
|
||||||
|
|
||||||
if (storeProposal) {
|
|
||||||
const { proposalAddress } = storeProposal;
|
|
||||||
const contract = new web3.eth.Contract(CrowdFund.abi, proposalAddress);
|
|
||||||
storeProposal.contractStatus = 'loading...';
|
|
||||||
const methods = Object.keys(INITIAL_CONTRACT).map(k => k as keyof Contract);
|
|
||||||
for (const method of methods) {
|
|
||||||
const methodType = INITIAL_CONTRACT[method].type;
|
|
||||||
if (methodType !== 'deep' && methodType !== 'send') {
|
|
||||||
app.crowdFundGeneralStatus = `calling (${storeProposal.title}).${method}...`;
|
|
||||||
const storeMethod = storeProposal.contract[method];
|
|
||||||
const contractMethod = contract.methods[method];
|
|
||||||
try {
|
|
||||||
storeMethod.status = 'loading';
|
|
||||||
if (methodType === 'eth' && method === 'getBalance') {
|
|
||||||
storeMethod.value = (await web3.eth.getBalance(proposalAddress)) + '';
|
|
||||||
} else if (methodType === 'array') {
|
|
||||||
const result = await collectArrayElements(contractMethod, app.ethAccount);
|
|
||||||
if (method === 'milestones') {
|
|
||||||
storeMethod.value = result.map(r =>
|
|
||||||
// clean-up incoming object before attaching to store
|
|
||||||
cleanClone(INITIAL_CONTRACT_MILESTONE, r),
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
storeMethod.value = result.map(r => r + '');
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
storeMethod.value =
|
|
||||||
(await contractMethod().call({ from: app.ethAccount })) + '';
|
|
||||||
}
|
|
||||||
storeMethod.status = 'loaded';
|
|
||||||
} catch (e) {
|
|
||||||
console.error(proposalId, method, e);
|
|
||||||
storeMethod.status = 'error';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
await populateProposalContractDeep(storeProposal.contract, contract, app.ethAccount);
|
|
||||||
storeProposal.contractStatus = 'updated @ ' + new Date().toISOString();
|
|
||||||
app.crowdFundGeneralStatus = `idle`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function populateProposalContractDeep(
|
|
||||||
storeContract: Proposal['contract'],
|
|
||||||
contract: EthContract,
|
|
||||||
fromAcct: string,
|
|
||||||
) {
|
|
||||||
storeContract.contributors.status = 'loading';
|
|
||||||
const milestones = storeContract.milestones.value as ContractMilestone[];
|
|
||||||
const contributorList = storeContract.contributorList.value as string[];
|
|
||||||
const contributors = await Promise.all(
|
|
||||||
contributorList.map(async addr => {
|
|
||||||
const contributor = await contract.methods
|
|
||||||
.contributors(addr)
|
|
||||||
.call({ from: fromAcct });
|
|
||||||
contributor.address = addr;
|
|
||||||
contributor.milestoneNoVotes = await Promise.all(
|
|
||||||
milestones.map(
|
|
||||||
async (_, idx) =>
|
|
||||||
await contract.methods
|
|
||||||
.getContributorMilestoneVote(addr, idx)
|
|
||||||
.call({ from: fromAcct }),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
contributor.contributionAmount = await contract.methods
|
|
||||||
.getContributorContributionAmount(addr)
|
|
||||||
.call({ from: fromAcct });
|
|
||||||
// clean-up incoming object before attaching to store
|
|
||||||
return cleanClone(INITIAL_CONTRACT_CONTRIBUTOR, contributor);
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
storeContract.contributors.value = contributors as ContractContributor[];
|
|
||||||
storeContract.contributors.status = 'loaded';
|
|
||||||
}
|
|
||||||
|
|
||||||
// clone and filter keys by keySource object's keys
|
|
||||||
export function cleanClone<T extends object>(keySource: T, target: Partial<T>) {
|
|
||||||
const sourceKeys = Object.keys(keySource);
|
|
||||||
const fullClone = { ...(target as object) };
|
|
||||||
const clone = pick(fullClone, sourceKeys);
|
|
||||||
return clone as T;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function collectArrayElements<T>(
|
|
||||||
method: Web3Method<T>,
|
|
||||||
account: string,
|
|
||||||
): Promise<T[]> {
|
|
||||||
const arrayElements = [];
|
|
||||||
let noError = true;
|
|
||||||
let index = 0;
|
|
||||||
while (noError) {
|
|
||||||
try {
|
|
||||||
arrayElements.push(await method(index).call({ from: account }));
|
|
||||||
index += 1;
|
|
||||||
} catch (e) {
|
|
||||||
noError = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return arrayElements;
|
|
||||||
}
|
|
|
@ -20,7 +20,6 @@
|
||||||
"lib": ["dom", "es2017"],
|
"lib": ["dom", "es2017"],
|
||||||
"paths": {
|
"paths": {
|
||||||
"src/*": ["./*"],
|
"src/*": ["./*"],
|
||||||
"contracts/*": ["../../contract/build/*"],
|
|
||||||
"components/*": ["./components/*"],
|
"components/*": ["./components/*"],
|
||||||
"styles/*": ["./styles/*"]
|
"styles/*": ["./styles/*"]
|
||||||
}
|
}
|
||||||
|
|
|
@ -113,7 +113,6 @@ module.exports = {
|
||||||
// tsconfig.compilerOptions.paths should sync with these
|
// tsconfig.compilerOptions.paths should sync with these
|
||||||
alias: {
|
alias: {
|
||||||
src: path.resolve(__dirname, 'src'),
|
src: path.resolve(__dirname, 'src'),
|
||||||
contracts: path.resolve(__dirname, '../contract/build'),
|
|
||||||
components: path.resolve(__dirname, 'src/components'),
|
components: path.resolve(__dirname, 'src/components'),
|
||||||
styles: path.resolve(__dirname, 'src/styles'),
|
styles: path.resolve(__dirname, 'src/styles'),
|
||||||
},
|
},
|
||||||
|
|
867
admin/yarn.lock
867
admin/yarn.lock
File diff suppressed because it is too large
Load Diff
|
@ -7,17 +7,11 @@ REDISTOGO_URL="redis://localhost:6379"
|
||||||
SECRET_KEY="not-so-secret"
|
SECRET_KEY="not-so-secret"
|
||||||
SENDGRID_API_KEY="optional, but emails won't send without it"
|
SENDGRID_API_KEY="optional, but emails won't send without it"
|
||||||
|
|
||||||
# for ropsten use the following
|
|
||||||
# ETHEREUM_ENDPOINT_URI = "https://ropsten.infura.io/API_KEY"
|
|
||||||
ETHEREUM_ENDPOINT_URI = "http://localhost:8545"
|
|
||||||
|
|
||||||
# CROWD_FUND_URL = "https://eip-712.herokuapp.com/contract/crowd-fund"
|
|
||||||
# CROWD_FUND_FACTORY_URL = "https://eip-712.herokuapp.com/contract/factory"
|
|
||||||
CROWD_FUND_URL = "http://localhost:5000/dev-contracts/CrowdFund.json"
|
|
||||||
CROWD_FUND_FACTORY_URL = "http://localhost:5000/dev-contracts/CrowdFundFactory.json"
|
|
||||||
|
|
||||||
# SENTRY_DSN="https://PUBLICKEY@sentry.io/PROJECTID"
|
# SENTRY_DSN="https://PUBLICKEY@sentry.io/PROJECTID"
|
||||||
# SENTRY_RELEASE="optional, overrides git hash"
|
# SENTRY_RELEASE="optional, overrides git hash"
|
||||||
|
|
||||||
UPLOAD_DIRECTORY = "/tmp"
|
AWS_ACCESS_KEY_ID=your-user-access-key
|
||||||
UPLOAD_URL = "http://localhost:5000" # for constructing download url
|
AWS_SECRET_ACCESS_KEY=your-user-secret-access-key
|
||||||
|
AWS_DEFAULT_REGION=us-west-2
|
||||||
|
S3_BUCKET=your-bucket-name
|
||||||
|
|
|
@ -29,8 +29,8 @@ database tables and perform the initial migration
|
||||||
flask db migrate
|
flask db migrate
|
||||||
flask db upgrade
|
flask db upgrade
|
||||||
|
|
||||||
|
|
||||||
## Running the App
|
## Running the App
|
||||||
|
|
||||||
Depending on what you need to run, there are several services that need to be started
|
Depending on what you need to run, there are several services that need to be started
|
||||||
|
|
||||||
If you just need the API, you can run
|
If you just need the API, you can run
|
||||||
|
@ -46,9 +46,8 @@ To deploy
|
||||||
export DATABASE_URL="<YOUR DATABASE URL>"
|
export DATABASE_URL="<YOUR DATABASE URL>"
|
||||||
flask run # start the flask server
|
flask run # start the flask server
|
||||||
|
|
||||||
In your production environment, make sure the ``FLASK_DEBUG`` environment
|
In your production environment, make sure the `FLASK_DEBUG` environment
|
||||||
variable is unset or is set to ``0``.
|
variable is unset or is set to `0`.
|
||||||
|
|
||||||
|
|
||||||
## Shell
|
## Shell
|
||||||
|
|
||||||
|
@ -56,8 +55,7 @@ To open the interactive shell, run
|
||||||
|
|
||||||
flask shell
|
flask shell
|
||||||
|
|
||||||
By default, you will have access to the flask ``app``.
|
By default, you will have access to the flask `app`.
|
||||||
|
|
||||||
|
|
||||||
## Running Tests
|
## Running Tests
|
||||||
|
|
||||||
|
@ -65,7 +63,6 @@ To run all tests, run
|
||||||
|
|
||||||
flask test
|
flask test
|
||||||
|
|
||||||
|
|
||||||
## Migrations
|
## Migrations
|
||||||
|
|
||||||
Whenever a database migration needs to be made. Run the following commands
|
Whenever a database migration needs to be made. Run the following commands
|
||||||
|
@ -78,14 +75,16 @@ This will generate a new migration script. Then run
|
||||||
|
|
||||||
To apply the migration.
|
To apply the migration.
|
||||||
|
|
||||||
For a full migration command reference, run ``flask db --help``.
|
For a full migration command reference, run `flask db --help`.
|
||||||
|
|
||||||
## Commands
|
## Commands
|
||||||
|
|
||||||
To create a proposal, run
|
To create a proposal, run
|
||||||
|
|
||||||
flask create_proposal "FUNDING_REQUIRED" 1 123 "My Awesome Proposal" "### Hi! I have a great proposal"
|
flask create_proposal "FUNDING_REQUIRED" 1 123 "My Awesome Proposal" "### Hi! I have a great proposal"
|
||||||
|
|
||||||
## External Services
|
## External Services
|
||||||
|
|
||||||
To decode EIP-712 signed messages, a Grant.io deployed service was created `https://eip-712.herokuapp.com`.
|
To decode EIP-712 signed messages, a Grant.io deployed service was created `https://eip-712.herokuapp.com`.
|
||||||
|
|
||||||
To adjust this endpoint, simply export `AUTH_URL` with a new endpoint value:
|
To adjust this endpoint, simply export `AUTH_URL` with a new endpoint value:
|
||||||
|
@ -93,3 +92,48 @@ To adjust this endpoint, simply export `AUTH_URL` with a new endpoint value:
|
||||||
export AUTH_URL=http://new-endpoint.com
|
export AUTH_URL=http://new-endpoint.com
|
||||||
|
|
||||||
To learn more about this auth service, you can visit the repo [here](https://github.com/grant-project/eip-712-server).
|
To learn more about this auth service, you can visit the repo [here](https://github.com/grant-project/eip-712-server).
|
||||||
|
|
||||||
|
## S3 Storage Setup
|
||||||
|
|
||||||
|
1. create bucket, keep the `bucket name` and `region` handy
|
||||||
|
1. unblock public access `Amazon S3 > BUCKET_NAME > Permissions > Public access settings`
|
||||||
|
1. set the CORS configuration, replace HOST_NAME with desired domain, or `*` to allow all
|
||||||
|
Amazon S3 > BUCKET_NAME > Permissions > CORS configuration
|
||||||
|
|
||||||
|
```
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<CORSConfiguration xmlns="http://s3.amazonaws.com/doc/2006-03-01/">
|
||||||
|
<CORSRule>
|
||||||
|
<AllowedOrigin>HOST_NAME</AllowedOrigin>
|
||||||
|
<AllowedMethod>GET</AllowedMethod>
|
||||||
|
<AllowedMethod>POST</AllowedMethod>
|
||||||
|
<AllowedMethod>PUT</AllowedMethod>
|
||||||
|
<AllowedHeader>*</AllowedHeader>
|
||||||
|
</CORSRule>
|
||||||
|
</CORSConfiguration>
|
||||||
|
```
|
||||||
|
|
||||||
|
1. create IAM Policy, replace `BUCKET_NAME` with correct name.
|
||||||
|
|
||||||
|
```
|
||||||
|
{
|
||||||
|
"Version": "2012-10-17",
|
||||||
|
"Statement": [
|
||||||
|
{
|
||||||
|
"Effect": "Allow",
|
||||||
|
"Action": [
|
||||||
|
"s3:PutObject",
|
||||||
|
"s3:PutObjectAcl",
|
||||||
|
"s3:GetObject",
|
||||||
|
"s3:DeleteObject"
|
||||||
|
],
|
||||||
|
"Resource": [
|
||||||
|
"arn:aws:s3:::BUCKET_NAME/*"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
1. create IAM user with programatic access (Access key) and assign that user the policy created above
|
||||||
|
1. copy the user's `Access key ID`, `Secret access key`, `bucket name` & `bucket region` to private `.env`, see `.env.example`
|
||||||
|
|
|
@ -3,11 +3,12 @@
|
||||||
from flask import Flask
|
from flask import Flask
|
||||||
from flask_cors import CORS
|
from flask_cors import CORS
|
||||||
from flask_sslify import SSLify
|
from flask_sslify import SSLify
|
||||||
|
from flask_security import SQLAlchemyUserDatastore
|
||||||
from sentry_sdk.integrations.flask import FlaskIntegration
|
from sentry_sdk.integrations.flask import FlaskIntegration
|
||||||
import sentry_sdk
|
import sentry_sdk
|
||||||
|
|
||||||
from grant import commands, proposal, user, comment, milestone, admin, email, web3 as web3module
|
from grant import commands, proposal, user, comment, milestone, admin, email
|
||||||
from grant.extensions import bcrypt, migrate, db, ma, mail, web3
|
from grant.extensions import bcrypt, migrate, db, ma, mail, security
|
||||||
from grant.settings import SENTRY_RELEASE, ENV
|
from grant.settings import SENTRY_RELEASE, ENV
|
||||||
|
|
||||||
|
|
||||||
|
@ -36,9 +37,11 @@ def register_extensions(app):
|
||||||
migrate.init_app(app, db)
|
migrate.init_app(app, db)
|
||||||
ma.init_app(app)
|
ma.init_app(app)
|
||||||
mail.init_app(app)
|
mail.init_app(app)
|
||||||
web3.init_app(app)
|
user_datastore = SQLAlchemyUserDatastore(db, user.models.User, user.models.Role)
|
||||||
|
security.init_app(app, user_datastore)
|
||||||
|
|
||||||
CORS(app)
|
# supports_credentials for session cookies
|
||||||
|
CORS(app, supports_credentials=True)
|
||||||
SSLify(app)
|
SSLify(app)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
@ -51,9 +54,6 @@ def register_blueprints(app):
|
||||||
app.register_blueprint(milestone.views.blueprint)
|
app.register_blueprint(milestone.views.blueprint)
|
||||||
app.register_blueprint(admin.views.blueprint)
|
app.register_blueprint(admin.views.blueprint)
|
||||||
app.register_blueprint(email.views.blueprint)
|
app.register_blueprint(email.views.blueprint)
|
||||||
# Only add these routes locally
|
|
||||||
if ENV == 'development':
|
|
||||||
app.register_blueprint(web3module.dev_contracts.blueprint)
|
|
||||||
|
|
||||||
|
|
||||||
def register_shellcontext(app):
|
def register_shellcontext(app):
|
||||||
|
|
|
@ -15,10 +15,18 @@ TEST_PATH = os.path.join(PROJECT_ROOT, "tests")
|
||||||
|
|
||||||
|
|
||||||
@click.command()
|
@click.command()
|
||||||
def test():
|
@click.option(
|
||||||
|
"-t",
|
||||||
|
"--test",
|
||||||
|
default=None,
|
||||||
|
help="Specify a specific test string to match (ex: test_api_user)"
|
||||||
|
)
|
||||||
|
def test(test):
|
||||||
"""Run the tests."""
|
"""Run the tests."""
|
||||||
import pytest
|
import pytest
|
||||||
|
if test:
|
||||||
|
rv = pytest.main([TEST_PATH, "--verbose", "-k", test])
|
||||||
|
else:
|
||||||
rv = pytest.main([TEST_PATH, "--verbose"])
|
rv = pytest.main([TEST_PATH, "--verbose"])
|
||||||
exit(rv)
|
exit(rv)
|
||||||
|
|
||||||
|
|
|
@ -9,6 +9,7 @@ default_template_args = {
|
||||||
'unsubscribe_url': 'https://grant.io/unsubscribe',
|
'unsubscribe_url': 'https://grant.io/unsubscribe',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def signup_info(email_args):
|
def signup_info(email_args):
|
||||||
return {
|
return {
|
||||||
'subject': 'Confirm your email on Grant.io',
|
'subject': 'Confirm your email on Grant.io',
|
||||||
|
@ -16,6 +17,7 @@ def signup_info(email_args):
|
||||||
'preview': 'Welcome to Grant.io, we just need to confirm your email address.',
|
'preview': 'Welcome to Grant.io, we just need to confirm your email address.',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def team_invite_info(email_args):
|
def team_invite_info(email_args):
|
||||||
return {
|
return {
|
||||||
'subject': '{} has invited you to a project'.format(email_args['inviter'].display_name),
|
'subject': '{} has invited you to a project'.format(email_args['inviter'].display_name),
|
||||||
|
@ -23,6 +25,7 @@ def team_invite_info(email_args):
|
||||||
'preview': 'You’ve been invited to the "{}" project team'.format(email_args['proposal'].title)
|
'preview': 'You’ve been invited to the "{}" project team'.format(email_args['proposal'].title)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
get_info_lookup = {
|
get_info_lookup = {
|
||||||
'signup': signup_info,
|
'signup': signup_info,
|
||||||
'team_invite': team_invite_info
|
'team_invite': team_invite_info
|
||||||
|
|
|
@ -5,11 +5,11 @@ from flask_marshmallow import Marshmallow
|
||||||
from flask_migrate import Migrate
|
from flask_migrate import Migrate
|
||||||
from flask_sqlalchemy import SQLAlchemy
|
from flask_sqlalchemy import SQLAlchemy
|
||||||
from flask_sendgrid import SendGrid
|
from flask_sendgrid import SendGrid
|
||||||
from flask_web3 import FlaskWeb3
|
from flask_security import Security
|
||||||
|
|
||||||
bcrypt = Bcrypt()
|
bcrypt = Bcrypt()
|
||||||
db = SQLAlchemy()
|
db = SQLAlchemy()
|
||||||
migrate = Migrate()
|
migrate = Migrate()
|
||||||
ma = Marshmallow()
|
ma = Marshmallow()
|
||||||
mail = SendGrid()
|
mail = SendGrid()
|
||||||
web3 = FlaskWeb3()
|
security = Security()
|
||||||
|
|
|
@ -53,7 +53,6 @@ class ProposalTeamInvite(db.Model):
|
||||||
def get_pending_for_user(user):
|
def get_pending_for_user(user):
|
||||||
return ProposalTeamInvite.query.filter(
|
return ProposalTeamInvite.query.filter(
|
||||||
ProposalTeamInvite.accepted == None,
|
ProposalTeamInvite.accepted == None,
|
||||||
(func.lower(user.account_address) == func.lower(ProposalTeamInvite.address)) |
|
|
||||||
(func.lower(user.email_address) == func.lower(ProposalTeamInvite.address))
|
(func.lower(user.email_address) == func.lower(ProposalTeamInvite.address))
|
||||||
).all()
|
).all()
|
||||||
|
|
||||||
|
|
|
@ -10,10 +10,9 @@ from grant.comment.models import Comment, comment_schema, comments_schema
|
||||||
from grant.milestone.models import Milestone
|
from grant.milestone.models import Milestone
|
||||||
from grant.user.models import User, SocialMedia, Avatar
|
from grant.user.models import User, SocialMedia, Avatar
|
||||||
from grant.email.send import send_email
|
from grant.email.send import send_email
|
||||||
from grant.utils.auth import requires_sm, requires_team_member_auth, verify_signed_auth, BadSignatureException
|
from grant.utils.auth import requires_auth, requires_team_member_auth
|
||||||
from grant.utils.exceptions import ValidationException
|
from grant.utils.exceptions import ValidationException
|
||||||
from grant.utils.misc import is_email, make_url
|
from grant.utils.misc import is_email
|
||||||
from grant.web3.proposal import read_proposal
|
|
||||||
from .models import(
|
from .models import(
|
||||||
Proposal,
|
Proposal,
|
||||||
proposals_schema,
|
proposals_schema,
|
||||||
|
@ -65,12 +64,10 @@ def get_proposal_comments(proposal_id):
|
||||||
|
|
||||||
|
|
||||||
@blueprint.route("/<proposal_id>/comments", methods=["POST"])
|
@blueprint.route("/<proposal_id>/comments", methods=["POST"])
|
||||||
@requires_sm
|
@requires_auth
|
||||||
@endpoint.api(
|
@endpoint.api(
|
||||||
parameter('comment', type=str, required=True),
|
parameter('comment', type=str, required=True),
|
||||||
parameter('parentCommentId', type=int, required=False),
|
parameter('parentCommentId', type=int, required=False)
|
||||||
parameter('signedMessage', type=str, required=True),
|
|
||||||
parameter('rawTypedData', type=str, required=True)
|
|
||||||
)
|
)
|
||||||
def post_proposal_comments(proposal_id, comment, parent_comment_id, signed_message, raw_typed_data):
|
def post_proposal_comments(proposal_id, comment, parent_comment_id, signed_message, raw_typed_data):
|
||||||
# Make sure proposal exists
|
# Make sure proposal exists
|
||||||
|
@ -84,24 +81,6 @@ def post_proposal_comments(proposal_id, comment, parent_comment_id, signed_messa
|
||||||
if not parent:
|
if not parent:
|
||||||
return {"message": "Parent comment doesn’t exist"}, 400
|
return {"message": "Parent comment doesn’t exist"}, 400
|
||||||
|
|
||||||
# Make sure comment content matches
|
|
||||||
typed_data = ast.literal_eval(raw_typed_data)
|
|
||||||
if comment != typed_data['message']['comment']:
|
|
||||||
return {"message": "Comment doesn’t match signature data"}, 400
|
|
||||||
|
|
||||||
# Verify the signature
|
|
||||||
try:
|
|
||||||
sig_address = verify_signed_auth(signed_message, raw_typed_data)
|
|
||||||
if sig_address.lower() != g.current_user.account_address.lower():
|
|
||||||
return {
|
|
||||||
"message": "Message signature address ({sig_address}) doesn't match current account address ({account_address})".format(
|
|
||||||
sig_address=sig_address,
|
|
||||||
account_address=g.current_user.account_address
|
|
||||||
)
|
|
||||||
}, 400
|
|
||||||
except BadSignatureException:
|
|
||||||
return {"message": "Invalid message signature"}, 400
|
|
||||||
|
|
||||||
# Make the comment
|
# Make the comment
|
||||||
comment = Comment(
|
comment = Comment(
|
||||||
proposal_id=proposal_id,
|
proposal_id=proposal_id,
|
||||||
|
@ -129,21 +108,15 @@ def get_proposals(stage):
|
||||||
else:
|
else:
|
||||||
proposals = Proposal.query.order_by(Proposal.date_created.desc()).all()
|
proposals = Proposal.query.order_by(Proposal.date_created.desc()).all()
|
||||||
dumped_proposals = proposals_schema.dump(proposals)
|
dumped_proposals = proposals_schema.dump(proposals)
|
||||||
|
return dumped_proposals
|
||||||
try:
|
# except Exception as e:
|
||||||
for p in dumped_proposals:
|
# print(e)
|
||||||
proposal_contract = read_proposal(p['proposal_address'])
|
# print(traceback.format_exc())
|
||||||
p['crowd_fund'] = proposal_contract
|
# return {"message": "Oops! Something went wrong."}, 500
|
||||||
filtered_proposals = list(filter(lambda p: p['crowd_fund'] is not None, dumped_proposals))
|
|
||||||
return filtered_proposals
|
|
||||||
except Exception as e:
|
|
||||||
print(e)
|
|
||||||
print(traceback.format_exc())
|
|
||||||
return {"message": "Oops! Something went wrong."}, 500
|
|
||||||
|
|
||||||
|
|
||||||
@blueprint.route("/drafts", methods=["POST"])
|
@blueprint.route("/drafts", methods=["POST"])
|
||||||
@requires_sm
|
@requires_auth
|
||||||
@endpoint.api()
|
@endpoint.api()
|
||||||
def make_proposal_draft():
|
def make_proposal_draft():
|
||||||
proposal = Proposal.create(status="DRAFT")
|
proposal = Proposal.create(status="DRAFT")
|
||||||
|
@ -154,7 +127,7 @@ def make_proposal_draft():
|
||||||
|
|
||||||
|
|
||||||
@blueprint.route("/drafts", methods=["GET"])
|
@blueprint.route("/drafts", methods=["GET"])
|
||||||
@requires_sm
|
@requires_auth
|
||||||
@endpoint.api()
|
@endpoint.api()
|
||||||
def get_proposal_drafts():
|
def get_proposal_drafts():
|
||||||
proposals = (
|
proposals = (
|
||||||
|
@ -167,6 +140,7 @@ def get_proposal_drafts():
|
||||||
)
|
)
|
||||||
return proposals_schema.dump(proposals), 200
|
return proposals_schema.dump(proposals), 200
|
||||||
|
|
||||||
|
|
||||||
@blueprint.route("/<proposal_id>", methods=["PUT"])
|
@blueprint.route("/<proposal_id>", methods=["PUT"])
|
||||||
@requires_team_member_auth
|
@requires_team_member_auth
|
||||||
@endpoint.api(
|
@endpoint.api(
|
||||||
|
@ -278,6 +252,7 @@ def post_proposal_update(proposal_id, title, content):
|
||||||
dumped_update = proposal_update_schema.dump(update)
|
dumped_update = proposal_update_schema.dump(update)
|
||||||
return dumped_update, 201
|
return dumped_update, 201
|
||||||
|
|
||||||
|
|
||||||
@blueprint.route("/<proposal_id>/invite", methods=["POST"])
|
@blueprint.route("/<proposal_id>/invite", methods=["POST"])
|
||||||
@requires_team_member_auth
|
@requires_team_member_auth
|
||||||
@endpoint.api(
|
@endpoint.api(
|
||||||
|
@ -294,7 +269,7 @@ def post_proposal_team_invite(proposal_id, address):
|
||||||
# Send email
|
# Send email
|
||||||
# TODO: Move this to some background task / after request action
|
# TODO: Move this to some background task / after request action
|
||||||
email = address
|
email = address
|
||||||
user = User.get_by_identifier(email_address=address, account_address=address)
|
user = User.get_by_email(email_address=address)
|
||||||
if user:
|
if user:
|
||||||
email = user.email_address
|
email = user.email_address
|
||||||
if is_email(email):
|
if is_email(email):
|
||||||
|
@ -352,7 +327,7 @@ def get_proposal_contribution(proposal_id, contribution_id):
|
||||||
|
|
||||||
|
|
||||||
@blueprint.route("/<proposal_id>/contributions", methods=["POST"])
|
@blueprint.route("/<proposal_id>/contributions", methods=["POST"])
|
||||||
@requires_sm
|
@requires_auth
|
||||||
@endpoint.api(
|
@endpoint.api(
|
||||||
parameter('txId', type=str, required=True),
|
parameter('txId', type=str, required=True),
|
||||||
parameter('fromAddress', type=str, required=True),
|
parameter('fromAddress', type=str, required=True),
|
||||||
|
|
|
@ -9,21 +9,20 @@ environment variables.
|
||||||
import subprocess
|
import subprocess
|
||||||
from environs import Env
|
from environs import Env
|
||||||
|
|
||||||
|
|
||||||
def git_revision_short_hash():
|
def git_revision_short_hash():
|
||||||
try:
|
try:
|
||||||
return subprocess.check_output(['git', 'rev-parse', '--short', 'HEAD'])
|
return subprocess.check_output(['git', 'rev-parse', '--short', 'HEAD'])
|
||||||
except subprocess.CalledProcessError:
|
except subprocess.CalledProcessError:
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
|
|
||||||
env = Env()
|
env = Env()
|
||||||
env.read_env()
|
env.read_env()
|
||||||
|
|
||||||
ENV = env.str("FLASK_ENV", default="production")
|
ENV = env.str("FLASK_ENV", default="production")
|
||||||
DEBUG = ENV == "development"
|
DEBUG = ENV == "development"
|
||||||
SITE_URL = env.str('SITE_URL', default='https://grant.io')
|
SITE_URL = env.str('SITE_URL', default='https://grant.io')
|
||||||
AUTH_URL = env.str('AUTH_URL', default='https://eip-712.herokuapp.com')
|
|
||||||
CROWD_FUND_FACTORY_URL = env.str('CROWD_FUND_FACTORY_URL', default=None)
|
|
||||||
CROWD_FUND_URL = env.str('CROWD_FUND_URL', default=None)
|
|
||||||
SQLALCHEMY_DATABASE_URI = env.str("DATABASE_URL")
|
SQLALCHEMY_DATABASE_URI = env.str("DATABASE_URL")
|
||||||
QUEUES = ["default"]
|
QUEUES = ["default"]
|
||||||
SECRET_KEY = env.str("SECRET_KEY")
|
SECRET_KEY = env.str("SECRET_KEY")
|
||||||
|
@ -34,10 +33,13 @@ CACHE_TYPE = "simple" # Can be "memcached", "redis", etc.
|
||||||
SQLALCHEMY_TRACK_MODIFICATIONS = False
|
SQLALCHEMY_TRACK_MODIFICATIONS = False
|
||||||
SENDGRID_API_KEY = env.str("SENDGRID_API_KEY", default="")
|
SENDGRID_API_KEY = env.str("SENDGRID_API_KEY", default="")
|
||||||
SENDGRID_DEFAULT_FROM = "noreply@grant.io"
|
SENDGRID_DEFAULT_FROM = "noreply@grant.io"
|
||||||
ETHEREUM_PROVIDER = "http"
|
|
||||||
ETHEREUM_ENDPOINT_URI = env.str("ETHEREUM_ENDPOINT_URI")
|
|
||||||
SENTRY_DSN = env.str("SENTRY_DSN", default=None)
|
SENTRY_DSN = env.str("SENTRY_DSN", default=None)
|
||||||
SENTRY_RELEASE = env.str("SENTRY_RELEASE", default=git_revision_short_hash())
|
SENTRY_RELEASE = env.str("SENTRY_RELEASE", default=git_revision_short_hash())
|
||||||
UPLOAD_DIRECTORY = env.str("UPLOAD_DIRECTORY")
|
|
||||||
UPLOAD_URL = env.str("UPLOAD_URL")
|
|
||||||
MAX_CONTENT_LENGTH = 5 * 1024 * 1024 # 5MB (limits file uploads, raises RequestEntityTooLarge)
|
MAX_CONTENT_LENGTH = 5 * 1024 * 1024 # 5MB (limits file uploads, raises RequestEntityTooLarge)
|
||||||
|
AWS_ACCESS_KEY_ID = env.str("AWS_ACCESS_KEY_ID")
|
||||||
|
AWS_SECRET_ACCESS_KEY = env.str("AWS_SECRET_ACCESS_KEY")
|
||||||
|
AWS_DEFAULT_REGION = env.str("AWS_DEFAULT_REGION")
|
||||||
|
S3_BUCKET = env.str("S3_BUCKET")
|
||||||
|
SECURITY_USER_IDENTITY_ATTRIBUTES = ['email_address'] # default is 'email'
|
||||||
|
SECURITY_PASSWORD_HASH = 'bcrypt'
|
||||||
|
SECURITY_PASSWORD_SALT = SECRET_KEY
|
||||||
|
|
|
@ -1,15 +1,15 @@
|
||||||
<p style="margin: 0;">
|
<p style="margin: 0;">
|
||||||
You’ve been invited by <strong>{{ args.inviter.display_name }}</strong>
|
You’ve been invited by <strong>{{ args.inviter.display_name }}</strong> to
|
||||||
to join the team for
|
join the team for
|
||||||
<strong>{{ args.proposal.title or '<em>Untitled Project</em>'|safe }}</strong>,
|
<strong>{{ args.proposal.title or '<em>Untitled Project</em>'|safe }}</strong
|
||||||
a project on Grant.io! If you want to accept the invitation, continue to the
|
>, a project on Grant.io! If you want to accept the invitation, continue to
|
||||||
site below.
|
the site below.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{% if not args.user %}
|
{% if not args.user %}
|
||||||
<p style="margin: 20px 0 0;">
|
<p style="margin: 20px 0 0;">
|
||||||
It looks like you don't yet have a Grant.io account, so you'll need to
|
It looks like you don't yet have a Grant.io account, so you'll need to sign up
|
||||||
sign up first before you can join the team.
|
first before you can join the team.
|
||||||
</p>
|
</p>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
|
@ -19,12 +19,13 @@
|
||||||
<table border="0" cellspacing="0" cellpadding="0">
|
<table border="0" cellspacing="0" cellpadding="0">
|
||||||
<tr>
|
<tr>
|
||||||
<td align="center" style="border-radius: 3px;" bgcolor="#530EEC">
|
<td align="center" style="border-radius: 3px;" bgcolor="#530EEC">
|
||||||
<a href="{{ args.invite_url }}" target="_blank" style="font-size: 20px; font-family: Helvetica, Arial, sans-serif; color: #ffffff; text-decoration: none; color: #ffffff; text-decoration: none; padding: 20px 50px; border-radius: 4px; border: 1px solid #530EEC; display: inline-block;">
|
<a
|
||||||
{% if args.user %}
|
href="{{ args.invite_url }}"
|
||||||
See invitation
|
target="_blank"
|
||||||
{% else %}
|
style="font-size: 20px; font-family: Helvetica, Arial, sans-serif; color: #ffffff; text-decoration: none; color: #ffffff; text-decoration: none; padding: 20px 50px; border-radius: 4px; border: 1px solid #530EEC; display: inline-block;"
|
||||||
Get started
|
>
|
||||||
{% endif %}
|
{% if args.user %} See invitation {% else %} Get started {% endif
|
||||||
|
%}
|
||||||
</a>
|
</a>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|
|
@ -11,12 +11,9 @@ def delete_user(identity):
|
||||||
print(identity)
|
print(identity)
|
||||||
user = None
|
user = None
|
||||||
if str.isdigit(identity):
|
if str.isdigit(identity):
|
||||||
user = User.query.filter(id=identity).first()
|
user = User.get_by_id(identity)
|
||||||
else:
|
else:
|
||||||
user = User.query.filter(
|
user = User.get_by_email(identity)
|
||||||
(User.account_address == identity) |
|
|
||||||
(User.email_address == identity)
|
|
||||||
).first()
|
|
||||||
|
|
||||||
if user:
|
if user:
|
||||||
db.session.delete(user)
|
db.session.delete(user)
|
||||||
|
|
|
@ -1,10 +1,34 @@
|
||||||
from sqlalchemy import func
|
from flask_security import UserMixin, RoleMixin
|
||||||
|
from flask_security.core import current_user
|
||||||
|
from flask_security.utils import hash_password, verify_and_update_password, login_user, logout_user
|
||||||
|
from sqlalchemy.ext.hybrid import hybrid_property
|
||||||
|
|
||||||
from grant.comment.models import Comment
|
from grant.comment.models import Comment
|
||||||
from grant.email.models import EmailVerification
|
from grant.email.models import EmailVerification
|
||||||
from grant.extensions import ma, db
|
from grant.email.send import send_email
|
||||||
|
from grant.extensions import ma, db, security
|
||||||
from grant.utils.misc import make_url
|
from grant.utils.misc import make_url
|
||||||
from grant.utils.social import get_social_info_from_url
|
from grant.utils.social import get_social_info_from_url
|
||||||
from grant.email.send import send_email
|
from grant.utils.upload import extract_avatar_filename, construct_avatar_url
|
||||||
|
|
||||||
|
|
||||||
|
def is_current_authed_user_id(user_id):
|
||||||
|
return current_user.is_authenticated and \
|
||||||
|
current_user.id == user_id
|
||||||
|
|
||||||
|
|
||||||
|
class RolesUsers(db.Model):
|
||||||
|
__tablename__ = 'roles_users'
|
||||||
|
id = db.Column(db.Integer(), primary_key=True)
|
||||||
|
user_id = db.Column('user_id', db.Integer(), db.ForeignKey('user.id'))
|
||||||
|
role_id = db.Column('role_id', db.Integer(), db.ForeignKey('role.id'))
|
||||||
|
|
||||||
|
|
||||||
|
class Role(db.Model, RoleMixin):
|
||||||
|
__tablename__ = 'role'
|
||||||
|
id = db.Column(db.Integer(), primary_key=True)
|
||||||
|
name = db.Column(db.String(80), unique=True)
|
||||||
|
description = db.Column(db.String(255))
|
||||||
|
|
||||||
|
|
||||||
class SocialMedia(db.Model):
|
class SocialMedia(db.Model):
|
||||||
|
@ -24,50 +48,66 @@ class Avatar(db.Model):
|
||||||
__tablename__ = "avatar"
|
__tablename__ = "avatar"
|
||||||
|
|
||||||
id = db.Column(db.Integer(), primary_key=True)
|
id = db.Column(db.Integer(), primary_key=True)
|
||||||
image_url = db.Column(db.String(255), unique=False, nullable=True)
|
_image_url = db.Column("image_url", db.String(255), unique=False, nullable=True)
|
||||||
user_id = db.Column(db.Integer, db.ForeignKey('user.id'))
|
user_id = db.Column(db.Integer, db.ForeignKey('user.id'))
|
||||||
user = db.relationship("User", back_populates="avatar")
|
user = db.relationship("User", back_populates="avatar")
|
||||||
|
|
||||||
|
@hybrid_property
|
||||||
|
def image_url(self):
|
||||||
|
return construct_avatar_url(self._image_url)
|
||||||
|
|
||||||
|
@image_url.setter
|
||||||
|
def image_url(self, image_url):
|
||||||
|
self._image_url = extract_avatar_filename(image_url)
|
||||||
|
|
||||||
def __init__(self, image_url, user_id):
|
def __init__(self, image_url, user_id):
|
||||||
self.image_url = image_url
|
self.image_url = image_url
|
||||||
self.user_id = user_id
|
self.user_id = user_id
|
||||||
|
|
||||||
|
|
||||||
class User(db.Model):
|
class User(db.Model, UserMixin):
|
||||||
__tablename__ = "user"
|
__tablename__ = "user"
|
||||||
|
|
||||||
id = db.Column(db.Integer(), primary_key=True)
|
id = db.Column(db.Integer(), primary_key=True)
|
||||||
email_address = db.Column(db.String(255), unique=True, nullable=True)
|
email_address = db.Column(db.String(255), unique=True, nullable=False)
|
||||||
account_address = db.Column(db.String(255), unique=True, nullable=True)
|
password = db.Column(db.String(255), unique=False, nullable=False)
|
||||||
display_name = db.Column(db.String(255), unique=False, nullable=True)
|
display_name = db.Column(db.String(255), unique=False, nullable=True)
|
||||||
title = db.Column(db.String(255), unique=False, nullable=True)
|
title = db.Column(db.String(255), unique=False, nullable=True)
|
||||||
|
active = db.Column(db.Boolean, default=True)
|
||||||
|
|
||||||
social_medias = db.relationship(SocialMedia, backref="user", lazy=True, cascade="all, delete-orphan")
|
social_medias = db.relationship(SocialMedia, backref="user", lazy=True, cascade="all, delete-orphan")
|
||||||
comments = db.relationship(Comment, backref="user", lazy=True)
|
comments = db.relationship(Comment, backref="user", lazy=True)
|
||||||
avatar = db.relationship(Avatar, uselist=False, back_populates="user", cascade="all, delete-orphan")
|
avatar = db.relationship(Avatar, uselist=False, back_populates="user", cascade="all, delete-orphan")
|
||||||
email_verification = db.relationship(EmailVerification, uselist=False, back_populates="user", lazy=True, cascade="all, delete-orphan")
|
email_verification = db.relationship(EmailVerification, uselist=False,
|
||||||
|
back_populates="user", lazy=True, cascade="all, delete-orphan")
|
||||||
|
roles = db.relationship('Role', secondary='roles_users',
|
||||||
|
backref=db.backref('users', lazy='dynamic'))
|
||||||
|
|
||||||
# TODO - add create and validate methods
|
# TODO - add create and validate methods
|
||||||
|
|
||||||
def __init__(self, email_address=None, account_address=None, display_name=None, title=None):
|
def __init__(
|
||||||
if not email_address and not account_address:
|
self,
|
||||||
raise ValueError("Either email_address or account_address is required to create a user")
|
email_address,
|
||||||
|
password,
|
||||||
|
active,
|
||||||
|
roles,
|
||||||
|
display_name=None,
|
||||||
|
title=None,
|
||||||
|
):
|
||||||
self.email_address = email_address
|
self.email_address = email_address
|
||||||
self.account_address = account_address
|
|
||||||
self.display_name = display_name
|
self.display_name = display_name
|
||||||
self.title = title
|
self.title = title
|
||||||
|
self.password = password
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def create(email_address=None, account_address=None, display_name=None, title=None, _send_email=True):
|
def create(email_address=None, password=None, display_name=None, title=None, _send_email=True):
|
||||||
user = User(
|
user = security.datastore.create_user(
|
||||||
account_address=account_address,
|
|
||||||
email_address=email_address,
|
email_address=email_address,
|
||||||
|
password=hash_password(password),
|
||||||
display_name=display_name,
|
display_name=display_name,
|
||||||
title=title
|
title=title
|
||||||
)
|
)
|
||||||
db.session.add(user)
|
security.datastore.commit()
|
||||||
db.session.flush()
|
|
||||||
|
|
||||||
# Setup & send email verification
|
# Setup & send email verification
|
||||||
ev = EmailVerification(user_id=user.id)
|
ev = EmailVerification(user_id=user.id)
|
||||||
|
@ -83,21 +123,33 @@ class User(db.Model):
|
||||||
return user
|
return user
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def get_by_identifier(email_address: str = None, account_address: str = None):
|
def get_by_id(user_id: int):
|
||||||
if not email_address and not account_address:
|
return security.datastore.get_user(user_id)
|
||||||
raise ValueError("Either email_address or account_address is required to get a user")
|
|
||||||
|
@staticmethod
|
||||||
|
def get_by_email(email_address: str):
|
||||||
|
return security.datastore.get_user(email_address)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def logout_current_user():
|
||||||
|
logout_user() # logs current user out
|
||||||
|
|
||||||
|
def check_password(self, password: str):
|
||||||
|
return verify_and_update_password(password, self)
|
||||||
|
|
||||||
|
def set_password(self, password: str):
|
||||||
|
self.password = hash_password(password)
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
def login(self):
|
||||||
|
login_user(self)
|
||||||
|
|
||||||
return User.query.filter(
|
|
||||||
(func.lower(User.account_address) == func.lower(account_address)) |
|
|
||||||
(func.lower(User.email_address) == func.lower(email_address))
|
|
||||||
).first()
|
|
||||||
|
|
||||||
class UserSchema(ma.Schema):
|
class UserSchema(ma.Schema):
|
||||||
class Meta:
|
class Meta:
|
||||||
model = User
|
model = User
|
||||||
# Fields to expose
|
# Fields to expose
|
||||||
fields = (
|
fields = (
|
||||||
"account_address",
|
|
||||||
"title",
|
"title",
|
||||||
"email_address",
|
"email_address",
|
||||||
"social_medias",
|
"social_medias",
|
||||||
|
@ -113,9 +165,11 @@ class UserSchema(ma.Schema):
|
||||||
def get_userid(self, obj):
|
def get_userid(self, obj):
|
||||||
return obj.id
|
return obj.id
|
||||||
|
|
||||||
|
|
||||||
user_schema = UserSchema()
|
user_schema = UserSchema()
|
||||||
users_schema = UserSchema(many=True)
|
users_schema = UserSchema(many=True)
|
||||||
|
|
||||||
|
|
||||||
class SocialMediaSchema(ma.Schema):
|
class SocialMediaSchema(ma.Schema):
|
||||||
class Meta:
|
class Meta:
|
||||||
model = SocialMedia
|
model = SocialMedia
|
||||||
|
|
|
@ -10,27 +10,14 @@ from grant.proposal.models import (
|
||||||
invites_with_proposal_schema,
|
invites_with_proposal_schema,
|
||||||
user_proposals_schema
|
user_proposals_schema
|
||||||
)
|
)
|
||||||
from grant.utils.auth import requires_sm, requires_same_user_auth, verify_signed_auth, BadSignatureException
|
from grant.utils.auth import requires_auth, requires_same_user_auth
|
||||||
from grant.utils.upload import save_avatar, send_upload, remove_avatar
|
from grant.utils.upload import remove_avatar, sign_avatar_upload, AvatarException
|
||||||
from grant.web3.proposal import read_user_proposal
|
|
||||||
from grant.settings import UPLOAD_URL
|
|
||||||
from .models import User, SocialMedia, Avatar, users_schema, user_schema, db
|
from .models import User, SocialMedia, Avatar, users_schema, user_schema, db
|
||||||
|
|
||||||
blueprint = Blueprint('user', __name__, url_prefix='/api/v1/users')
|
blueprint = Blueprint('user', __name__, url_prefix='/api/v1/users')
|
||||||
|
|
||||||
|
|
||||||
def populate_user_proposals_cfs(proposals):
|
|
||||||
for p in proposals:
|
|
||||||
proposal_contract = read_user_proposal(p['proposal_address'])
|
|
||||||
if proposal_contract:
|
|
||||||
p['target'] = proposal_contract['target']
|
|
||||||
p['funded'] = proposal_contract['funded']
|
|
||||||
else:
|
|
||||||
p['target'] = None
|
|
||||||
filtered_proposals = list(filter(lambda p: p['target'] is not None, proposals))
|
|
||||||
return filtered_proposals
|
|
||||||
|
|
||||||
|
|
||||||
@blueprint.route("/", methods=["GET"])
|
@blueprint.route("/", methods=["GET"])
|
||||||
@endpoint.api(
|
@endpoint.api(
|
||||||
parameter('proposalId', type=str, required=False)
|
parameter('proposalId', type=str, required=False)
|
||||||
|
@ -52,136 +39,121 @@ def get_users(proposal_id):
|
||||||
|
|
||||||
|
|
||||||
@blueprint.route("/me", methods=["GET"])
|
@blueprint.route("/me", methods=["GET"])
|
||||||
@requires_sm
|
@requires_auth
|
||||||
@endpoint.api()
|
@endpoint.api()
|
||||||
def get_me():
|
def get_me():
|
||||||
dumped_user = user_schema.dump(g.current_user)
|
dumped_user = user_schema.dump(g.current_user)
|
||||||
return dumped_user
|
return dumped_user
|
||||||
|
|
||||||
|
|
||||||
@blueprint.route("/<user_identity>", methods=["GET"])
|
@blueprint.route("/<user_id>", methods=["GET"])
|
||||||
@endpoint.api(
|
@endpoint.api(
|
||||||
parameter("withProposals", type=bool, required=False),
|
parameter("withProposals", type=bool, required=False),
|
||||||
parameter("withComments", type=bool, required=False),
|
parameter("withComments", type=bool, required=False),
|
||||||
parameter("withFunded", type=bool, required=False)
|
parameter("withFunded", type=bool, required=False)
|
||||||
)
|
)
|
||||||
def get_user(user_identity, with_proposals, with_comments, with_funded):
|
def get_user(user_id, with_proposals, with_comments, with_funded):
|
||||||
user = User.get_by_identifier(email_address=user_identity, account_address=user_identity)
|
user = User.get_by_id(user_id)
|
||||||
if user:
|
if user:
|
||||||
result = user_schema.dump(user)
|
result = user_schema.dump(user)
|
||||||
if with_proposals:
|
if with_proposals:
|
||||||
proposals = Proposal.get_by_user(user)
|
proposals = Proposal.get_by_user(user)
|
||||||
proposals_dump = user_proposals_schema.dump(proposals)
|
proposals_dump = user_proposals_schema.dump(proposals)
|
||||||
result["createdProposals"] = populate_user_proposals_cfs(proposals_dump)
|
result["createdProposals"] = proposals_dump
|
||||||
if with_funded:
|
if with_funded:
|
||||||
contributions = Proposal.get_by_user_contribution(user)
|
contributions = Proposal.get_by_user_contribution(user)
|
||||||
contributions_dump = user_proposals_schema.dump(contributions)
|
contributions_dump = user_proposals_schema.dump(contributions)
|
||||||
result["fundedProposals"] = populate_user_proposals_cfs(contributions_dump)
|
result["fundedProposals"] = contributions_dump
|
||||||
if with_comments:
|
if with_comments:
|
||||||
comments = Comment.get_by_user(user)
|
comments = Comment.get_by_user(user)
|
||||||
comments_dump = user_comments_schema.dump(comments)
|
comments_dump = user_comments_schema.dump(comments)
|
||||||
result["comments"] = comments_dump
|
result["comments"] = comments_dump
|
||||||
return result
|
return result
|
||||||
else:
|
else:
|
||||||
message = "User with account_address or user_identity matching {} not found".format(user_identity)
|
message = "User with id matching {} not found".format(user_id)
|
||||||
return {"message": message}, 404
|
return {"message": message}, 404
|
||||||
|
|
||||||
|
|
||||||
@blueprint.route("/", methods=["POST"])
|
@blueprint.route("/", methods=["POST"])
|
||||||
@endpoint.api(
|
@endpoint.api(
|
||||||
parameter('accountAddress', type=str, required=True),
|
|
||||||
parameter('emailAddress', type=str, required=True),
|
parameter('emailAddress', type=str, required=True),
|
||||||
|
parameter('password', type=str, required=True),
|
||||||
parameter('displayName', type=str, required=True),
|
parameter('displayName', type=str, required=True),
|
||||||
parameter('title', type=str, required=True),
|
parameter('title', type=str, required=True)
|
||||||
parameter('signedMessage', type=str, required=True),
|
|
||||||
parameter('rawTypedData', type=str, required=True)
|
|
||||||
)
|
)
|
||||||
def create_user(
|
def create_user(
|
||||||
account_address,
|
|
||||||
email_address,
|
email_address,
|
||||||
|
password,
|
||||||
display_name,
|
display_name,
|
||||||
title,
|
title
|
||||||
signed_message,
|
|
||||||
raw_typed_data
|
|
||||||
):
|
):
|
||||||
existing_user = User.get_by_identifier(email_address=email_address, account_address=account_address)
|
existing_user = User.get_by_email(email_address)
|
||||||
if existing_user:
|
if existing_user:
|
||||||
return {"message": "User with that address or email already exists"}, 409
|
return {"message": "User with that email already exists"}, 409
|
||||||
|
|
||||||
# Handle signature
|
|
||||||
try:
|
|
||||||
sig_address = verify_signed_auth(signed_message, raw_typed_data)
|
|
||||||
if sig_address.lower() != account_address.lower():
|
|
||||||
return {
|
|
||||||
"message": "Message signature address ({sig_address}) doesn't match account_address ({account_address})".format(
|
|
||||||
sig_address=sig_address,
|
|
||||||
account_address=account_address
|
|
||||||
)
|
|
||||||
}, 400
|
|
||||||
except BadSignatureException:
|
|
||||||
return {"message": "Invalid message signature"}, 400
|
|
||||||
|
|
||||||
# TODO: Handle avatar & social stuff too
|
|
||||||
user = User.create(
|
user = User.create(
|
||||||
account_address=account_address,
|
|
||||||
email_address=email_address,
|
email_address=email_address,
|
||||||
|
password=password,
|
||||||
display_name=display_name,
|
display_name=display_name,
|
||||||
title=title
|
title=title
|
||||||
)
|
)
|
||||||
|
user.login()
|
||||||
result = user_schema.dump(user)
|
result = user_schema.dump(user)
|
||||||
return result, 201
|
return result, 201
|
||||||
|
|
||||||
|
|
||||||
@blueprint.route("/auth", methods=["POST"])
|
@blueprint.route("/auth", methods=["POST"])
|
||||||
@endpoint.api(
|
@endpoint.api(
|
||||||
parameter('accountAddress', type=str, required=True),
|
parameter('email', type=str, required=True),
|
||||||
parameter('signedMessage', type=str, required=True),
|
parameter('password', type=str, required=True)
|
||||||
parameter('rawTypedData', type=str, required=True)
|
|
||||||
)
|
)
|
||||||
def auth_user(account_address, signed_message, raw_typed_data):
|
def auth_user(email, password):
|
||||||
existing_user = User.get_by_identifier(account_address=account_address)
|
existing_user = User.get_by_email(email)
|
||||||
if not existing_user:
|
if not existing_user:
|
||||||
return {"message": "No user exists with that address"}, 400
|
return {"message": "No user exists with that email"}, 400
|
||||||
|
if not existing_user.check_password(password):
|
||||||
try:
|
return {"message": "Invalid password"}, 403
|
||||||
sig_address = verify_signed_auth(signed_message, raw_typed_data)
|
existing_user.login()
|
||||||
if sig_address.lower() != account_address.lower():
|
|
||||||
return {
|
|
||||||
"message": "Message signature address ({sig_address}) doesn't match account_address ({account_address})".format(
|
|
||||||
sig_address=sig_address,
|
|
||||||
account_address=account_address
|
|
||||||
)
|
|
||||||
}, 400
|
|
||||||
except BadSignatureException:
|
|
||||||
return {"message": "Invalid message signature"}, 400
|
|
||||||
|
|
||||||
return user_schema.dump(existing_user)
|
return user_schema.dump(existing_user)
|
||||||
|
|
||||||
|
|
||||||
@blueprint.route("/avatar", methods=["POST"])
|
@blueprint.route("/password", methods=["PUT"])
|
||||||
@requires_sm
|
@requires_auth
|
||||||
|
@endpoint.api(
|
||||||
|
parameter('currentPassword', type=str, required=True),
|
||||||
|
parameter('password', type=str, required=True),
|
||||||
|
)
|
||||||
|
def update_user_password(current_password, password):
|
||||||
|
if not g.current_user.check_password(current_password):
|
||||||
|
return {"message": "Current password incorrect"}, 403
|
||||||
|
g.current_user.set_password(password)
|
||||||
|
return None, 200
|
||||||
|
|
||||||
|
|
||||||
|
@blueprint.route("/logout", methods=["POST"])
|
||||||
|
@requires_auth
|
||||||
@endpoint.api()
|
@endpoint.api()
|
||||||
def upload_avatar():
|
def logout_user():
|
||||||
|
User.logout_current_user()
|
||||||
|
return None, 200
|
||||||
|
|
||||||
|
|
||||||
|
@blueprint.route("/avatar", methods=["POST"])
|
||||||
|
@requires_auth
|
||||||
|
@endpoint.api(
|
||||||
|
parameter('mimetype', type=str, required=True)
|
||||||
|
)
|
||||||
|
def upload_avatar(mimetype):
|
||||||
user = g.current_user
|
user = g.current_user
|
||||||
if 'file' not in request.files:
|
|
||||||
return {"message": "No file in post"}, 400
|
|
||||||
file = request.files['file']
|
|
||||||
if file.filename == '':
|
|
||||||
return {"message": "No selected file"}, 400
|
|
||||||
try:
|
try:
|
||||||
filename = save_avatar(file, user.id)
|
signed_post = sign_avatar_upload(mimetype, user.id)
|
||||||
return {"url": "{0}/api/v1/users/avatar/{1}".format(UPLOAD_URL, filename)}
|
return signed_post
|
||||||
except Exception as e:
|
except AvatarException as e:
|
||||||
return {"message": str(e)}, 400
|
return {"message": str(e)}, 400
|
||||||
|
|
||||||
|
|
||||||
@blueprint.route("/avatar/<filename>", methods=["GET"])
|
|
||||||
def get_avatar(filename):
|
|
||||||
return send_upload(filename)
|
|
||||||
|
|
||||||
|
|
||||||
@blueprint.route("/avatar", methods=["DELETE"])
|
@blueprint.route("/avatar", methods=["DELETE"])
|
||||||
@requires_sm
|
@requires_auth
|
||||||
@endpoint.api(
|
@endpoint.api(
|
||||||
parameter('url', type=str, required=True)
|
parameter('url', type=str, required=True)
|
||||||
)
|
)
|
||||||
|
@ -190,8 +162,8 @@ def delete_avatar(url):
|
||||||
remove_avatar(url, user.id)
|
remove_avatar(url, user.id)
|
||||||
|
|
||||||
|
|
||||||
@blueprint.route("/<user_identity>", methods=["PUT"])
|
@blueprint.route("/<user_id>", methods=["PUT"])
|
||||||
@requires_sm
|
@requires_auth
|
||||||
@requires_same_user_auth
|
@requires_same_user_auth
|
||||||
@endpoint.api(
|
@endpoint.api(
|
||||||
parameter('displayName', type=str, required=True),
|
parameter('displayName', type=str, required=True),
|
||||||
|
@ -199,7 +171,7 @@ def delete_avatar(url):
|
||||||
parameter('socialMedias', type=list, required=True),
|
parameter('socialMedias', type=list, required=True),
|
||||||
parameter('avatar', type=str, required=True)
|
parameter('avatar', type=str, required=True)
|
||||||
)
|
)
|
||||||
def update_user(user_identity, display_name, title, social_medias, avatar):
|
def update_user(user_id, display_name, title, social_medias, avatar):
|
||||||
user = g.current_user
|
user = g.current_user
|
||||||
|
|
||||||
if display_name is not None:
|
if display_name is not None:
|
||||||
|
@ -224,7 +196,7 @@ def update_user(user_identity, display_name, title, social_medias, avatar):
|
||||||
db.session.add(new_avatar)
|
db.session.add(new_avatar)
|
||||||
|
|
||||||
old_avatar_url = db_avatar and db_avatar.image_url
|
old_avatar_url = db_avatar and db_avatar.image_url
|
||||||
if old_avatar_url and old_avatar_url != new_avatar.image_url:
|
if old_avatar_url and old_avatar_url != avatar:
|
||||||
remove_avatar(old_avatar_url, user.id)
|
remove_avatar(old_avatar_url, user.id)
|
||||||
|
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
|
@ -232,20 +204,20 @@ def update_user(user_identity, display_name, title, social_medias, avatar):
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
@blueprint.route("/<user_identity>/invites", methods=["GET"])
|
@blueprint.route("/<user_id>/invites", methods=["GET"])
|
||||||
@requires_same_user_auth
|
@requires_same_user_auth
|
||||||
@endpoint.api()
|
@endpoint.api()
|
||||||
def get_user_invites(user_identity):
|
def get_user_invites(user_id):
|
||||||
invites = ProposalTeamInvite.get_pending_for_user(g.current_user)
|
invites = ProposalTeamInvite.get_pending_for_user(g.current_user)
|
||||||
return invites_with_proposal_schema.dump(invites)
|
return invites_with_proposal_schema.dump(invites)
|
||||||
|
|
||||||
|
|
||||||
@blueprint.route("/<user_identity>/invites/<invite_id>/respond", methods=["PUT"])
|
@blueprint.route("/<user_id>/invites/<invite_id>/respond", methods=["PUT"])
|
||||||
@requires_same_user_auth
|
@requires_same_user_auth
|
||||||
@endpoint.api(
|
@endpoint.api(
|
||||||
parameter('response', type=bool, required=True)
|
parameter('response', type=bool, required=True)
|
||||||
)
|
)
|
||||||
def respond_to_invite(user_identity, invite_id, response):
|
def respond_to_invite(user_id, invite_id, response):
|
||||||
invite = ProposalTeamInvite.query.filter_by(id=invite_id).first()
|
invite = ProposalTeamInvite.query.filter_by(id=invite_id).first()
|
||||||
if not invite:
|
if not invite:
|
||||||
return {"message": "No invite found with id {}".format(invite_id)}, 404
|
return {"message": "No invite found with id {}".format(invite_id)}, 404
|
||||||
|
|
|
@ -3,107 +3,48 @@ import json
|
||||||
from functools import wraps
|
from functools import wraps
|
||||||
|
|
||||||
import requests
|
import requests
|
||||||
|
from flask_security.core import current_user
|
||||||
from flask import request, g, jsonify
|
from flask import request, g, jsonify
|
||||||
from itsdangerous import SignatureExpired, BadSignature
|
|
||||||
from itsdangerous import TimedJSONWebSignatureSerializer as Serializer
|
|
||||||
import sentry_sdk
|
import sentry_sdk
|
||||||
|
|
||||||
from grant.settings import SECRET_KEY, AUTH_URL
|
from grant.settings import SECRET_KEY
|
||||||
from ..proposal.models import Proposal
|
from ..proposal.models import Proposal
|
||||||
from ..user.models import User
|
from ..user.models import User
|
||||||
|
|
||||||
TWO_WEEKS = 1209600
|
|
||||||
|
|
||||||
|
def requires_auth(f):
|
||||||
def generate_token(user, expiration=TWO_WEEKS):
|
|
||||||
s = Serializer(SECRET_KEY, expires_in=expiration)
|
|
||||||
token = s.dumps({
|
|
||||||
'id': user.id,
|
|
||||||
'email': user.email,
|
|
||||||
}).decode('utf-8')
|
|
||||||
return token
|
|
||||||
|
|
||||||
|
|
||||||
def verify_token(token):
|
|
||||||
s = Serializer(SECRET_KEY)
|
|
||||||
try:
|
|
||||||
data = s.loads(token)
|
|
||||||
except (BadSignature, SignatureExpired):
|
|
||||||
return None
|
|
||||||
return data
|
|
||||||
|
|
||||||
|
|
||||||
# Custom exception for bad auth
|
|
||||||
class BadSignatureException(Exception):
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
def verify_signed_auth(signature, typed_data):
|
|
||||||
loaded_typed_data = ast.literal_eval(typed_data)
|
|
||||||
if loaded_typed_data['domain']['name'] != 'Grant.io':
|
|
||||||
raise BadSignatureException("Signature is not for Grant.io")
|
|
||||||
|
|
||||||
url = AUTH_URL + "/message/recover"
|
|
||||||
payload = json.dumps({"sig": signature, "data": loaded_typed_data})
|
|
||||||
headers = {'content-type': 'application/json'}
|
|
||||||
response = requests.request("POST", url, data=payload, headers=headers)
|
|
||||||
json_response = response.json()
|
|
||||||
recovered_address = json_response.get('recoveredAddress')
|
|
||||||
if not recovered_address:
|
|
||||||
raise BadSignatureException("Authorization signature is invalid")
|
|
||||||
|
|
||||||
return recovered_address
|
|
||||||
|
|
||||||
|
|
||||||
# Decorator that requires you to have EIP-712 message signature headers for auth
|
|
||||||
def requires_sm(f):
|
|
||||||
@wraps(f)
|
@wraps(f)
|
||||||
def decorated(*args, **kwargs):
|
def decorated(*args, **kwargs):
|
||||||
signature = request.headers.get('MsgSignature', None)
|
if not current_user.is_authenticated:
|
||||||
typed_data = request.headers.get('RawTypedData', None)
|
return jsonify(message="Authentication is required to access this resource"), 401
|
||||||
|
g.current_user = current_user
|
||||||
if typed_data and signature:
|
|
||||||
try:
|
|
||||||
auth_address = verify_signed_auth(signature, typed_data)
|
|
||||||
except BadSignatureException:
|
|
||||||
return jsonify(message="Invalid auth message signature"), 401
|
|
||||||
|
|
||||||
user = User.get_by_identifier(account_address=auth_address)
|
|
||||||
if not user:
|
|
||||||
return jsonify(message="No user exists with address: {}".format(auth_address)), 401
|
|
||||||
|
|
||||||
g.current_user = user
|
|
||||||
with sentry_sdk.configure_scope() as scope:
|
with sentry_sdk.configure_scope() as scope:
|
||||||
scope.user = {
|
scope.user = {
|
||||||
"id": user.id,
|
"id": current_user.id,
|
||||||
}
|
}
|
||||||
return f(*args, **kwargs)
|
return f(*args, **kwargs)
|
||||||
|
|
||||||
return jsonify(message="Authentication is required to access this resource"), 401
|
|
||||||
|
|
||||||
return decorated
|
return decorated
|
||||||
|
|
||||||
# Decorator that requires you to be the user you're interacting with
|
|
||||||
def requires_same_user_auth(f):
|
def requires_same_user_auth(f):
|
||||||
@wraps(f)
|
@wraps(f)
|
||||||
def decorated(*args, **kwargs):
|
def decorated(*args, **kwargs):
|
||||||
user_identity = kwargs["user_identity"]
|
user_id = kwargs["user_id"]
|
||||||
if not user_identity:
|
if not user_id:
|
||||||
return jsonify(message="Decorator requires_same_user_auth requires path variable <user_identity>"), 500
|
return jsonify(message="Decorator requires_same_user_auth requires path variable <user_id>"), 500
|
||||||
|
|
||||||
user = User.get_by_identifier(account_address=user_identity, email_address=user_identity)
|
user = User.get_by_id(user_id=user_id)
|
||||||
if not user:
|
if not user:
|
||||||
return jsonify(message="Could not find user with identity {}".format(user_identity)), 403
|
return jsonify(message="Could not find user with id {}".format(user_id)), 403
|
||||||
|
|
||||||
if user.id != g.current_user.id:
|
if user.id != g.current_user.id:
|
||||||
return jsonify(message="You are not authorized to modify this user"), 403
|
return jsonify(message="You are not authorized to modify this user"), 403
|
||||||
|
|
||||||
return f(*args, **kwargs)
|
return f(*args, **kwargs)
|
||||||
|
|
||||||
return requires_sm(decorated)
|
return requires_auth(decorated)
|
||||||
|
|
||||||
|
|
||||||
# Decorator that requires you to be a team member of a proposal to access
|
|
||||||
def requires_team_member_auth(f):
|
def requires_team_member_auth(f):
|
||||||
@wraps(f)
|
@wraps(f)
|
||||||
def decorated(*args, **kwargs):
|
def decorated(*args, **kwargs):
|
||||||
|
@ -121,4 +62,4 @@ def requires_team_member_auth(f):
|
||||||
g.current_proposal = proposal
|
g.current_proposal = proposal
|
||||||
return f(*args, **kwargs)
|
return f(*args, **kwargs)
|
||||||
|
|
||||||
return requires_sm(decorated)
|
return requires_auth(decorated)
|
||||||
|
|
|
@ -1,54 +1,63 @@
|
||||||
import os
|
|
||||||
import re
|
import re
|
||||||
from hashlib import md5
|
import uuid
|
||||||
from werkzeug.utils import secure_filename
|
import boto3
|
||||||
from flask import send_from_directory
|
from flask import current_app
|
||||||
from grant.settings import UPLOAD_DIRECTORY
|
|
||||||
|
|
||||||
IMAGE_MIME_TYPES = set(['image/png', 'image/jpg', 'image/gif'])
|
IMAGE_MIME_TYPES = set(['image/png', 'image/jpg', 'image/gif'])
|
||||||
AVATAR_MAX_SIZE = 2 * 1024 * 1024 # 2MB
|
AVATAR_MAX_SIZE = 2 * 1024 * 1024 # 2MB
|
||||||
|
|
||||||
|
|
||||||
class FileValidationException(Exception):
|
class AvatarException(Exception):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
def allowed_avatar_file(file):
|
def allowed_avatar_type(mimetype):
|
||||||
if file.mimetype not in IMAGE_MIME_TYPES:
|
if mimetype not in IMAGE_MIME_TYPES:
|
||||||
raise FileValidationException("Unacceptable file type: {0}".format(file.mimetype))
|
raise AvatarException("Unacceptable file type: {0}".format(mimetype))
|
||||||
file.seek(0, os.SEEK_END)
|
|
||||||
size = file.tell()
|
|
||||||
file.seek(0)
|
|
||||||
if size > AVATAR_MAX_SIZE:
|
|
||||||
raise FileValidationException(
|
|
||||||
"File size is too large ({0}KB), max size is {1}KB".format(size / 1024, AVATAR_MAX_SIZE / 1024)
|
|
||||||
)
|
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
def hash_file(file):
|
def extract_avatar_filename(url):
|
||||||
hasher = md5()
|
match = re.search(r'avatars/(\d+\.\w+\.\w+)$', url)
|
||||||
buf = file.read()
|
if match:
|
||||||
hasher.update(buf)
|
return match.group(1)
|
||||||
file.seek(0)
|
else:
|
||||||
return hasher.hexdigest()
|
raise AvatarException("Unable to extract avatar filename from %s" % url)
|
||||||
|
|
||||||
|
|
||||||
def save_avatar(file, user_id):
|
def construct_avatar_url(filename):
|
||||||
if file and allowed_avatar_file(file):
|
S3_BUCKET = current_app.config['S3_BUCKET']
|
||||||
ext = file.mimetype.replace('image/', '')
|
return "https://%s.s3.amazonaws.com/avatars/%s" % (S3_BUCKET, filename)
|
||||||
filename = "{0}.{1}.{2}".format(user_id, hash_file(file), ext)
|
|
||||||
file.save(os.path.join(UPLOAD_DIRECTORY, filename))
|
|
||||||
return filename
|
|
||||||
|
|
||||||
|
|
||||||
def remove_avatar(url, user_id):
|
def remove_avatar(url, user_id):
|
||||||
match = re.search(r'/api/v1/users/avatar/(\d+.\w+.\w+)', url)
|
S3_BUCKET = current_app.config['S3_BUCKET']
|
||||||
if match:
|
filename = extract_avatar_filename(url)
|
||||||
filename = match.group(1)
|
user_match = re.search(r'^(\d+)\.\w+\.\w+$', filename)
|
||||||
if filename.startswith(str(user_id) + '.'):
|
if user_match and user_match.group(1) == str(user_id):
|
||||||
os.remove(os.path.join(UPLOAD_DIRECTORY, filename))
|
s3 = boto3.resource('s3')
|
||||||
|
s3.Object(S3_BUCKET, 'avatars/' + filename).delete()
|
||||||
|
|
||||||
|
|
||||||
def send_upload(filename):
|
def sign_avatar_upload(mimetype, user_id):
|
||||||
return send_from_directory(UPLOAD_DIRECTORY, secure_filename(filename))
|
S3_BUCKET = current_app.config['S3_BUCKET']
|
||||||
|
if mimetype and allowed_avatar_type(mimetype):
|
||||||
|
ext = mimetype.replace('image/', '')
|
||||||
|
filename = "{0}.{1}.{2}".format(user_id, uuid.uuid4().hex, ext)
|
||||||
|
key = "avatars/" + filename
|
||||||
|
s3 = boto3.client('s3')
|
||||||
|
presigned_post = s3.generate_presigned_post(
|
||||||
|
Bucket=S3_BUCKET,
|
||||||
|
Key=key,
|
||||||
|
Fields={"acl": "public-read", "Content-Type": mimetype},
|
||||||
|
Conditions=[
|
||||||
|
{"acl": "public-read"},
|
||||||
|
{"Content-Type": mimetype},
|
||||||
|
["content-length-range", 0, AVATAR_MAX_SIZE]
|
||||||
|
],
|
||||||
|
ExpiresIn=3600
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"data": presigned_post,
|
||||||
|
"url": construct_avatar_url(filename)
|
||||||
|
}
|
||||||
|
|
|
@ -1 +0,0 @@
|
||||||
from . import dev_contracts
|
|
|
@ -1,19 +0,0 @@
|
||||||
import json
|
|
||||||
|
|
||||||
from flask import Blueprint, jsonify
|
|
||||||
|
|
||||||
blueprint = Blueprint('dev-contracts', __name__, url_prefix='/dev-contracts')
|
|
||||||
|
|
||||||
|
|
||||||
@blueprint.route("/CrowdFundFactory.json", methods=["GET"])
|
|
||||||
def factory():
|
|
||||||
with open("../contract/build/contracts/CrowdFundFactory.json", "r") as read_file:
|
|
||||||
crowd_fund_factory_json = json.load(read_file)
|
|
||||||
return jsonify(crowd_fund_factory_json)
|
|
||||||
|
|
||||||
|
|
||||||
@blueprint.route("/CrowdFund.json", methods=["GET"])
|
|
||||||
def crowd_find():
|
|
||||||
with open("../contract/build/contracts/CrowdFund.json", "r") as read_file:
|
|
||||||
crowd_fund_json = json.load(read_file)
|
|
||||||
return jsonify(crowd_fund_json)
|
|
|
@ -1,178 +0,0 @@
|
||||||
import time
|
|
||||||
|
|
||||||
import requests
|
|
||||||
from flask_web3 import current_web3
|
|
||||||
|
|
||||||
from grant.settings import CROWD_FUND_URL
|
|
||||||
from .util import batch_call, call_array, RpcError
|
|
||||||
|
|
||||||
crowd_fund_abi = None
|
|
||||||
|
|
||||||
|
|
||||||
def get_crowd_fund_abi():
|
|
||||||
global crowd_fund_abi
|
|
||||||
if crowd_fund_abi:
|
|
||||||
return crowd_fund_abi
|
|
||||||
|
|
||||||
crowd_fund_json = requests.get(CROWD_FUND_URL).json()
|
|
||||||
crowd_fund_abi = crowd_fund_json['abi']
|
|
||||||
return crowd_fund_abi
|
|
||||||
|
|
||||||
|
|
||||||
def read_proposal(address):
|
|
||||||
if not address:
|
|
||||||
return None
|
|
||||||
|
|
||||||
current_web3.eth.defaultAccount = '0x537680D921C000fC52Af9962ceEb4e359C50F424' if not current_web3.eth.accounts else \
|
|
||||||
current_web3.eth.accounts[0]
|
|
||||||
crowd_fund_abi = get_crowd_fund_abi()
|
|
||||||
contract = current_web3.eth.contract(address=address, abi=crowd_fund_abi)
|
|
||||||
|
|
||||||
crowd_fund = {}
|
|
||||||
methods = [
|
|
||||||
"immediateFirstMilestonePayout",
|
|
||||||
"raiseGoal",
|
|
||||||
"amountVotingForRefund",
|
|
||||||
"beneficiary",
|
|
||||||
"deadline",
|
|
||||||
"milestoneVotingPeriod",
|
|
||||||
"frozen",
|
|
||||||
"isRaiseGoalReached",
|
|
||||||
]
|
|
||||||
|
|
||||||
# batched
|
|
||||||
calls = list(map(lambda x: [x, None], methods))
|
|
||||||
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)
|
|
||||||
|
|
||||||
# arrays (sync)
|
|
||||||
crowd_fund['milestones'] = call_array(contract.functions.milestones)
|
|
||||||
crowd_fund['trustees'] = call_array(contract.functions.trustees)
|
|
||||||
contributor_list = call_array(contract.functions.contributorList)
|
|
||||||
|
|
||||||
# make milestones
|
|
||||||
def make_ms(enum_ms):
|
|
||||||
index = enum_ms[0]
|
|
||||||
ms = enum_ms[1]
|
|
||||||
is_immediate = index == 0 and crowd_fund['immediateFirstMilestonePayout']
|
|
||||||
deadline = ms[2] * 1000
|
|
||||||
amount_against = ms[1]
|
|
||||||
pct_against = round(amount_against * 100 / crowd_fund['raiseGoal'])
|
|
||||||
paid = ms[3]
|
|
||||||
state = 'WAITING'
|
|
||||||
if crowd_fund["isRaiseGoalReached"] and deadline > 0:
|
|
||||||
if paid:
|
|
||||||
state = 'PAID'
|
|
||||||
elif deadline > time.time() * 1000:
|
|
||||||
state = 'ACTIVE'
|
|
||||||
elif pct_against >= 50:
|
|
||||||
state = 'REJECTED'
|
|
||||||
else:
|
|
||||||
state = 'PAID'
|
|
||||||
return {
|
|
||||||
"index": index,
|
|
||||||
"state": state,
|
|
||||||
"amount": str(ms[0]),
|
|
||||||
"amountAgainstPayout": str(amount_against),
|
|
||||||
"percentAgainstPayout": pct_against,
|
|
||||||
"payoutRequestVoteDeadline": deadline,
|
|
||||||
"isPaid": paid,
|
|
||||||
"isImmediatePayout": is_immediate
|
|
||||||
}
|
|
||||||
|
|
||||||
crowd_fund['milestones'] = list(map(make_ms, enumerate(crowd_fund['milestones'])))
|
|
||||||
|
|
||||||
# contributor calls (batched)
|
|
||||||
contributors_calls = list(map(lambda c_addr: ['contributors', (c_addr,)], contributor_list))
|
|
||||||
contrib_votes_calls = []
|
|
||||||
for c_addr in contributor_list:
|
|
||||||
for msi in range(len(crowd_fund['milestones'])):
|
|
||||||
contrib_votes_calls.append(['getContributorMilestoneVote', (c_addr, msi)])
|
|
||||||
derived_calls = contributors_calls + contrib_votes_calls
|
|
||||||
derived_results = batch_call(current_web3, address, crowd_fund_abi, derived_calls, contract)
|
|
||||||
|
|
||||||
# make contributors
|
|
||||||
contributors = []
|
|
||||||
for contrib_address in contributor_list:
|
|
||||||
contrib_raw = derived_results['contributors' + contrib_address]
|
|
||||||
|
|
||||||
def get_no_vote(i):
|
|
||||||
return derived_results['getContributorMilestoneVote' + contrib_address + str(i)]
|
|
||||||
|
|
||||||
no_votes = list(map(get_no_vote, range(len(crowd_fund['milestones']))))
|
|
||||||
|
|
||||||
contrib = {
|
|
||||||
"address": contrib_address,
|
|
||||||
"contributionAmount": str(contrib_raw[0]),
|
|
||||||
"refundVote": contrib_raw[1],
|
|
||||||
"refunded": contrib_raw[2],
|
|
||||||
"milestoneNoVotes": no_votes,
|
|
||||||
}
|
|
||||||
contributors.append(contrib)
|
|
||||||
crowd_fund['contributors'] = contributors
|
|
||||||
|
|
||||||
# massage names and numbers
|
|
||||||
crowd_fund['target'] = crowd_fund.pop('raiseGoal')
|
|
||||||
crowd_fund['isFrozen'] = crowd_fund.pop('frozen')
|
|
||||||
crowd_fund['deadline'] = crowd_fund['deadline'] * 1000
|
|
||||||
crowd_fund['milestoneVotingPeriod'] = crowd_fund['milestoneVotingPeriod'] * 60 * 1000
|
|
||||||
if crowd_fund['isRaiseGoalReached']:
|
|
||||||
crowd_fund['funded'] = crowd_fund['target']
|
|
||||||
crowd_fund['percentFunded'] = 100
|
|
||||||
else:
|
|
||||||
crowd_fund['funded'] = crowd_fund['balance']
|
|
||||||
crowd_fund['percentFunded'] = round(crowd_fund['balance'] * 100 / crowd_fund['target'])
|
|
||||||
crowd_fund['percentVotingForRefund'] = round(crowd_fund['amountVotingForRefund'] * 100 / crowd_fund['target'])
|
|
||||||
|
|
||||||
bn_keys = ['amountVotingForRefund', 'balance', 'funded', 'target']
|
|
||||||
for k in bn_keys:
|
|
||||||
crowd_fund[k] = str(crowd_fund[k])
|
|
||||||
|
|
||||||
return crowd_fund
|
|
||||||
|
|
||||||
|
|
||||||
def read_user_proposal(address):
|
|
||||||
if not address:
|
|
||||||
return None
|
|
||||||
|
|
||||||
current_web3.eth.defaultAccount = '0x537680D921C000fC52Af9962ceEb4e359C50F424' if not current_web3.eth.accounts else \
|
|
||||||
current_web3.eth.accounts[0]
|
|
||||||
crowd_fund_abi = get_crowd_fund_abi()
|
|
||||||
contract = current_web3.eth.contract(address=address, abi=crowd_fund_abi)
|
|
||||||
|
|
||||||
crowd_fund = {}
|
|
||||||
methods = [
|
|
||||||
"raiseGoal",
|
|
||||||
]
|
|
||||||
|
|
||||||
# batched
|
|
||||||
calls = list(map(lambda x: [x, None], methods))
|
|
||||||
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)
|
|
||||||
crowd_fund['target'] = str(crowd_fund.pop('raiseGoal'))
|
|
||||||
crowd_fund['funded'] = str(crowd_fund.pop('balance'))
|
|
||||||
|
|
||||||
return crowd_fund
|
|
||||||
|
|
||||||
|
|
||||||
def validate_contribution_tx(tx_id, from_address, to_address, amount):
|
|
||||||
amount_wei = current_web3.toWei(amount, 'ether')
|
|
||||||
tx = current_web3.eth.getTransaction(tx_id)
|
|
||||||
if tx:
|
|
||||||
if from_address.lower() == tx.get("from").lower() and \
|
|
||||||
to_address == tx.get("to") and \
|
|
||||||
amount_wei == tx.get("value"):
|
|
||||||
return True
|
|
||||||
return False
|
|
|
@ -1,86 +0,0 @@
|
||||||
import requests
|
|
||||||
from web3.providers.base import JSONBaseProvider
|
|
||||||
from web3.utils.contracts import prepare_transaction, find_matching_fn_abi
|
|
||||||
from web3 import EthereumTesterProvider
|
|
||||||
from grant.settings import ETHEREUM_ENDPOINT_URI
|
|
||||||
from hexbytes import HexBytes
|
|
||||||
from eth_abi import decode_abi
|
|
||||||
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
|
|
||||||
index = 0
|
|
||||||
while no_error:
|
|
||||||
try:
|
|
||||||
results.append(fn(index).call())
|
|
||||||
index += 1
|
|
||||||
except Exception:
|
|
||||||
no_error = False
|
|
||||||
return results
|
|
||||||
|
|
||||||
|
|
||||||
def make_key(method, args):
|
|
||||||
return method + "".join(list(map(lambda z: str(z), args))) if args else method
|
|
||||||
|
|
||||||
|
|
||||||
def tester_batch(calls, contract):
|
|
||||||
# fallback to sync calls for eth-tester instead of implementing batching
|
|
||||||
results = {}
|
|
||||||
for call in calls:
|
|
||||||
method, args = call
|
|
||||||
args = args if args else ()
|
|
||||||
results[make_key(method, args)] = contract.functions[method](*args).call()
|
|
||||||
return results
|
|
||||||
|
|
||||||
|
|
||||||
def batch(node_address, params):
|
|
||||||
base_provider = JSONBaseProvider()
|
|
||||||
request_data = b'[' + b','.join(
|
|
||||||
[base_provider.encode_rpc_request('eth_call', p) for p in params]
|
|
||||||
) + b']'
|
|
||||||
r = requests.post(node_address, data=request_data, headers={'Content-Type': 'application/json'})
|
|
||||||
responses = base_provider.decode_rpc_response(r.content)
|
|
||||||
return responses
|
|
||||||
|
|
||||||
|
|
||||||
def batch_call(w3, address, abi, calls, contract):
|
|
||||||
# TODO: use web3py batching once its added
|
|
||||||
# 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 = []
|
|
||||||
for c in calls:
|
|
||||||
name, args = c
|
|
||||||
tx = {"from": w3.eth.defaultAccount, "to": address}
|
|
||||||
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'])
|
|
||||||
fn_id, args = r[0]
|
|
||||||
fn_abi = find_matching_fn_abi(abi, fn_id, args)
|
|
||||||
output_types = get_abi_output_types(fn_abi)
|
|
||||||
output_data = decode_abi(output_types, result)
|
|
||||||
normalized_data = map_abi_data(BASE_RETURN_NORMALIZERS, output_types, output_data)
|
|
||||||
key = make_key(fn_id, args)
|
|
||||||
if len(normalized_data) == 1:
|
|
||||||
results[key] = normalized_data[0]
|
|
||||||
else:
|
|
||||||
results[key] = normalized_data
|
|
||||||
|
|
||||||
return results
|
|
|
@ -0,0 +1,49 @@
|
||||||
|
"""empty message
|
||||||
|
|
||||||
|
Revision ID: 13d788130b39
|
||||||
|
Revises: a6087d62cb4b
|
||||||
|
Create Date: 2018-12-11 14:42:18.681421
|
||||||
|
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = '13d788130b39'
|
||||||
|
down_revision = 'a6087d62cb4b'
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade():
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.create_table('role',
|
||||||
|
sa.Column('id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('name', sa.String(length=80), nullable=True),
|
||||||
|
sa.Column('description', sa.String(length=255), nullable=True),
|
||||||
|
sa.PrimaryKeyConstraint('id'),
|
||||||
|
sa.UniqueConstraint('name')
|
||||||
|
)
|
||||||
|
op.create_table('roles_users',
|
||||||
|
sa.Column('id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('user_id', sa.Integer(), nullable=True),
|
||||||
|
sa.Column('role_id', sa.Integer(), nullable=True),
|
||||||
|
sa.ForeignKeyConstraint(['role_id'], ['role.id'], ),
|
||||||
|
sa.ForeignKeyConstraint(['user_id'], ['user.id'], ),
|
||||||
|
sa.PrimaryKeyConstraint('id')
|
||||||
|
)
|
||||||
|
op.add_column('user', sa.Column('active', sa.Boolean(), nullable=True))
|
||||||
|
op.add_column('user', sa.Column('password', sa.String(length=255), nullable=False))
|
||||||
|
op.drop_column('user', 'password_hash')
|
||||||
|
# ### end Alembic commands ###
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade():
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.add_column('user', sa.Column('password_hash', sa.VARCHAR(length=255), autoincrement=False, nullable=False))
|
||||||
|
op.drop_column('user', 'password')
|
||||||
|
op.drop_column('user', 'active')
|
||||||
|
op.drop_table('roles_users')
|
||||||
|
op.drop_table('role')
|
||||||
|
# ### end Alembic commands ###
|
|
@ -0,0 +1,38 @@
|
||||||
|
"""empty message
|
||||||
|
|
||||||
|
Revision ID: a6087d62cb4b
|
||||||
|
Revises: 3699cb98fc2a
|
||||||
|
Create Date: 2018-12-10 11:38:36.875419
|
||||||
|
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = 'a6087d62cb4b'
|
||||||
|
down_revision = '3699cb98fc2a'
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade():
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.add_column('user', sa.Column('password_hash', sa.String(length=255), nullable=False))
|
||||||
|
op.alter_column('user', 'email_address',
|
||||||
|
existing_type=sa.VARCHAR(length=255),
|
||||||
|
nullable=False)
|
||||||
|
op.drop_constraint('user_account_address_key', 'user', type_='unique')
|
||||||
|
op.drop_column('user', 'account_address')
|
||||||
|
# ### end Alembic commands ###
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade():
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.add_column('user', sa.Column('account_address', sa.VARCHAR(length=255), autoincrement=False, nullable=True))
|
||||||
|
op.create_unique_constraint('user_account_address_key', 'user', ['account_address'])
|
||||||
|
op.alter_column('user', 'email_address',
|
||||||
|
existing_type=sa.VARCHAR(length=255),
|
||||||
|
nullable=True)
|
||||||
|
op.drop_column('user', 'password_hash')
|
||||||
|
# ### end Alembic commands ###
|
|
@ -57,11 +57,14 @@ sendgrid==5.3.0
|
||||||
# input validation
|
# input validation
|
||||||
flask-yolo2API==0.2.6
|
flask-yolo2API==0.2.6
|
||||||
|
|
||||||
#web3
|
|
||||||
flask-web3==0.1.1
|
|
||||||
web3==4.8.1
|
|
||||||
|
|
||||||
#sentry
|
#sentry
|
||||||
sentry-sdk[flask]==0.5.5
|
sentry-sdk[flask]==0.5.5
|
||||||
|
|
||||||
|
#boto3 (AWS sdk)
|
||||||
|
boto3==1.9.52
|
||||||
|
|
||||||
|
# force SSL
|
||||||
Flask-SSLify==0.1.5
|
Flask-SSLify==0.1.5
|
||||||
|
|
||||||
|
# sessions
|
||||||
|
Flask-Security==3.0.0
|
||||||
|
|
|
@ -1,3 +1,4 @@
|
||||||
|
import json
|
||||||
from flask_testing import TestCase
|
from flask_testing import TestCase
|
||||||
|
|
||||||
from grant.app import create_app
|
from grant.app import create_app
|
||||||
|
@ -33,6 +34,7 @@ class BaseTestConfig(TestCase):
|
||||||
|
|
||||||
assert_status = assertStatus
|
assert_status = assertStatus
|
||||||
|
|
||||||
|
|
||||||
class BaseUserConfig(BaseTestConfig):
|
class BaseUserConfig(BaseTestConfig):
|
||||||
headers = {
|
headers = {
|
||||||
"MsgSignature": message["sig"],
|
"MsgSignature": message["sig"],
|
||||||
|
@ -42,8 +44,8 @@ class BaseUserConfig(BaseTestConfig):
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
super(BaseUserConfig, self).setUp()
|
super(BaseUserConfig, self).setUp()
|
||||||
self.user = User.create(
|
self.user = User.create(
|
||||||
account_address=test_user["accountAddress"],
|
|
||||||
email_address=test_user["emailAddress"],
|
email_address=test_user["emailAddress"],
|
||||||
|
password=test_user["password"],
|
||||||
display_name=test_user["displayName"],
|
display_name=test_user["displayName"],
|
||||||
title=test_user["title"],
|
title=test_user["title"],
|
||||||
)
|
)
|
||||||
|
@ -52,19 +54,32 @@ class BaseUserConfig(BaseTestConfig):
|
||||||
avatar = Avatar(image_url=test_user["avatar"]["link"], user_id=self.user.id)
|
avatar = Avatar(image_url=test_user["avatar"]["link"], user_id=self.user.id)
|
||||||
db.session.add(avatar)
|
db.session.add(avatar)
|
||||||
|
|
||||||
|
self.user_password = test_user["password"]
|
||||||
|
|
||||||
self.other_user = User.create(
|
self.other_user = User.create(
|
||||||
account_address=test_other_user["accountAddress"],
|
|
||||||
email_address=test_other_user["emailAddress"],
|
email_address=test_other_user["emailAddress"],
|
||||||
|
password=test_other_user["password"],
|
||||||
display_name=test_other_user["displayName"],
|
display_name=test_other_user["displayName"],
|
||||||
title=test_other_user["title"]
|
title=test_other_user["title"]
|
||||||
)
|
)
|
||||||
|
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
|
|
||||||
|
def login_default_user(self):
|
||||||
|
self.app.post(
|
||||||
|
"/api/v1/users/auth",
|
||||||
|
data=json.dumps({
|
||||||
|
"email": self.user.email_address,
|
||||||
|
"password": self.user_password
|
||||||
|
}),
|
||||||
|
content_type="application/json"
|
||||||
|
)
|
||||||
|
|
||||||
def remove_default_user(self):
|
def remove_default_user(self):
|
||||||
User.query.filter_by(id=self.user.id).delete()
|
User.query.filter_by(id=self.user.id).delete()
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
|
|
||||||
|
|
||||||
class BaseProposalCreatorConfig(BaseUserConfig):
|
class BaseProposalCreatorConfig(BaseUserConfig):
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
super().setUp()
|
super().setUp()
|
||||||
|
|
|
@ -7,15 +7,6 @@ from ..test_data import test_proposal, test_user
|
||||||
|
|
||||||
|
|
||||||
class TestAPI(BaseProposalCreatorConfig):
|
class TestAPI(BaseProposalCreatorConfig):
|
||||||
def test_create_invite_by_account_address(self):
|
|
||||||
invite_res = self.app.post(
|
|
||||||
"/api/v1/proposals/{}/invite".format(self.proposal.id),
|
|
||||||
data=json.dumps({ "address": "0x8B0B72F8bDE212991135668922fD5acE557DE6aB" }),
|
|
||||||
headers=self.headers,
|
|
||||||
content_type='application/json'
|
|
||||||
)
|
|
||||||
self.assertStatus(invite_res, 201)
|
|
||||||
|
|
||||||
def test_create_invite_by_email_address(self):
|
def test_create_invite_by_email_address(self):
|
||||||
invite_res = self.app.post(
|
invite_res = self.app.post(
|
||||||
"/api/v1/proposals/{}/invite".format(self.proposal.id),
|
"/api/v1/proposals/{}/invite".format(self.proposal.id),
|
||||||
|
|
|
@ -8,5 +8,8 @@ DEBUG_TB_ENABLED = False
|
||||||
CACHE_TYPE = 'simple' # Can be "memcached", "redis", etc.
|
CACHE_TYPE = 'simple' # Can be "memcached", "redis", etc.
|
||||||
SQLALCHEMY_TRACK_MODIFICATIONS = False
|
SQLALCHEMY_TRACK_MODIFICATIONS = False
|
||||||
WTF_CSRF_ENABLED = False # Allows form testing
|
WTF_CSRF_ENABLED = False # Allows form testing
|
||||||
ETHEREUM_PROVIDER = "test"
|
|
||||||
ETHEREUM_ENDPOINT_URI = ""
|
AWS_ACCESS_KEY_ID = "your-user-access-key"
|
||||||
|
AWS_SECRET_ACCESS_KEY = "your-user-secret-access-key"
|
||||||
|
AWS_DEFAULT_REGION = "us-west-2"
|
||||||
|
S3_BUCKET = "your-bucket-name"
|
||||||
|
|
|
@ -46,12 +46,12 @@ message = {
|
||||||
}
|
}
|
||||||
|
|
||||||
test_user = {
|
test_user = {
|
||||||
"accountAddress": '0x6bEeA1Cef016c23e292381b6FcaeC092960e41aa',
|
|
||||||
"displayName": 'Groot',
|
"displayName": 'Groot',
|
||||||
"emailAddress": 'iam@groot.com',
|
"emailAddress": 'iam@groot.com',
|
||||||
|
"password": "p4ssw0rd",
|
||||||
"title": 'I am Groot!',
|
"title": 'I am Groot!',
|
||||||
"avatar": {
|
"avatar": {
|
||||||
"link": 'https://avatars2.githubusercontent.com/u/1393943?s=400&v=4'
|
"link": 'https://your-bucket-name.s3.amazonaws.com/avatars/1.b0be8bf740ce419a80ea9e1f55974ce1.png'
|
||||||
},
|
},
|
||||||
"socialMedias": [
|
"socialMedias": [
|
||||||
{
|
{
|
||||||
|
@ -65,11 +65,10 @@ test_user = {
|
||||||
test_team = [test_user]
|
test_team = [test_user]
|
||||||
|
|
||||||
test_other_user = {
|
test_other_user = {
|
||||||
"accountAddress": "0xA65AD9c6006fe8948E75EC0861A1BAbaD8168DE0",
|
|
||||||
"displayName": 'Faketoshi',
|
"displayName": 'Faketoshi',
|
||||||
"emailAddress": 'fake@toshi.com',
|
"emailAddress": 'fake@toshi.com',
|
||||||
"title": 'The Real Fake Satoshi'
|
"title": 'The Real Fake Satoshi',
|
||||||
# TODO make signed messages for this for more tests
|
"password": 'n4k0m0t0'
|
||||||
}
|
}
|
||||||
|
|
||||||
milestones = [
|
milestones = [
|
||||||
|
@ -91,8 +90,8 @@ test_proposal = {
|
||||||
"milestones": milestones,
|
"milestones": milestones,
|
||||||
"category": random.choice(CATEGORIES),
|
"category": random.choice(CATEGORIES),
|
||||||
"target": "123.456",
|
"target": "123.456",
|
||||||
"payoutAddress": test_team[0]["accountAddress"],
|
"payoutAddress": "123",
|
||||||
"trustees": [test_team[0]["accountAddress"]],
|
"trustees": ["123"],
|
||||||
"deadlineDuration": 100,
|
"deadlineDuration": 100,
|
||||||
"voteDuration": 100
|
"voteDuration": 100
|
||||||
}
|
}
|
||||||
|
|
|
@ -8,22 +8,6 @@ from ..test_data import test_proposal, test_user
|
||||||
|
|
||||||
|
|
||||||
class TestAPI(BaseProposalCreatorConfig):
|
class TestAPI(BaseProposalCreatorConfig):
|
||||||
def test_get_user_invites_by_address(self):
|
|
||||||
invite = ProposalTeamInvite(
|
|
||||||
proposal_id=self.proposal.id,
|
|
||||||
address=self.user.account_address
|
|
||||||
)
|
|
||||||
db.session.add(invite)
|
|
||||||
db.session.commit()
|
|
||||||
|
|
||||||
invites_res = self.app.get(
|
|
||||||
"/api/v1/users/{}/invites".format(self.user.account_address),
|
|
||||||
headers=self.headers
|
|
||||||
)
|
|
||||||
self.assertStatus(invites_res, 200)
|
|
||||||
self.assertEqual(invites_res.json[0]['address'], self.user.account_address)
|
|
||||||
self.assertEqual(invites_res.json[0]['proposal']['proposalId'], self.proposal.id)
|
|
||||||
|
|
||||||
def test_get_user_invites_by_email(self):
|
def test_get_user_invites_by_email(self):
|
||||||
invite = ProposalTeamInvite(
|
invite = ProposalTeamInvite(
|
||||||
proposal_id=self.proposal.id,
|
proposal_id=self.proposal.id,
|
||||||
|
@ -51,13 +35,13 @@ class TestAPI(BaseProposalCreatorConfig):
|
||||||
proposal_id = self.other_proposal.id
|
proposal_id = self.other_proposal.id
|
||||||
invite = ProposalTeamInvite(
|
invite = ProposalTeamInvite(
|
||||||
proposal_id=proposal_id,
|
proposal_id=proposal_id,
|
||||||
address=self.user.account_address
|
address=self.user.email_address
|
||||||
)
|
)
|
||||||
db.session.add(invite)
|
db.session.add(invite)
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
|
|
||||||
invites_res = self.app.put(
|
invites_res = self.app.put(
|
||||||
"/api/v1/users/{}/invites/{}/respond".format(self.user.account_address, invite.id),
|
"/api/v1/users/{}/invites/{}/respond".format(self.user.id, invite.id),
|
||||||
headers=self.headers,
|
headers=self.headers,
|
||||||
data=json.dumps({"response": True}),
|
data=json.dumps({"response": True}),
|
||||||
content_type='application/json'
|
content_type='application/json'
|
||||||
|
@ -72,13 +56,13 @@ class TestAPI(BaseProposalCreatorConfig):
|
||||||
proposal_id = self.other_proposal.id
|
proposal_id = self.other_proposal.id
|
||||||
invite = ProposalTeamInvite(
|
invite = ProposalTeamInvite(
|
||||||
proposal_id=proposal_id,
|
proposal_id=proposal_id,
|
||||||
address=self.user.account_address
|
address=self.user.email_address
|
||||||
)
|
)
|
||||||
db.session.add(invite)
|
db.session.add(invite)
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
|
|
||||||
invites_res = self.app.put(
|
invites_res = self.app.put(
|
||||||
"/api/v1/users/{}/invites/{}/respond".format(self.user.account_address, invite.id),
|
"/api/v1/users/{}/invites/{}/respond".format(self.user.id, invite.id),
|
||||||
headers=self.headers,
|
headers=self.headers,
|
||||||
data=json.dumps({"response": False}),
|
data=json.dumps({"response": False}),
|
||||||
content_type='application/json'
|
content_type='application/json'
|
||||||
|
@ -93,13 +77,13 @@ class TestAPI(BaseProposalCreatorConfig):
|
||||||
proposal_id = self.other_proposal.id
|
proposal_id = self.other_proposal.id
|
||||||
invite = ProposalTeamInvite(
|
invite = ProposalTeamInvite(
|
||||||
proposal_id=proposal_id,
|
proposal_id=proposal_id,
|
||||||
address=self.user.account_address
|
address=self.user.email_address
|
||||||
)
|
)
|
||||||
db.session.add(invite)
|
db.session.add(invite)
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
|
|
||||||
invites_res = self.app.put(
|
invites_res = self.app.put(
|
||||||
"/api/v1/users/{}/invites/{}/respond".format(self.user.account_address, invite.id),
|
"/api/v1/users/{}/invites/{}/respond".format(self.user.id, invite.id),
|
||||||
data=json.dumps({"response": True}),
|
data=json.dumps({"response": True}),
|
||||||
content_type='application/json'
|
content_type='application/json'
|
||||||
)
|
)
|
||||||
|
@ -107,7 +91,7 @@ class TestAPI(BaseProposalCreatorConfig):
|
||||||
|
|
||||||
def test_invalid_invite_put_user_invite_response(self):
|
def test_invalid_invite_put_user_invite_response(self):
|
||||||
invites_res = self.app.put(
|
invites_res = self.app.put(
|
||||||
"/api/v1/users/{}/invites/1234567890/respond".format(self.user.account_address),
|
"/api/v1/users/{}/invites/1234567890/respond".format(self.user.id),
|
||||||
headers=self.headers,
|
headers=self.headers,
|
||||||
data=json.dumps({"response": True}),
|
data=json.dumps({"response": True}),
|
||||||
content_type='application/json'
|
content_type='application/json'
|
||||||
|
|
|
@ -1,71 +0,0 @@
|
||||||
import json
|
|
||||||
|
|
||||||
from ..config import BaseTestConfig
|
|
||||||
from ..test_data import test_user, message
|
|
||||||
|
|
||||||
|
|
||||||
class TestRequiredSignedMessageDecorator(BaseTestConfig):
|
|
||||||
def test_required_sm_aborts_without_data_and_sig_headers(self):
|
|
||||||
self.app.post(
|
|
||||||
"/api/v1/users/",
|
|
||||||
data=json.dumps(test_user),
|
|
||||||
content_type='application/json'
|
|
||||||
)
|
|
||||||
|
|
||||||
response = self.app.get(
|
|
||||||
"/api/v1/users/me",
|
|
||||||
headers={
|
|
||||||
"MsgSignature": message["sig"],
|
|
||||||
# "RawTypedData: message["data"]
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
self.assert401(response)
|
|
||||||
|
|
||||||
response = self.app.get(
|
|
||||||
"/api/v1/users/me",
|
|
||||||
headers={
|
|
||||||
# "MsgSignature": message["sig"],
|
|
||||||
"RawTypedData": message["data"]
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
self.assert401(response)
|
|
||||||
|
|
||||||
def test_required_sm_aborts_without_existing_user(self):
|
|
||||||
# We don't create the user here to test a failure case
|
|
||||||
# self.app.post(
|
|
||||||
# "/api/v1/users/",
|
|
||||||
# data=json.dumps(user),
|
|
||||||
# content_type='application/json'
|
|
||||||
# )
|
|
||||||
|
|
||||||
response = self.app.get(
|
|
||||||
"/api/v1/users/me",
|
|
||||||
headers={
|
|
||||||
"MsgSignature": message["sig"],
|
|
||||||
"RawTypedData": message["data"]
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
self.assert401(response)
|
|
||||||
|
|
||||||
def test_required_sm_decorator_authorizes_when_recovered_address_matches_existing_user(self):
|
|
||||||
self.app.post(
|
|
||||||
"/api/v1/users/",
|
|
||||||
data=json.dumps(test_user),
|
|
||||||
content_type='application/json'
|
|
||||||
)
|
|
||||||
|
|
||||||
response = self.app.get(
|
|
||||||
"/api/v1/users/me",
|
|
||||||
headers={
|
|
||||||
"MsgSignature": message["sig"],
|
|
||||||
"RawTypedData": message["data"]
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
response_json = response.json
|
|
||||||
|
|
||||||
self.assert200(response)
|
|
||||||
self.assertEqual(response_json["displayName"], test_user["displayName"])
|
|
|
@ -26,10 +26,10 @@ class TestAPI(BaseUserConfig):
|
||||||
self.assertStatus(response, 201)
|
self.assertStatus(response, 201)
|
||||||
|
|
||||||
# User
|
# User
|
||||||
user_db = User.get_by_identifier(account_address=test_user["accountAddress"])
|
user_db = User.get_by_email(test_user["emailAddress"])
|
||||||
self.assertEqual(user_db.display_name, test_user["displayName"])
|
self.assertEqual(user_db.display_name, test_user["displayName"])
|
||||||
self.assertEqual(user_db.title, test_user["title"])
|
self.assertEqual(user_db.title, test_user["title"])
|
||||||
self.assertEqual(user_db.account_address, test_user["accountAddress"])
|
self.assertEqual(user_db.email_address, test_user["emailAddress"])
|
||||||
|
|
||||||
def test_get_all_users(self):
|
def test_get_all_users(self):
|
||||||
users_get_resp = self.app.get(
|
users_get_resp = self.app.get(
|
||||||
|
@ -39,9 +39,9 @@ class TestAPI(BaseUserConfig):
|
||||||
users_json = users_get_resp.json
|
users_json = users_get_resp.json
|
||||||
self.assertEqual(users_json[0]["displayName"], self.user.display_name)
|
self.assertEqual(users_json[0]["displayName"], self.user.display_name)
|
||||||
|
|
||||||
def test_get_single_user_by_email(self):
|
def test_get_single_user_by_id(self):
|
||||||
users_get_resp = self.app.get(
|
users_get_resp = self.app.get(
|
||||||
"/api/v1/users/{}".format(self.user.email_address)
|
"/api/v1/users/{}".format(self.user.id)
|
||||||
)
|
)
|
||||||
|
|
||||||
users_json = users_get_resp.json
|
users_json = users_get_resp.json
|
||||||
|
@ -51,17 +51,78 @@ class TestAPI(BaseUserConfig):
|
||||||
self.assertEqual(users_json["socialMedias"][0]["url"], self.user.social_medias[0].social_media_link)
|
self.assertEqual(users_json["socialMedias"][0]["url"], self.user.social_medias[0].social_media_link)
|
||||||
self.assertEqual(users_json["displayName"], self.user.display_name)
|
self.assertEqual(users_json["displayName"], self.user.display_name)
|
||||||
|
|
||||||
def test_get_single_user_by_account_address(self):
|
def test_user_auth_success(self):
|
||||||
users_get_resp = self.app.get(
|
user_auth_resp = self.app.post(
|
||||||
"/api/v1/users/{}".format(self.user.account_address)
|
"/api/v1/users/auth",
|
||||||
|
data=json.dumps({
|
||||||
|
"email": self.user.email_address,
|
||||||
|
"password": self.user_password
|
||||||
|
}),
|
||||||
|
content_type="application/json"
|
||||||
)
|
)
|
||||||
|
print(user_auth_resp.headers)
|
||||||
|
self.assertEqual(user_auth_resp.json['emailAddress'], self.user.email_address)
|
||||||
|
self.assertEqual(user_auth_resp.json['displayName'], self.user.display_name)
|
||||||
|
|
||||||
users_json = users_get_resp.json
|
def test_user_auth_required(self):
|
||||||
self.assertEqual(users_json["avatar"]["imageUrl"], self.user.avatar.image_url)
|
login_resp = self.app.post(
|
||||||
self.assertEqual(users_json["socialMedias"][0]["service"], 'GITHUB')
|
"/api/v1/users/auth",
|
||||||
self.assertEqual(users_json["socialMedias"][0]["username"], 'groot')
|
data=json.dumps({
|
||||||
self.assertEqual(users_json["socialMedias"][0]["url"], self.user.social_medias[0].social_media_link)
|
"email": self.user.email_address,
|
||||||
self.assertEqual(users_json["displayName"], self.user.display_name)
|
"password": self.user_password
|
||||||
|
}),
|
||||||
|
content_type="application/json"
|
||||||
|
)
|
||||||
|
print(login_resp.headers)
|
||||||
|
# should have session cookie now
|
||||||
|
me_resp = self.app.get(
|
||||||
|
"/api/v1/users/me",
|
||||||
|
data=json.dumps({
|
||||||
|
"email": self.user.email_address,
|
||||||
|
"password": self.user_password
|
||||||
|
}),
|
||||||
|
content_type="application/json"
|
||||||
|
)
|
||||||
|
print(me_resp.headers)
|
||||||
|
self.assert200(me_resp)
|
||||||
|
|
||||||
|
def test_user_auth_required_fail(self):
|
||||||
|
me_resp = self.app.get(
|
||||||
|
"/api/v1/users/me",
|
||||||
|
data=json.dumps({
|
||||||
|
"email": self.user.email_address,
|
||||||
|
"password": self.user_password
|
||||||
|
}),
|
||||||
|
|
||||||
|
content_type="application/json"
|
||||||
|
)
|
||||||
|
print(me_resp.json)
|
||||||
|
print(me_resp.headers)
|
||||||
|
self.assert401(me_resp)
|
||||||
|
|
||||||
|
def test_user_auth_bad_password(self):
|
||||||
|
user_auth_resp = self.app.post(
|
||||||
|
"/api/v1/users/auth",
|
||||||
|
data=json.dumps({
|
||||||
|
"email": self.user.email_address,
|
||||||
|
"password": "badpassword"
|
||||||
|
}),
|
||||||
|
content_type="application/json"
|
||||||
|
)
|
||||||
|
self.assert403(user_auth_resp)
|
||||||
|
self.assertTrue(user_auth_resp.json['message'] is not None)
|
||||||
|
|
||||||
|
def test_user_auth_bad_email(self):
|
||||||
|
user_auth_resp = self.app.post(
|
||||||
|
"/api/v1/users/auth",
|
||||||
|
data=json.dumps({
|
||||||
|
"email": "bademail@bad.com",
|
||||||
|
"password": "somepassword"
|
||||||
|
}),
|
||||||
|
content_type="application/json"
|
||||||
|
)
|
||||||
|
self.assert400(user_auth_resp)
|
||||||
|
self.assertTrue(user_auth_resp.json['message'] is not None)
|
||||||
|
|
||||||
def test_create_user_duplicate_400(self):
|
def test_create_user_duplicate_400(self):
|
||||||
# self.user is identical to test_user, should throw
|
# self.user is identical to test_user, should throw
|
||||||
|
@ -73,14 +134,16 @@ class TestAPI(BaseUserConfig):
|
||||||
|
|
||||||
self.assertEqual(response.status_code, 409)
|
self.assertEqual(response.status_code, 409)
|
||||||
|
|
||||||
def test_update_user_remove_social_and_avatar(self):
|
@patch('grant.user.views.remove_avatar')
|
||||||
|
def test_update_user_remove_social_and_avatar(self, mock_remove_avatar):
|
||||||
|
self.login_default_user()
|
||||||
updated_user = animalify(copy.deepcopy(user_schema.dump(self.user)))
|
updated_user = animalify(copy.deepcopy(user_schema.dump(self.user)))
|
||||||
updated_user["displayName"] = 'new display name'
|
updated_user["displayName"] = 'new display name'
|
||||||
updated_user["avatar"] = {}
|
updated_user["avatar"] = {}
|
||||||
updated_user["socialMedias"] = []
|
updated_user["socialMedias"] = []
|
||||||
|
|
||||||
user_update_resp = self.app.put(
|
user_update_resp = self.app.put(
|
||||||
"/api/v1/users/{}".format(self.user.account_address),
|
"/api/v1/users/{}".format(self.user.id),
|
||||||
data=json.dumps(updated_user),
|
data=json.dumps(updated_user),
|
||||||
headers=self.headers,
|
headers=self.headers,
|
||||||
content_type='application/json'
|
content_type='application/json'
|
||||||
|
@ -92,13 +155,15 @@ class TestAPI(BaseUserConfig):
|
||||||
self.assertFalse(len(user_json["socialMedias"]))
|
self.assertFalse(len(user_json["socialMedias"]))
|
||||||
self.assertEqual(user_json["displayName"], updated_user["displayName"])
|
self.assertEqual(user_json["displayName"], updated_user["displayName"])
|
||||||
self.assertEqual(user_json["title"], updated_user["title"])
|
self.assertEqual(user_json["title"], updated_user["title"])
|
||||||
|
mock_remove_avatar.assert_called_with(test_user["avatar"]["link"], 1)
|
||||||
|
|
||||||
def test_update_user_400_when_required_param_not_passed(self):
|
def test_update_user_400_when_required_param_not_passed(self):
|
||||||
|
self.login_default_user()
|
||||||
updated_user = animalify(copy.deepcopy(user_schema.dump(self.user)))
|
updated_user = animalify(copy.deepcopy(user_schema.dump(self.user)))
|
||||||
updated_user["displayName"] = 'new display name'
|
updated_user["displayName"] = 'new display name'
|
||||||
del updated_user["avatar"]
|
del updated_user["avatar"]
|
||||||
user_update_resp = self.app.put(
|
user_update_resp = self.app.put(
|
||||||
"/api/v1/users/{}".format(self.user.account_address),
|
"/api/v1/users/{}".format(self.user.id),
|
||||||
data=json.dumps(updated_user),
|
data=json.dumps(updated_user),
|
||||||
headers=self.headers,
|
headers=self.headers,
|
||||||
content_type='application/json'
|
content_type='application/json'
|
||||||
|
|
|
@ -1,127 +0,0 @@
|
||||||
import time
|
|
||||||
|
|
||||||
import eth_tester.backends.pyevm.main as py_evm_main
|
|
||||||
import requests
|
|
||||||
from flask_web3 import current_web3
|
|
||||||
|
|
||||||
from grant.extensions import web3
|
|
||||||
from grant.settings import CROWD_FUND_URL, CROWD_FUND_FACTORY_URL
|
|
||||||
from grant.web3.proposal import read_proposal
|
|
||||||
from ..config import BaseTestConfig
|
|
||||||
|
|
||||||
# increase gas limit on eth-tester
|
|
||||||
# https://github.com/ethereum/web3.py/issues/1013
|
|
||||||
# https://gitter.im/ethereum/py-evm?at=5b7eb68c4be56c5918854337
|
|
||||||
py_evm_main.GENESIS_GAS_LIMIT = 10000000
|
|
||||||
|
|
||||||
|
|
||||||
class TestWeb3ProposalRead(BaseTestConfig):
|
|
||||||
def create_app(self):
|
|
||||||
self.real_app = BaseTestConfig.create_app(self)
|
|
||||||
return self.real_app
|
|
||||||
|
|
||||||
def setUp(self):
|
|
||||||
BaseTestConfig.setUp(self)
|
|
||||||
# the following will properly configure web3 with test config
|
|
||||||
web3.init_app(self.real_app)
|
|
||||||
crowd_fund_factory_json = requests.get(CROWD_FUND_FACTORY_URL).json()
|
|
||||||
self.crowd_fund_json = requests.get(CROWD_FUND_URL).json()
|
|
||||||
current_web3.eth.defaultAccount = current_web3.eth.accounts[0]
|
|
||||||
CrowdFundFactory = current_web3.eth.contract(
|
|
||||||
abi=crowd_fund_factory_json['abi'], bytecode=crowd_fund_factory_json['bytecode'])
|
|
||||||
tx_hash = CrowdFundFactory.constructor().transact()
|
|
||||||
tx_receipt = current_web3.eth.waitForTransactionReceipt(tx_hash)
|
|
||||||
self.crowd_fund_factory = current_web3.eth.contract(
|
|
||||||
address=tx_receipt.contractAddress,
|
|
||||||
abi=crowd_fund_factory_json['abi']
|
|
||||||
)
|
|
||||||
|
|
||||||
def get_mock_proposal_read(self, contributors=[]):
|
|
||||||
mock_proposal_read = {
|
|
||||||
"immediateFirstMilestonePayout": True,
|
|
||||||
"amountVotingForRefund": "0",
|
|
||||||
"beneficiary": current_web3.eth.accounts[0],
|
|
||||||
# "deadline": 1541706179000,
|
|
||||||
"milestoneVotingPeriod": 3600000,
|
|
||||||
"isRaiseGoalReached": False,
|
|
||||||
"balance": "0",
|
|
||||||
"milestones": [
|
|
||||||
{
|
|
||||||
"index": 0,
|
|
||||||
"state": "WAITING",
|
|
||||||
"amount": "5000000000000000000",
|
|
||||||
"amountAgainstPayout": "0",
|
|
||||||
"percentAgainstPayout": 0,
|
|
||||||
"payoutRequestVoteDeadline": 0,
|
|
||||||
"isPaid": False,
|
|
||||||
"isImmediatePayout": True
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"trustees": [current_web3.eth.accounts[0]],
|
|
||||||
"contributors": [],
|
|
||||||
"target": "5000000000000000000",
|
|
||||||
"isFrozen": False,
|
|
||||||
"funded": "0",
|
|
||||||
"percentFunded": 0,
|
|
||||||
"percentVotingForRefund": 0
|
|
||||||
}
|
|
||||||
for c in contributors:
|
|
||||||
mock_proposal_read['contributors'].append({
|
|
||||||
"address": current_web3.eth.accounts[c[0]],
|
|
||||||
"contributionAmount": str(c[1] * 1000000000000000000),
|
|
||||||
"refundVote": False,
|
|
||||||
"refunded": False,
|
|
||||||
"milestoneNoVotes": [False]
|
|
||||||
})
|
|
||||||
return mock_proposal_read
|
|
||||||
|
|
||||||
def create_crowd_fund(self):
|
|
||||||
tx_hash = self.crowd_fund_factory.functions.createCrowdFund(
|
|
||||||
5000000000000000000, # ethAmount
|
|
||||||
current_web3.eth.accounts[0], # payout
|
|
||||||
[current_web3.eth.accounts[0]], # trustees
|
|
||||||
[5000000000000000000], # milestone amounts
|
|
||||||
60, # duration (minutes)
|
|
||||||
60, # voting period (minutes)
|
|
||||||
True # immediate first milestone payout
|
|
||||||
).transact()
|
|
||||||
tx_receipt = current_web3.eth.waitForTransactionReceipt(tx_hash)
|
|
||||||
tx_events = self.crowd_fund_factory.events.ContractCreated().processReceipt(tx_receipt)
|
|
||||||
contract_address = tx_events[0]['args']['newAddress']
|
|
||||||
return contract_address
|
|
||||||
|
|
||||||
def fund_crowd_fund(self, address):
|
|
||||||
contract = current_web3.eth.contract(address=address, abi=self.crowd_fund_json['abi'])
|
|
||||||
accts = current_web3.eth.accounts
|
|
||||||
for c in [[5, 1], [6, 1], [7, 3]]:
|
|
||||||
tx_hash = contract.functions.contribute().transact({
|
|
||||||
"from": accts[c[0]],
|
|
||||||
"value": c[1] * 1000000000000000000
|
|
||||||
})
|
|
||||||
current_web3.eth.waitForTransactionReceipt(tx_hash)
|
|
||||||
|
|
||||||
def test_proposal_read_new(self):
|
|
||||||
contract_address = self.create_crowd_fund()
|
|
||||||
proposal_read = read_proposal(contract_address)
|
|
||||||
deadline = proposal_read.pop('deadline')
|
|
||||||
deadline_diff = deadline - time.time() * 1000
|
|
||||||
self.assertGreater(60000, deadline_diff)
|
|
||||||
self.assertGreater(deadline_diff, 50000)
|
|
||||||
self.maxDiff = None
|
|
||||||
self.assertEqual(proposal_read, self.get_mock_proposal_read())
|
|
||||||
|
|
||||||
def test_proposal_funded(self):
|
|
||||||
contract_address = self.create_crowd_fund()
|
|
||||||
self.fund_crowd_fund(contract_address)
|
|
||||||
proposal_read = read_proposal(contract_address)
|
|
||||||
expected = self.get_mock_proposal_read([[5, 1], [6, 1], [7, 3]])
|
|
||||||
expected['funded'] = expected['target']
|
|
||||||
expected['balance'] = expected['target']
|
|
||||||
expected['isRaiseGoalReached'] = True
|
|
||||||
expected['percentFunded'] = 100
|
|
||||||
deadline = proposal_read.pop('deadline')
|
|
||||||
deadline_diff = deadline - time.time() * 1000
|
|
||||||
self.assertGreater(60000, deadline_diff)
|
|
||||||
self.assertGreater(deadline_diff, 50000)
|
|
||||||
self.maxDiff = None
|
|
||||||
self.assertEqual(proposal_read, expected)
|
|
|
@ -1,2 +0,0 @@
|
||||||
INFURA_KEY=key
|
|
||||||
MNEMONIC=mnemonic
|
|
|
@ -1,5 +0,0 @@
|
||||||
node_modules
|
|
||||||
.idea/
|
|
||||||
yarn-error.log
|
|
||||||
.env
|
|
||||||
build
|
|
|
@ -1 +0,0 @@
|
||||||
8.13.0
|
|
|
@ -1,42 +0,0 @@
|
||||||
# Grant.io Smart Contracts
|
|
||||||
|
|
||||||
This is a collection of the smart contracts and associated testing and build
|
|
||||||
process used for the [Grant.io](http://grant.io) dApp.
|
|
||||||
|
|
||||||
## API
|
|
||||||
|
|
||||||
This repo provides Truffle build artifacts, ABI json, and type definitions
|
|
||||||
for all contracts. You can import them like so:
|
|
||||||
|
|
||||||
```ts
|
|
||||||
import {
|
|
||||||
EscrowContract, // Truffle build artifacts
|
|
||||||
EscrowABI, // Contract ABI
|
|
||||||
Escrow, // Contract type defintion
|
|
||||||
} from 'grant-contracts';
|
|
||||||
```
|
|
||||||
|
|
||||||
## Commands
|
|
||||||
|
|
||||||
To run any commands, you must install node dependencies, and have `truffle`
|
|
||||||
installed globally.
|
|
||||||
|
|
||||||
### Testing
|
|
||||||
|
|
||||||
```bash
|
|
||||||
yarn run test
|
|
||||||
```
|
|
||||||
|
|
||||||
Runs the truffle test suite
|
|
||||||
|
|
||||||
### Building
|
|
||||||
|
|
||||||
```bash
|
|
||||||
yarn run build
|
|
||||||
```
|
|
||||||
|
|
||||||
Builds the contract artifact JSON files, ABI JSON files, and type definitions
|
|
||||||
|
|
||||||
### Publishing
|
|
||||||
|
|
||||||
TBD
|
|
|
@ -1,31 +0,0 @@
|
||||||
const fs = require('fs');
|
|
||||||
const path = require('path');
|
|
||||||
|
|
||||||
const contractsPath = path.resolve(__dirname, '../build/contracts');
|
|
||||||
const abiPath = path.resolve(__dirname, '../build/abi');
|
|
||||||
|
|
||||||
fs.readdir(contractsPath, (err, files) => {
|
|
||||||
if (err) {
|
|
||||||
console.error(err);
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!fs.existsSync(abiPath)) {
|
|
||||||
fs.mkdirSync(abiPath);
|
|
||||||
}
|
|
||||||
|
|
||||||
files.forEach(file => {
|
|
||||||
fs.readFile(
|
|
||||||
path.join(contractsPath, file),
|
|
||||||
{ encoding: 'utf8'},
|
|
||||||
(err, data) => {
|
|
||||||
if (err) {
|
|
||||||
console.error(err);
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
const json = JSON.parse(data);
|
|
||||||
fs.writeFileSync(path.join(abiPath, file), JSON.stringify(json.abi, null, 2));
|
|
||||||
}
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
|
@ -1,10 +0,0 @@
|
||||||
const path = require('path');
|
|
||||||
const { generateTypeChainWrappers } = require('typechain');
|
|
||||||
|
|
||||||
process.env.DEBUG = 'typechain';
|
|
||||||
generateTypeChainWrappers({
|
|
||||||
cwd: path.resolve(__dirname, '..'),
|
|
||||||
glob: path.resolve(__dirname, '../build/abi/*.json'),
|
|
||||||
outDir: path.resolve(__dirname, '../build/typedefs'),
|
|
||||||
force: true,
|
|
||||||
});
|
|
|
@ -1,301 +0,0 @@
|
||||||
pragma solidity ^0.4.24;
|
|
||||||
import "openzeppelin-solidity/contracts/math/SafeMath.sol";
|
|
||||||
|
|
||||||
|
|
||||||
contract CrowdFund {
|
|
||||||
using SafeMath for uint256;
|
|
||||||
|
|
||||||
enum FreezeReason {
|
|
||||||
CALLER_IS_TRUSTEE,
|
|
||||||
CROWD_FUND_FAILED,
|
|
||||||
MAJORITY_VOTING_TO_REFUND
|
|
||||||
}
|
|
||||||
|
|
||||||
FreezeReason freezeReason;
|
|
||||||
|
|
||||||
struct Milestone {
|
|
||||||
uint amount;
|
|
||||||
uint amountVotingAgainstPayout;
|
|
||||||
uint payoutRequestVoteDeadline;
|
|
||||||
bool paid;
|
|
||||||
}
|
|
||||||
|
|
||||||
struct Contributor {
|
|
||||||
uint contributionAmount;
|
|
||||||
// array index bool reflect milestone index vote
|
|
||||||
bool[] milestoneNoVotes;
|
|
||||||
bool refundVote;
|
|
||||||
bool refunded;
|
|
||||||
}
|
|
||||||
|
|
||||||
event Deposited(address indexed payee, uint256 weiAmount);
|
|
||||||
event Withdrawn(address indexed payee, uint256 weiAmount);
|
|
||||||
|
|
||||||
bool public frozen;
|
|
||||||
bool public isRaiseGoalReached;
|
|
||||||
bool public immediateFirstMilestonePayout;
|
|
||||||
uint public milestoneVotingPeriod;
|
|
||||||
uint public deadline;
|
|
||||||
uint public raiseGoal;
|
|
||||||
uint public amountRaised;
|
|
||||||
uint public frozenBalance;
|
|
||||||
uint public minimumContributionAmount;
|
|
||||||
uint public amountVotingForRefund;
|
|
||||||
address public beneficiary;
|
|
||||||
mapping(address => Contributor) public contributors;
|
|
||||||
address[] public contributorList;
|
|
||||||
// authorized addresses to ask for milestone payouts
|
|
||||||
address[] public trustees;
|
|
||||||
// constructor ensures that all values combined equal raiseGoal
|
|
||||||
Milestone[] public milestones;
|
|
||||||
|
|
||||||
constructor(
|
|
||||||
uint _raiseGoal,
|
|
||||||
address _beneficiary,
|
|
||||||
address[] _trustees,
|
|
||||||
uint[] _milestones,
|
|
||||||
uint _deadline,
|
|
||||||
uint _milestoneVotingPeriod,
|
|
||||||
bool _immediateFirstMilestonePayout)
|
|
||||||
public {
|
|
||||||
require(_raiseGoal >= 1 ether, "Raise goal is smaller than 1 ether");
|
|
||||||
require(_trustees.length >= 1 && _trustees.length <= 10, "Trustee addresses must be at least 1 and not more than 10");
|
|
||||||
require(_milestones.length >= 1 && _milestones.length <= 10, "Milestones must be at least 1 and not more than 10");
|
|
||||||
// TODO - require minimum duration
|
|
||||||
// TODO - require minimum milestone voting period
|
|
||||||
|
|
||||||
// ensure that cumalative milestone payouts equal raiseGoalAmount
|
|
||||||
uint milestoneTotal = 0;
|
|
||||||
for (uint i = 0; i < _milestones.length; i++) {
|
|
||||||
uint milestoneAmount = _milestones[i];
|
|
||||||
require(milestoneAmount > 0, "Milestone amount must be greater than 0");
|
|
||||||
milestoneTotal = milestoneTotal.add(milestoneAmount);
|
|
||||||
milestones.push(Milestone({
|
|
||||||
amount: milestoneAmount,
|
|
||||||
payoutRequestVoteDeadline: 0,
|
|
||||||
amountVotingAgainstPayout: 0,
|
|
||||||
paid: false
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
require(milestoneTotal == _raiseGoal, "Milestone total must equal raise goal");
|
|
||||||
// TODO - increase minimum contribution amount is 0.1% of raise goal
|
|
||||||
minimumContributionAmount = 1;
|
|
||||||
raiseGoal = _raiseGoal;
|
|
||||||
beneficiary = _beneficiary;
|
|
||||||
trustees = _trustees;
|
|
||||||
deadline = now + _deadline;
|
|
||||||
milestoneVotingPeriod = _milestoneVotingPeriod;
|
|
||||||
immediateFirstMilestonePayout = _immediateFirstMilestonePayout;
|
|
||||||
isRaiseGoalReached = false;
|
|
||||||
amountVotingForRefund = 0;
|
|
||||||
frozen = false;
|
|
||||||
// assumes no ether contributed as part of contract deployment
|
|
||||||
amountRaised = 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
function contribute() public payable onlyOnGoing onlyUnfrozen {
|
|
||||||
// don't allow overfunding
|
|
||||||
uint newAmountRaised = amountRaised.add(msg.value);
|
|
||||||
require(newAmountRaised <= raiseGoal, "Contribution exceeds the raise goal.");
|
|
||||||
// require minimumContributionAmount (set during construction)
|
|
||||||
// there may be a special case where just enough has been raised so that the remaining raise amount is just smaller than the minimumContributionAmount
|
|
||||||
// in this case, allow that the msg.value + amountRaised will equal the raiseGoal.
|
|
||||||
// This makes sure that we don't enter a scenario where a proposal can never be fully funded
|
|
||||||
bool greaterThanMinimum = msg.value >= minimumContributionAmount;
|
|
||||||
bool exactlyRaiseGoal = newAmountRaised == raiseGoal;
|
|
||||||
require(greaterThanMinimum || exactlyRaiseGoal, "msg.value greater than minimum, or msg.value == remaining amount to be raised");
|
|
||||||
// in cases where an address pays > 1 times
|
|
||||||
if (contributors[msg.sender].contributionAmount == 0) {
|
|
||||||
contributors[msg.sender] = Contributor({
|
|
||||||
contributionAmount: msg.value,
|
|
||||||
milestoneNoVotes: new bool[](milestones.length),
|
|
||||||
refundVote: false,
|
|
||||||
refunded: false
|
|
||||||
});
|
|
||||||
contributorList.push(msg.sender);
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
contributors[msg.sender].contributionAmount = contributors[msg.sender].contributionAmount.add(msg.value);
|
|
||||||
}
|
|
||||||
|
|
||||||
amountRaised = newAmountRaised;
|
|
||||||
if (amountRaised == raiseGoal) {
|
|
||||||
isRaiseGoalReached = true;
|
|
||||||
}
|
|
||||||
emit Deposited(msg.sender, msg.value);
|
|
||||||
}
|
|
||||||
|
|
||||||
function requestMilestonePayout (uint index) public onlyTrustee onlyRaised onlyUnfrozen {
|
|
||||||
bool milestoneAlreadyPaid = milestones[index].paid;
|
|
||||||
bool voteDeadlineHasPassed = milestones[index].payoutRequestVoteDeadline > now;
|
|
||||||
bool majorityAgainstPayout = isMajorityVoting(milestones[index].amountVotingAgainstPayout);
|
|
||||||
// prevent requesting paid milestones
|
|
||||||
require(!milestoneAlreadyPaid, "Milestone already paid");
|
|
||||||
|
|
||||||
int lowestIndexPaid = -1;
|
|
||||||
for (uint i = 0; i < milestones.length; i++) {
|
|
||||||
if (milestones[i].paid) {
|
|
||||||
lowestIndexPaid = int(i);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
require(index == uint(lowestIndexPaid + 1), "Milestone request must be for first unpaid milestone");
|
|
||||||
// begin grace period for contributors to vote no on milestone payout
|
|
||||||
if (milestones[index].payoutRequestVoteDeadline == 0) {
|
|
||||||
if (index == 0 && immediateFirstMilestonePayout) {
|
|
||||||
// make milestone payouts immediately avtheailable for the first milestone if immediateFirstMilestonePayout is set during consutrction
|
|
||||||
milestones[index].payoutRequestVoteDeadline = 1;
|
|
||||||
} else {
|
|
||||||
milestones[index].payoutRequestVoteDeadline = now.add(milestoneVotingPeriod);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// if the payoutRequestVoteDealine has passed and majority voted against it previously, begin the grace period with 2 times the deadline
|
|
||||||
else if (voteDeadlineHasPassed && majorityAgainstPayout) {
|
|
||||||
milestones[index].payoutRequestVoteDeadline = now.add(milestoneVotingPeriod.mul(2));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function voteMilestonePayout(uint index, bool vote) public onlyContributor onlyRaised onlyUnfrozen {
|
|
||||||
bool existingMilestoneNoVote = contributors[msg.sender].milestoneNoVotes[index];
|
|
||||||
require(existingMilestoneNoVote != vote, "Vote value must be different than existing vote state");
|
|
||||||
bool milestoneVotingStarted = milestones[index].payoutRequestVoteDeadline > 0;
|
|
||||||
bool votePeriodHasEnded = milestones[index].payoutRequestVoteDeadline <= now;
|
|
||||||
bool onGoingVote = milestoneVotingStarted && !votePeriodHasEnded;
|
|
||||||
require(onGoingVote, "Milestone voting must be open");
|
|
||||||
contributors[msg.sender].milestoneNoVotes[index] = vote;
|
|
||||||
if (!vote) {
|
|
||||||
milestones[index].amountVotingAgainstPayout = milestones[index].amountVotingAgainstPayout.sub(contributors[msg.sender].contributionAmount);
|
|
||||||
} else {
|
|
||||||
milestones[index].amountVotingAgainstPayout = milestones[index].amountVotingAgainstPayout.add(contributors[msg.sender].contributionAmount);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function payMilestonePayout(uint index) public onlyRaised onlyUnfrozen {
|
|
||||||
bool voteDeadlineHasPassed = milestones[index].payoutRequestVoteDeadline < now;
|
|
||||||
bool majorityVotedNo = isMajorityVoting(milestones[index].amountVotingAgainstPayout);
|
|
||||||
bool milestoneAlreadyPaid = milestones[index].paid;
|
|
||||||
if (voteDeadlineHasPassed && !majorityVotedNo && !milestoneAlreadyPaid) {
|
|
||||||
milestones[index].paid = true;
|
|
||||||
uint amount = milestones[index].amount;
|
|
||||||
beneficiary.transfer(amount);
|
|
||||||
emit Withdrawn(beneficiary, amount);
|
|
||||||
// if the final milestone just got paid
|
|
||||||
if (milestones.length.sub(index) == 1) {
|
|
||||||
// useful to selfdestruct in case funds were forcefully deposited into contract. otherwise they are lost.
|
|
||||||
selfdestruct(beneficiary);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
revert("required conditions were not satisfied");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function voteRefund(bool vote) public onlyContributor onlyRaised onlyUnfrozen {
|
|
||||||
bool refundVote = contributors[msg.sender].refundVote;
|
|
||||||
require(vote != refundVote, "Existing vote state is identical to vote value");
|
|
||||||
contributors[msg.sender].refundVote = vote;
|
|
||||||
if (!vote) {
|
|
||||||
amountVotingForRefund = amountVotingForRefund.sub(contributors[msg.sender].contributionAmount);
|
|
||||||
} else {
|
|
||||||
amountVotingForRefund = amountVotingForRefund.add(contributors[msg.sender].contributionAmount);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function refund() public onlyUnfrozen {
|
|
||||||
bool callerIsTrustee = isCallerTrustee();
|
|
||||||
bool crowdFundFailed = isFailed();
|
|
||||||
bool majorityVotingToRefund = isMajorityVoting(amountVotingForRefund);
|
|
||||||
require(callerIsTrustee || crowdFundFailed || majorityVotingToRefund, "Required conditions for refund are not met");
|
|
||||||
if (callerIsTrustee) {
|
|
||||||
freezeReason = FreezeReason.CALLER_IS_TRUSTEE;
|
|
||||||
} else if (crowdFundFailed) {
|
|
||||||
freezeReason = FreezeReason.CROWD_FUND_FAILED;
|
|
||||||
} else {
|
|
||||||
freezeReason = FreezeReason.MAJORITY_VOTING_TO_REFUND;
|
|
||||||
}
|
|
||||||
frozen = true;
|
|
||||||
frozenBalance = address(this).balance;
|
|
||||||
}
|
|
||||||
|
|
||||||
// anyone can refund a contributor if a crowdfund has been frozen
|
|
||||||
function withdraw(address refundAddress) public onlyFrozen {
|
|
||||||
require(frozen, "CrowdFund is not frozen");
|
|
||||||
bool isRefunded = contributors[refundAddress].refunded;
|
|
||||||
require(!isRefunded, "Specified address is already refunded");
|
|
||||||
contributors[refundAddress].refunded = true;
|
|
||||||
uint contributionAmount = contributors[refundAddress].contributionAmount;
|
|
||||||
uint amountToRefund = contributionAmount.mul(address(this).balance).div(frozenBalance);
|
|
||||||
refundAddress.transfer(amountToRefund);
|
|
||||||
emit Withdrawn(refundAddress, amountToRefund);
|
|
||||||
}
|
|
||||||
|
|
||||||
// it may be useful to selfdestruct in case funds were force deposited to the contract
|
|
||||||
function destroy() public onlyTrustee onlyFrozen {
|
|
||||||
for (uint i = 0; i < contributorList.length; i++) {
|
|
||||||
address contributorAddress = contributorList[i];
|
|
||||||
if (!contributors[contributorAddress].refunded) {
|
|
||||||
revert("At least one contributor has not yet refunded");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
selfdestruct(beneficiary);
|
|
||||||
}
|
|
||||||
|
|
||||||
function isMajorityVoting(uint valueVoting) public view returns (bool) {
|
|
||||||
return valueVoting.mul(2) > amountRaised;
|
|
||||||
}
|
|
||||||
|
|
||||||
function isCallerTrustee() public view returns (bool) {
|
|
||||||
for (uint i = 0; i < trustees.length; i++) {
|
|
||||||
if (msg.sender == trustees[i]) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
function isFailed() public view returns (bool) {
|
|
||||||
return now >= deadline && !isRaiseGoalReached;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getContributorMilestoneVote(address contributorAddress, uint milestoneIndex) public view returns (bool) {
|
|
||||||
return contributors[contributorAddress].milestoneNoVotes[milestoneIndex];
|
|
||||||
}
|
|
||||||
|
|
||||||
function getContributorContributionAmount(address contributorAddress) public view returns (uint) {
|
|
||||||
return contributors[contributorAddress].contributionAmount;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getFreezeReason() public view returns (uint) {
|
|
||||||
return uint(freezeReason);
|
|
||||||
}
|
|
||||||
|
|
||||||
modifier onlyFrozen() {
|
|
||||||
require(frozen, "CrowdFund is not frozen");
|
|
||||||
_;
|
|
||||||
}
|
|
||||||
|
|
||||||
modifier onlyUnfrozen() {
|
|
||||||
require(!frozen, "CrowdFund is frozen");
|
|
||||||
_;
|
|
||||||
}
|
|
||||||
|
|
||||||
modifier onlyRaised() {
|
|
||||||
require(isRaiseGoalReached, "Raise goal is not reached");
|
|
||||||
_;
|
|
||||||
}
|
|
||||||
|
|
||||||
modifier onlyOnGoing() {
|
|
||||||
require(now <= deadline && !isRaiseGoalReached, "CrowdFund is not ongoing");
|
|
||||||
_;
|
|
||||||
}
|
|
||||||
|
|
||||||
modifier onlyContributor() {
|
|
||||||
require(contributors[msg.sender].contributionAmount != 0, "Caller is not a contributor");
|
|
||||||
_;
|
|
||||||
}
|
|
||||||
|
|
||||||
modifier onlyTrustee() {
|
|
||||||
require(isCallerTrustee(), "Caller is not a trustee");
|
|
||||||
_;
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
|
@ -1,31 +0,0 @@
|
||||||
pragma solidity ^0.4.24;
|
|
||||||
import "./CrowdFund.sol";
|
|
||||||
|
|
||||||
contract CrowdFundFactory {
|
|
||||||
address[] crowdfunds;
|
|
||||||
|
|
||||||
event ContractCreated(address newAddress);
|
|
||||||
|
|
||||||
function createCrowdFund (
|
|
||||||
uint raiseGoalAmount,
|
|
||||||
address payOutAddress,
|
|
||||||
address[] trusteesAddresses,
|
|
||||||
uint[] allMilestones,
|
|
||||||
uint durationInSeconds,
|
|
||||||
uint milestoneVotingPeriodInSeconds,
|
|
||||||
bool immediateFirstMilestonePayout
|
|
||||||
) public returns(address) {
|
|
||||||
address newCrowdFundContract = new CrowdFund(
|
|
||||||
raiseGoalAmount,
|
|
||||||
payOutAddress,
|
|
||||||
trusteesAddresses,
|
|
||||||
allMilestones,
|
|
||||||
durationInSeconds,
|
|
||||||
milestoneVotingPeriodInSeconds,
|
|
||||||
immediateFirstMilestonePayout
|
|
||||||
);
|
|
||||||
emit ContractCreated(newCrowdFundContract);
|
|
||||||
crowdfunds.push(newCrowdFundContract);
|
|
||||||
return newCrowdFundContract;
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,15 +0,0 @@
|
||||||
pragma solidity ^0.4.24;
|
|
||||||
|
|
||||||
contract Forward {
|
|
||||||
address public destinationAddress;
|
|
||||||
|
|
||||||
constructor(address _destinationAddress) public {
|
|
||||||
destinationAddress = _destinationAddress;
|
|
||||||
}
|
|
||||||
|
|
||||||
function() public payable { }
|
|
||||||
|
|
||||||
function payOut() public {
|
|
||||||
destinationAddress.transfer(address(this).balance);
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,23 +0,0 @@
|
||||||
pragma solidity ^0.4.23;
|
|
||||||
|
|
||||||
contract Migrations {
|
|
||||||
address public owner;
|
|
||||||
uint public last_completed_migration;
|
|
||||||
|
|
||||||
constructor() public {
|
|
||||||
owner = msg.sender;
|
|
||||||
}
|
|
||||||
|
|
||||||
modifier restricted() {
|
|
||||||
if (msg.sender == owner) _;
|
|
||||||
}
|
|
||||||
|
|
||||||
function setCompleted(uint completed) public restricted {
|
|
||||||
last_completed_migration = completed;
|
|
||||||
}
|
|
||||||
|
|
||||||
function upgrade(address new_address) public restricted {
|
|
||||||
Migrations upgraded = Migrations(new_address);
|
|
||||||
upgraded.setCompleted(last_completed_migration);
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,202 +0,0 @@
|
||||||
pragma solidity ^0.4.24;
|
|
||||||
import "openzeppelin-solidity/contracts/math/SafeMath.sol";
|
|
||||||
|
|
||||||
contract PrivateFund {
|
|
||||||
|
|
||||||
using SafeMath for uint256;
|
|
||||||
|
|
||||||
struct Milestone {
|
|
||||||
uint amount;
|
|
||||||
bool openRequest;
|
|
||||||
bool paid;
|
|
||||||
}
|
|
||||||
|
|
||||||
struct BoardMember {
|
|
||||||
bool[] milestoneApprovals;
|
|
||||||
address refundAddress;
|
|
||||||
// TODO - refactor not to waste space like this;
|
|
||||||
bool exists;
|
|
||||||
}
|
|
||||||
|
|
||||||
event Transfered(address payee, uint weiAmount);
|
|
||||||
event Deposited(address indexed payee, uint256 weiAmount);
|
|
||||||
event Withdrawn(address indexed payee, uint256 weiAmount);
|
|
||||||
|
|
||||||
uint public amountRaised;
|
|
||||||
uint public raiseGoal;
|
|
||||||
uint public quorum;
|
|
||||||
address public beneficiary;
|
|
||||||
address public funder;
|
|
||||||
address[] public trustees;
|
|
||||||
bool unanimityForRefunds;
|
|
||||||
|
|
||||||
mapping(address => BoardMember) public boardMembers;
|
|
||||||
address[] public boardMembersList;
|
|
||||||
// constructor ensures that all values combined equal raiseGoal
|
|
||||||
Milestone[] public milestones;
|
|
||||||
|
|
||||||
constructor(
|
|
||||||
uint _raiseGoal,
|
|
||||||
address _beneficiary,
|
|
||||||
address[] _trustees,
|
|
||||||
uint _quorum,
|
|
||||||
address[] _boardMembers,
|
|
||||||
uint[] _milestones,
|
|
||||||
address _funder,
|
|
||||||
bool _unanimityForRefunds)
|
|
||||||
public {
|
|
||||||
require(_raiseGoal >= 1 ether, "Raise goal is smaller than 1 ether");
|
|
||||||
require(_milestones.length >= 1, "Milestones must be at least 1");
|
|
||||||
require(_quorum <= _boardMembers.length, "quorum is larger than total number of boardMembers");
|
|
||||||
require(_quorum >= 1, "quorum must be at least 1");
|
|
||||||
// TODO - require minimum milestone voting period
|
|
||||||
|
|
||||||
// ensure that cumalative milestone payouts equal raiseGoalAmount
|
|
||||||
uint milestoneTotal = 0;
|
|
||||||
for (uint i = 0; i < _milestones.length; i++) {
|
|
||||||
uint milestoneAmount = _milestones[i];
|
|
||||||
require(milestoneAmount > 0, "Milestone amount must be greater than 0");
|
|
||||||
milestoneTotal = milestoneTotal.add(milestoneAmount);
|
|
||||||
milestones.push(Milestone({
|
|
||||||
amount: milestoneAmount,
|
|
||||||
openRequest: false,
|
|
||||||
paid: false
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
require(milestoneTotal == _raiseGoal, "Milestone total must equal raise goal");
|
|
||||||
|
|
||||||
boardMembersList = _boardMembers;
|
|
||||||
for (uint e = 0; e < boardMembersList.length; e++) {
|
|
||||||
address boardMemberAddress = boardMembersList[e];
|
|
||||||
boardMembers[boardMemberAddress] = BoardMember({
|
|
||||||
milestoneApprovals: new bool[](milestones.length),
|
|
||||||
refundAddress: 0,
|
|
||||||
exists: true
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
quorum = _quorum;
|
|
||||||
raiseGoal = _raiseGoal;
|
|
||||||
beneficiary = _beneficiary;
|
|
||||||
trustees = _trustees;
|
|
||||||
funder = _funder;
|
|
||||||
unanimityForRefunds = _unanimityForRefunds;
|
|
||||||
amountRaised = 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
function contribute() public payable {
|
|
||||||
require(msg.sender == funder, "Sender must be funder");
|
|
||||||
require(amountRaised.add(msg.value) == raiseGoal, "Contribution must be exactly raise goal");
|
|
||||||
amountRaised = msg.value;
|
|
||||||
emit Deposited(msg.sender, msg.value);
|
|
||||||
}
|
|
||||||
|
|
||||||
function requestMilestonePayout (uint index) public onlyTrustee onlyRaised {
|
|
||||||
bool milestoneAlreadyPaid = milestones[index].paid;
|
|
||||||
// prevent requesting paid milestones
|
|
||||||
require(!milestoneAlreadyPaid, "Milestone already paid");
|
|
||||||
|
|
||||||
int lowestIndexPaid = -1;
|
|
||||||
for (uint i = 0; i < milestones.length; i++) {
|
|
||||||
if (milestones[i].paid) {
|
|
||||||
lowestIndexPaid = int(i);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
require(index == uint(lowestIndexPaid + 1), "Milestone request must be for first unpaid milestone");
|
|
||||||
// begin grace period for contributors to vote no on milestone payout
|
|
||||||
require(!milestones[index].openRequest, "Milestone must not have already been requested");
|
|
||||||
milestones[index].openRequest = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
function voteMilestonePayout(uint index, bool vote) public onlyBoardMember onlyRaised {
|
|
||||||
bool existingMilestoneVote = boardMembers[msg.sender].milestoneApprovals[index];
|
|
||||||
require(existingMilestoneVote != vote, "Vote value must be different than existing vote state");
|
|
||||||
require(milestones[index].openRequest, "Milestone voting must be open");
|
|
||||||
boardMembers[msg.sender].milestoneApprovals[index] = vote;
|
|
||||||
}
|
|
||||||
|
|
||||||
function payMilestonePayout(uint index) public onlyRaised {
|
|
||||||
bool quorumReached = isQuorumReachedForMilestonePayout(index);
|
|
||||||
bool milestoneAlreadyPaid = milestones[index].paid;
|
|
||||||
if (quorumReached && !milestoneAlreadyPaid) {
|
|
||||||
milestones[index].paid = true;
|
|
||||||
milestones[index].openRequest = false;
|
|
||||||
fundTransfer(beneficiary, milestones[index].amount);
|
|
||||||
// TODO trigger self-destruct with any un-spent funds (since funds could have been force sent at any point)
|
|
||||||
} else {
|
|
||||||
revert("required conditions were not satisfied");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function voteRefundAddress(address refundAddress) public onlyBoardMember onlyRaised {
|
|
||||||
boardMembers[msg.sender].refundAddress = refundAddress;
|
|
||||||
}
|
|
||||||
|
|
||||||
function refund(address refundAddress) public onlyBoardMember onlyRaised {
|
|
||||||
require(isConsensusReachedForRefund(refundAddress), "Unanimity is not reached to refund to given address");
|
|
||||||
selfdestruct(refundAddress);
|
|
||||||
}
|
|
||||||
|
|
||||||
function fundTransfer(address etherReceiver, uint256 amount) private {
|
|
||||||
etherReceiver.transfer(amount);
|
|
||||||
emit Transfered(etherReceiver, amount);
|
|
||||||
}
|
|
||||||
|
|
||||||
function isConsensusReachedForRefund(address refundAddress) public view onlyRaised returns (bool) {
|
|
||||||
uint yesVotes = 0;
|
|
||||||
for (uint i = 0; i < boardMembersList.length; i++) {
|
|
||||||
address boardMemberAddress = boardMembersList[i];
|
|
||||||
address boardMemberRefundAddressSelection = boardMembers[boardMemberAddress].refundAddress;
|
|
||||||
if (boardMemberRefundAddressSelection == refundAddress) {
|
|
||||||
yesVotes += 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (unanimityForRefunds) {
|
|
||||||
return yesVotes == boardMembersList.length;
|
|
||||||
} else {
|
|
||||||
return yesVotes >= quorum;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function isQuorumReachedForMilestonePayout(uint milestoneIndex) public view onlyRaised returns (bool) {
|
|
||||||
uint yesVotes = 0;
|
|
||||||
for (uint i = 0; i < boardMembersList.length; i++) {
|
|
||||||
address boardMemberAddress = boardMembersList[i];
|
|
||||||
bool boardMemberVote = boardMembers[boardMemberAddress].milestoneApprovals[milestoneIndex];
|
|
||||||
if (boardMemberVote) {
|
|
||||||
yesVotes += 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return yesVotes >= quorum;
|
|
||||||
}
|
|
||||||
|
|
||||||
function isCallerTrustee() public view returns (bool) {
|
|
||||||
for (uint i = 0; i < trustees.length; i++) {
|
|
||||||
if (msg.sender == trustees[i]) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getBoardMemberMilestoneVote(address boardMemberAddress, uint milestoneIndex) public view returns (bool) {
|
|
||||||
return boardMembers[boardMemberAddress].milestoneApprovals[milestoneIndex];
|
|
||||||
}
|
|
||||||
|
|
||||||
modifier onlyRaised() {
|
|
||||||
require(raiseGoal == amountRaised, "Proposal is not funded");
|
|
||||||
_;
|
|
||||||
}
|
|
||||||
|
|
||||||
modifier onlyBoardMember() {
|
|
||||||
require(boardMembers[msg.sender].exists, "Caller is not a board member");
|
|
||||||
_;
|
|
||||||
}
|
|
||||||
|
|
||||||
modifier onlyTrustee() {
|
|
||||||
require(isCallerTrustee(), "Caller is not a trustee");
|
|
||||||
_;
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
|
@ -1,33 +0,0 @@
|
||||||
pragma solidity ^0.4.24;
|
|
||||||
import "./PrivateFund.sol";
|
|
||||||
|
|
||||||
contract PrivateFundFactory {
|
|
||||||
address[] privateFunds;
|
|
||||||
|
|
||||||
event ContractCreated(address newAddress);
|
|
||||||
|
|
||||||
function createPrivateFund (
|
|
||||||
uint _raiseGoal,
|
|
||||||
address _beneficiary,
|
|
||||||
address[] _trustees,
|
|
||||||
uint _quorum,
|
|
||||||
address[] _boardMembers,
|
|
||||||
uint[] _milestones,
|
|
||||||
address _funder,
|
|
||||||
bool _unanimityForRefunds
|
|
||||||
) public returns(address) {
|
|
||||||
address newPrivateFundContract = new PrivateFund(
|
|
||||||
_raiseGoal,
|
|
||||||
_beneficiary,
|
|
||||||
_trustees,
|
|
||||||
_quorum,
|
|
||||||
_boardMembers,
|
|
||||||
_milestones,
|
|
||||||
_funder,
|
|
||||||
_unanimityForRefunds
|
|
||||||
);
|
|
||||||
emit ContractCreated(newPrivateFundContract);
|
|
||||||
privateFunds.push(newPrivateFundContract);
|
|
||||||
return newPrivateFundContract;
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,13 +0,0 @@
|
||||||
export { default as CrowdFundContract } from './build/contracts/CrowdFund.json';
|
|
||||||
export { default as CrowdFundFactoryContract } from './build/contracts/CrowdFundFactory.json';
|
|
||||||
export { default as MigrationsContract } from './build/contracts/Migrations.json';
|
|
||||||
export { default as SafeMathContract } from './build/contracts/SafeMath.json';
|
|
||||||
|
|
||||||
export { default as CrowdFundABI } from './build/abi/CrowdFund.json';
|
|
||||||
export { default as CrowdFundFactoryABI } from './build/abi/CrowdFundFactory.json';
|
|
||||||
export { default as MigrationsABI } from './build/abi/Migrations.json';
|
|
||||||
export { default as SafeMathABI } from './build/abi/SafeMath.json';
|
|
||||||
|
|
||||||
export { CrowdFund } from './build/typedefs/CrowdFund.ts';
|
|
||||||
export { CrowdFundFactory } from './build/typedefs/CrowdFundFactory.ts';
|
|
||||||
export { Migrations } from './build/typedefs/Migrations.ts';
|
|
|
@ -1,5 +0,0 @@
|
||||||
var Migrations = artifacts.require("./Migrations.sol");
|
|
||||||
|
|
||||||
module.exports = function(deployer) {
|
|
||||||
deployer.deploy(Migrations);
|
|
||||||
};
|
|
|
@ -1,5 +0,0 @@
|
||||||
const CrowdFundFactory = artifacts.require("./CrowdFundFactory.sol");
|
|
||||||
|
|
||||||
module.exports = function(deployer) {
|
|
||||||
deployer.deploy(CrowdFundFactory);
|
|
||||||
};
|
|
|
@ -1,24 +0,0 @@
|
||||||
{
|
|
||||||
"name": "grant-contract",
|
|
||||||
"version": "1.0.0",
|
|
||||||
"description": "",
|
|
||||||
"main": "main.js",
|
|
||||||
"directories": {
|
|
||||||
"test": "test"
|
|
||||||
},
|
|
||||||
"scripts": {
|
|
||||||
"test": "truffle test",
|
|
||||||
"build": "truffle compile && node ./bin/build-abi && node ./bin/build-types"
|
|
||||||
},
|
|
||||||
"author": "",
|
|
||||||
"license": "ISC",
|
|
||||||
"dependencies": {
|
|
||||||
"openzeppelin-solidity": "^1.12.0",
|
|
||||||
"truffle-hdwallet-provider": "^0.0.6"
|
|
||||||
},
|
|
||||||
"devDependencies": {
|
|
||||||
"chai": "^4.1.2",
|
|
||||||
"eth-gas-reporter": "^0.1.10",
|
|
||||||
"typechain": "0.2.7"
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,118 +0,0 @@
|
||||||
const CrowdFund = artifacts.require("CrowdFund");
|
|
||||||
const { increaseTime } = require("./utils");
|
|
||||||
|
|
||||||
const HOUR = 3600;
|
|
||||||
const DAY = HOUR * 24;
|
|
||||||
const ETHER = 10 ** 18;
|
|
||||||
const DEADLINE = DAY * 100;
|
|
||||||
const AFTER_DEADLINE_EXPIRES = DEADLINE + DAY;
|
|
||||||
|
|
||||||
contract("CrowdFund Deadline", accounts => {
|
|
||||||
const [
|
|
||||||
firstAccount,
|
|
||||||
firstTrusteeAccount,
|
|
||||||
thirdAccount,
|
|
||||||
fourthAccount
|
|
||||||
] = accounts;
|
|
||||||
const raiseGoal = ETHER;
|
|
||||||
const beneficiary = firstTrusteeAccount;
|
|
||||||
// TODO - set multiple trustees and add tests
|
|
||||||
const trustees = [firstTrusteeAccount];
|
|
||||||
// TODO - set multiple milestones and add tests
|
|
||||||
const milestones = [raiseGoal];
|
|
||||||
const deadline = DEADLINE;
|
|
||||||
const milestoneVotingPeriod = HOUR;
|
|
||||||
const immediateFirstMilestonePayout = false;
|
|
||||||
|
|
||||||
let crowdFund;
|
|
||||||
|
|
||||||
beforeEach(async () => {
|
|
||||||
crowdFund = await CrowdFund.new(
|
|
||||||
raiseGoal,
|
|
||||||
beneficiary,
|
|
||||||
trustees,
|
|
||||||
milestones,
|
|
||||||
deadline,
|
|
||||||
milestoneVotingPeriod,
|
|
||||||
immediateFirstMilestonePayout,
|
|
||||||
{ from: firstAccount }
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns true when isFailed is called after deadline has passed", async () => {
|
|
||||||
assert.equal(await crowdFund.isFailed.call(), false);
|
|
||||||
await increaseTime(AFTER_DEADLINE_EXPIRES);
|
|
||||||
assert.equal(await crowdFund.isFailed.call(), true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("allows anyone to refund after time is up and goal is not reached and sets refund reason to 1", async () => {
|
|
||||||
const fundAmount = raiseGoal / 10;
|
|
||||||
await crowdFund.contribute({
|
|
||||||
from: fourthAccount,
|
|
||||||
value: fundAmount,
|
|
||||||
gasPrice: 0,
|
|
||||||
});
|
|
||||||
assert.equal(
|
|
||||||
(await crowdFund.contributors(fourthAccount))[0].toNumber(),
|
|
||||||
fundAmount
|
|
||||||
);
|
|
||||||
assert.equal(await crowdFund.contributorList(0), fourthAccount);
|
|
||||||
const initBalance = await web3.eth.getBalance(fourthAccount);
|
|
||||||
await increaseTime(AFTER_DEADLINE_EXPIRES);
|
|
||||||
await crowdFund.refund();
|
|
||||||
assert.equal((await crowdFund.getFreezeReason()), 1)
|
|
||||||
await crowdFund.withdraw(fourthAccount);
|
|
||||||
const finalBalance = await web3.eth.getBalance(fourthAccount);
|
|
||||||
assert.ok(finalBalance.equals(initBalance.plus(fundAmount)));
|
|
||||||
});
|
|
||||||
|
|
||||||
it("refunds remaining proportionally when fundraiser has failed", async () => {
|
|
||||||
const tenthOfRaiseGoal = raiseGoal / 10;
|
|
||||||
await crowdFund.contribute({
|
|
||||||
from: fourthAccount,
|
|
||||||
value: tenthOfRaiseGoal
|
|
||||||
});
|
|
||||||
const initBalanceFourthAccount = await web3.eth.getBalance(fourthAccount);
|
|
||||||
await increaseTime(AFTER_DEADLINE_EXPIRES);
|
|
||||||
assert.ok(await crowdFund.isFailed());
|
|
||||||
await crowdFund.refund();
|
|
||||||
await crowdFund.withdraw(fourthAccount);
|
|
||||||
const finalBalanceFourthAccount = await web3.eth.getBalance(fourthAccount);
|
|
||||||
assert.ok(finalBalanceFourthAccount.gt(initBalanceFourthAccount));
|
|
||||||
});
|
|
||||||
|
|
||||||
it("refund remaining proportionally when fundraiser has failed (more complex)", async () => {
|
|
||||||
const tenthOfRaiseGoal = raiseGoal / 10;
|
|
||||||
await crowdFund.contribute({
|
|
||||||
from: fourthAccount,
|
|
||||||
value: tenthOfRaiseGoal
|
|
||||||
});
|
|
||||||
await crowdFund.contribute({
|
|
||||||
from: thirdAccount,
|
|
||||||
value: tenthOfRaiseGoal * 4
|
|
||||||
});
|
|
||||||
const initBalanceFourthAccount = await web3.eth.getBalance(fourthAccount);
|
|
||||||
const initBalanceThirdAccount = await web3.eth.getBalance(thirdAccount);
|
|
||||||
await increaseTime(AFTER_DEADLINE_EXPIRES);
|
|
||||||
assert.ok(await crowdFund.isFailed());
|
|
||||||
const afterContributionBalanceFourthAccount = await web3.eth.getBalance(
|
|
||||||
fourthAccount
|
|
||||||
);
|
|
||||||
const afterContributionBalanceThirdAccount = await web3.eth.getBalance(
|
|
||||||
thirdAccount
|
|
||||||
);
|
|
||||||
// fourthAccount contributed a tenth of the raise goal, compared to third account with a fourth
|
|
||||||
assert.ok(
|
|
||||||
afterContributionBalanceFourthAccount.gt(
|
|
||||||
afterContributionBalanceThirdAccount
|
|
||||||
)
|
|
||||||
);
|
|
||||||
await crowdFund.refund();
|
|
||||||
await crowdFund.withdraw(fourthAccount);
|
|
||||||
await crowdFund.withdraw(thirdAccount);
|
|
||||||
const finalBalanceFourthAccount = await web3.eth.getBalance(fourthAccount);
|
|
||||||
const finalBalanceThirdAccount = await web3.eth.getBalance(thirdAccount);
|
|
||||||
assert.ok(finalBalanceFourthAccount.gt(initBalanceFourthAccount));
|
|
||||||
assert.ok(finalBalanceThirdAccount.gt(initBalanceThirdAccount));
|
|
||||||
});
|
|
||||||
});
|
|
|
@ -1,457 +0,0 @@
|
||||||
// References https://michalzalecki.com/ethereum-test-driven-introduction-to-solidity/
|
|
||||||
const CrowdFund = artifacts.require("CrowdFund");
|
|
||||||
const { increaseTime, assertRevert, assertVMException } = require("./utils");
|
|
||||||
|
|
||||||
const HOUR = 3600;
|
|
||||||
const DAY = HOUR * 24;
|
|
||||||
const ETHER = 10 ** 18;
|
|
||||||
const NOW = Math.round(new Date().getTime() / 1000);
|
|
||||||
const AFTER_VOTING_EXPIRES = HOUR * 2;
|
|
||||||
|
|
||||||
contract("CrowdFund", accounts => {
|
|
||||||
const [
|
|
||||||
firstAccount,
|
|
||||||
firstTrusteeAccount,
|
|
||||||
thirdAccount,
|
|
||||||
fourthAccount,
|
|
||||||
fifthAccount
|
|
||||||
] = accounts;
|
|
||||||
const raiseGoal = 1 * ETHER;
|
|
||||||
const beneficiary = firstTrusteeAccount;
|
|
||||||
// TODO - set multiple trustees and add tests
|
|
||||||
const trustees = [firstTrusteeAccount];
|
|
||||||
// TODO - set multiple milestones and add tests
|
|
||||||
const milestones = [raiseGoal];
|
|
||||||
const deadline = NOW + DAY * 100;
|
|
||||||
const milestoneVotingPeriod = HOUR;
|
|
||||||
const immediateFirstMilestonePayout = false;
|
|
||||||
|
|
||||||
let crowdFund;
|
|
||||||
|
|
||||||
beforeEach(async () => {
|
|
||||||
crowdFund = await CrowdFund.new(
|
|
||||||
raiseGoal,
|
|
||||||
beneficiary,
|
|
||||||
trustees,
|
|
||||||
milestones,
|
|
||||||
deadline,
|
|
||||||
milestoneVotingPeriod,
|
|
||||||
immediateFirstMilestonePayout,
|
|
||||||
{ from: fifthAccount }
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
// [BEGIN] constructor
|
|
||||||
// TODO - test all initial variables have expected values
|
|
||||||
it("initializes", async () => {
|
|
||||||
assert.equal(await crowdFund.raiseGoal.call(), raiseGoal);
|
|
||||||
assert.equal(await crowdFund.beneficiary.call(), beneficiary);
|
|
||||||
|
|
||||||
trustees.forEach(async (address, i) => {
|
|
||||||
assert.equal(await crowdFund.trustees.call(i), trustees[i]);
|
|
||||||
});
|
|
||||||
|
|
||||||
milestones.forEach(async (milestoneAmount, i) => {
|
|
||||||
assert.equal(await crowdFund.milestones.call(i)[0], milestoneAmount);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// [END] constructor
|
|
||||||
// [BEGIN] contribute
|
|
||||||
|
|
||||||
it("reverts on next contribution once raise goal is reached", async () => {
|
|
||||||
await crowdFund.contribute({
|
|
||||||
from: firstAccount,
|
|
||||||
value: raiseGoal
|
|
||||||
});
|
|
||||||
assert.ok(await crowdFund.isRaiseGoalReached());
|
|
||||||
assertRevert(
|
|
||||||
crowdFund.contribute({
|
|
||||||
from: firstAccount,
|
|
||||||
value: raiseGoal
|
|
||||||
})
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("keeps track of contributions", async () => {
|
|
||||||
await crowdFund.contribute({
|
|
||||||
from: firstAccount,
|
|
||||||
value: raiseGoal / 10
|
|
||||||
});
|
|
||||||
await crowdFund.contribute({
|
|
||||||
from: firstTrusteeAccount,
|
|
||||||
value: raiseGoal / 10
|
|
||||||
});
|
|
||||||
await crowdFund.contribute({
|
|
||||||
from: firstTrusteeAccount,
|
|
||||||
value: raiseGoal / 10
|
|
||||||
});
|
|
||||||
assert.equal(
|
|
||||||
(await crowdFund.contributors(firstAccount))[0].toNumber(),
|
|
||||||
raiseGoal / 10
|
|
||||||
);
|
|
||||||
assert.equal(
|
|
||||||
(await crowdFund.contributors(firstTrusteeAccount))[0].toNumber(),
|
|
||||||
raiseGoal / 5
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
// TODO BLOCKED - it reverts when contribution is under 1 wei. Blocked by switching contract to use minimum percentage contribution
|
|
||||||
|
|
||||||
it("revertd on contribution that exceeds raise goal", async () => {
|
|
||||||
assertRevert(
|
|
||||||
crowdFund.contribute({
|
|
||||||
from: firstAccount,
|
|
||||||
value: raiseGoal + raiseGoal / 10
|
|
||||||
})
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
// [BEGIN] requestMilestonePayout
|
|
||||||
|
|
||||||
it("does not allow milestone requests when caller is not a trustee", async () => {
|
|
||||||
assertRevert(crowdFund.requestMilestonePayout(0, { from: firstAccount }));
|
|
||||||
});
|
|
||||||
|
|
||||||
it("does not allow milestone requests when milestone has already been paid", async () => {
|
|
||||||
await crowdFund.contribute({ from: thirdAccount, value: raiseGoal });
|
|
||||||
const initBalance = await web3.eth.getBalance(firstTrusteeAccount);
|
|
||||||
await crowdFund.requestMilestonePayout(0, { from: firstTrusteeAccount });
|
|
||||||
await increaseTime(AFTER_VOTING_EXPIRES);
|
|
||||||
await crowdFund.payMilestonePayout(0);
|
|
||||||
const finalBalance = await web3.eth.getBalance(firstTrusteeAccount);
|
|
||||||
assert.ok(finalBalance.greaterThan(initBalance));
|
|
||||||
// TODO - enable
|
|
||||||
// assertRevert(
|
|
||||||
// crowdFund.requestMilestonePayout(0, { from: firstTrusteeAccount })
|
|
||||||
// );
|
|
||||||
});
|
|
||||||
|
|
||||||
// [END] requestMilestonePayout
|
|
||||||
// [BEGIN] voteMilestonePayout
|
|
||||||
|
|
||||||
it("only counts milestone vote once", async () => {
|
|
||||||
const tenthOfRaiseGoal = raiseGoal / 10;
|
|
||||||
await crowdFund.contribute({ from: thirdAccount, value: tenthOfRaiseGoal });
|
|
||||||
await crowdFund.contribute({
|
|
||||||
from: firstAccount,
|
|
||||||
value: tenthOfRaiseGoal * 9
|
|
||||||
});
|
|
||||||
assert.ok(await crowdFund.isRaiseGoalReached());
|
|
||||||
await crowdFund.requestMilestonePayout(0, { from: firstTrusteeAccount });
|
|
||||||
// first vote
|
|
||||||
await crowdFund.voteMilestonePayout(0, true, { from: firstAccount });
|
|
||||||
assert.equal(
|
|
||||||
(await crowdFund.milestones(0))[1].toNumber(),
|
|
||||||
tenthOfRaiseGoal * 9
|
|
||||||
);
|
|
||||||
// second vote
|
|
||||||
assertRevert(
|
|
||||||
crowdFund.voteMilestonePayout(0, true, { from: firstAccount })
|
|
||||||
);
|
|
||||||
assert.equal(
|
|
||||||
(await crowdFund.milestones(0))[1].toNumber(),
|
|
||||||
tenthOfRaiseGoal * 9
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("does not allow milestone voting before vote period has started", async () => {
|
|
||||||
await crowdFund.contribute({
|
|
||||||
from: thirdAccount,
|
|
||||||
value: raiseGoal / 10
|
|
||||||
});
|
|
||||||
await crowdFund.contribute({
|
|
||||||
from: firstAccount,
|
|
||||||
value: (raiseGoal / 10) * 9
|
|
||||||
});
|
|
||||||
assertRevert(
|
|
||||||
crowdFund.voteMilestonePayout(0, true, { from: thirdAccount })
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("does not allow milestone voting after vote period has ended", async () => {
|
|
||||||
await crowdFund.contribute({
|
|
||||||
from: thirdAccount,
|
|
||||||
value: raiseGoal / 10
|
|
||||||
});
|
|
||||||
await crowdFund.contribute({
|
|
||||||
from: firstAccount,
|
|
||||||
value: (raiseGoal / 10) * 9
|
|
||||||
});
|
|
||||||
await crowdFund.requestMilestonePayout(0, { from: firstTrusteeAccount });
|
|
||||||
await crowdFund.voteMilestonePayout(0, true, { from: thirdAccount });
|
|
||||||
await increaseTime(AFTER_VOTING_EXPIRES);
|
|
||||||
assertRevert(
|
|
||||||
crowdFund.voteMilestonePayout(0, true, { from: firstAccount })
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
// [END] voteMilestonePayout
|
|
||||||
// [BEGIN] payMilestonePayout
|
|
||||||
|
|
||||||
it("pays milestone when milestone is unpaid, caller is trustee, and no earlier milestone is unpaid", async () => {
|
|
||||||
await crowdFund.contribute({ from: thirdAccount, value: raiseGoal });
|
|
||||||
const initBalance = await web3.eth.getBalance(firstTrusteeAccount);
|
|
||||||
await crowdFund.requestMilestonePayout(0, { from: firstTrusteeAccount });
|
|
||||||
await increaseTime(AFTER_VOTING_EXPIRES);
|
|
||||||
await crowdFund.payMilestonePayout(0);
|
|
||||||
const finalBalance = await web3.eth.getBalance(firstTrusteeAccount);
|
|
||||||
assert.ok(finalBalance.greaterThan(initBalance));
|
|
||||||
});
|
|
||||||
|
|
||||||
it("does not pay milestone when vote deadline has not passed", async () => {
|
|
||||||
await crowdFund.contribute({ from: thirdAccount, value: raiseGoal });
|
|
||||||
await crowdFund.requestMilestonePayout(0, { from: firstTrusteeAccount });
|
|
||||||
assertRevert(
|
|
||||||
crowdFund.payMilestonePayout(0, { from: firstTrusteeAccount })
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("does not pay milestone when raise goal is not met", async () => {
|
|
||||||
await crowdFund.contribute({
|
|
||||||
from: thirdAccount,
|
|
||||||
value: raiseGoal / 10
|
|
||||||
});
|
|
||||||
assert.ok((await crowdFund.raiseGoal()).gt(await crowdFund.amountRaised()));
|
|
||||||
assertRevert(
|
|
||||||
crowdFund.requestMilestonePayout(0, { from: firstTrusteeAccount })
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("does not pay milestone when majority is voting no on a milestone", async () => {
|
|
||||||
await crowdFund.contribute({ from: thirdAccount, value: raiseGoal });
|
|
||||||
await crowdFund.requestMilestonePayout(0, { from: firstTrusteeAccount });
|
|
||||||
await crowdFund.voteMilestonePayout(0, true, { from: thirdAccount });
|
|
||||||
await increaseTime(AFTER_VOTING_EXPIRES);
|
|
||||||
assertRevert(crowdFund.payMilestonePayout(0));
|
|
||||||
});
|
|
||||||
|
|
||||||
// [END] payMilestonePayout
|
|
||||||
// [BEGIN] voteRefund
|
|
||||||
|
|
||||||
it("keeps track of refund vote amount", async () => {
|
|
||||||
const tenthOfRaiseGoal = raiseGoal / 10;
|
|
||||||
await crowdFund.contribute({ from: thirdAccount, value: tenthOfRaiseGoal });
|
|
||||||
await crowdFund.contribute({
|
|
||||||
from: firstAccount,
|
|
||||||
value: tenthOfRaiseGoal * 9
|
|
||||||
});
|
|
||||||
assert.ok(await crowdFund.isRaiseGoalReached());
|
|
||||||
await crowdFund.voteRefund(true, { from: thirdAccount });
|
|
||||||
await crowdFund.voteRefund(true, { from: firstAccount });
|
|
||||||
assert.equal(
|
|
||||||
(await crowdFund.amountVotingForRefund()).toNumber(),
|
|
||||||
tenthOfRaiseGoal * 9 + tenthOfRaiseGoal
|
|
||||||
);
|
|
||||||
await crowdFund.voteRefund(false, { from: firstAccount });
|
|
||||||
assert.equal(
|
|
||||||
(await crowdFund.amountVotingForRefund()).toNumber(),
|
|
||||||
tenthOfRaiseGoal
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("does not allow non-contributors to vote", async () => {
|
|
||||||
const tenthOfRaiseGoal = raiseGoal / 10;
|
|
||||||
await crowdFund.contribute({ from: thirdAccount, value: tenthOfRaiseGoal });
|
|
||||||
await crowdFund.contribute({
|
|
||||||
from: firstAccount,
|
|
||||||
value: tenthOfRaiseGoal * 9
|
|
||||||
});
|
|
||||||
assert.ok(await crowdFund.isRaiseGoalReached());
|
|
||||||
assertRevert(crowdFund.voteRefund(true, { from: firstTrusteeAccount }));
|
|
||||||
});
|
|
||||||
|
|
||||||
it("only allows contributors to vote after raise goal has been reached", async () => {
|
|
||||||
const tenthOfRaiseGoal = raiseGoal / 10;
|
|
||||||
await crowdFund.contribute({
|
|
||||||
from: fourthAccount,
|
|
||||||
value: tenthOfRaiseGoal
|
|
||||||
});
|
|
||||||
assert.ok(!(await crowdFund.isRaiseGoalReached()));
|
|
||||||
assertRevert(crowdFund.voteRefund(true, { from: fourthAccount }));
|
|
||||||
await crowdFund.contribute({
|
|
||||||
from: firstAccount,
|
|
||||||
value: tenthOfRaiseGoal * 9
|
|
||||||
});
|
|
||||||
assert.ok(await crowdFund.isRaiseGoalReached());
|
|
||||||
assert.ok(await crowdFund.voteRefund(true, { from: fourthAccount }));
|
|
||||||
});
|
|
||||||
|
|
||||||
it("only adds refund voter amount once", async () => {
|
|
||||||
const tenthOfRaiseGoal = raiseGoal / 10;
|
|
||||||
await crowdFund.contribute({ from: thirdAccount, value: tenthOfRaiseGoal });
|
|
||||||
await crowdFund.contribute({
|
|
||||||
from: firstAccount,
|
|
||||||
value: tenthOfRaiseGoal * 9
|
|
||||||
});
|
|
||||||
assert.ok(await crowdFund.isRaiseGoalReached());
|
|
||||||
await crowdFund.voteRefund(true, { from: thirdAccount });
|
|
||||||
assert.equal(
|
|
||||||
(await crowdFund.amountVotingForRefund()).toNumber(),
|
|
||||||
tenthOfRaiseGoal
|
|
||||||
);
|
|
||||||
await crowdFund.voteRefund(false, { from: thirdAccount });
|
|
||||||
assert.equal((await crowdFund.amountVotingForRefund()).toNumber(), 0);
|
|
||||||
await crowdFund.voteRefund(true, { from: thirdAccount });
|
|
||||||
assert.equal(
|
|
||||||
(await crowdFund.amountVotingForRefund()).toNumber(),
|
|
||||||
tenthOfRaiseGoal
|
|
||||||
);
|
|
||||||
assertVMException(crowdFund.voteRefund(true, { from: thirdAccount }));
|
|
||||||
});
|
|
||||||
|
|
||||||
// [END] voteRefund
|
|
||||||
// [BEGIN] refund
|
|
||||||
|
|
||||||
it("does not allow non-trustees to refund", async () => {
|
|
||||||
await crowdFund.contribute({
|
|
||||||
from: fourthAccount,
|
|
||||||
value: raiseGoal / 5
|
|
||||||
});
|
|
||||||
|
|
||||||
assert.ok(!(await crowdFund.isRaiseGoalReached()));
|
|
||||||
assertRevert(crowdFund.refund());
|
|
||||||
});
|
|
||||||
|
|
||||||
it("allows trustee to refund while the CrowdFund is on-going and sets reason to 0", async () => {
|
|
||||||
await crowdFund.contribute({
|
|
||||||
from: fourthAccount,
|
|
||||||
value: raiseGoal / 5
|
|
||||||
});
|
|
||||||
assert.ok(!(await crowdFund.isRaiseGoalReached()));
|
|
||||||
const balanceAfterFundingFourthAccount = await web3.eth.getBalance(
|
|
||||||
fourthAccount
|
|
||||||
);
|
|
||||||
await crowdFund.refund({ from: firstTrusteeAccount });
|
|
||||||
assert.equal((await crowdFund.getFreezeReason()), 0);
|
|
||||||
await crowdFund.withdraw(fourthAccount);
|
|
||||||
const balanceAfterRefundFourthAccount = await web3.eth.getBalance(
|
|
||||||
fourthAccount
|
|
||||||
);
|
|
||||||
assert.ok(
|
|
||||||
balanceAfterRefundFourthAccount.gt(balanceAfterFundingFourthAccount)
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("allows trustee to refund after the CrowdFund has finished", async () => {
|
|
||||||
await crowdFund.contribute({
|
|
||||||
from: fourthAccount,
|
|
||||||
value: raiseGoal
|
|
||||||
});
|
|
||||||
|
|
||||||
assert.ok(await crowdFund.isRaiseGoalReached());
|
|
||||||
const balanceAfterFundingFourthAccount = await web3.eth.getBalance(
|
|
||||||
fourthAccount
|
|
||||||
);
|
|
||||||
await crowdFund.refund({ from: firstTrusteeAccount });
|
|
||||||
await crowdFund.withdraw(fourthAccount);
|
|
||||||
const balanceAfterRefundFourthAccount = await web3.eth.getBalance(
|
|
||||||
fourthAccount
|
|
||||||
);
|
|
||||||
assert.ok(
|
|
||||||
balanceAfterRefundFourthAccount.gt(balanceAfterFundingFourthAccount)
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("reverts if non-trustee attempts to refund on active CrowdFund", async () => {
|
|
||||||
const tenthOfRaiseGoal = raiseGoal / 10;
|
|
||||||
await crowdFund.contribute({
|
|
||||||
from: fourthAccount,
|
|
||||||
value: tenthOfRaiseGoal
|
|
||||||
});
|
|
||||||
assertRevert(crowdFund.refund());
|
|
||||||
});
|
|
||||||
|
|
||||||
it("reverts if non-trustee attempts to refund a successful CrowdFund without a majority voting to refund", async () => {
|
|
||||||
const tenthOfRaiseGoal = raiseGoal / 10;
|
|
||||||
await crowdFund.contribute({
|
|
||||||
from: fourthAccount,
|
|
||||||
value: tenthOfRaiseGoal * 2
|
|
||||||
});
|
|
||||||
await crowdFund.contribute({
|
|
||||||
from: thirdAccount,
|
|
||||||
value: tenthOfRaiseGoal * 8
|
|
||||||
});
|
|
||||||
assert.ok(await crowdFund.isRaiseGoalReached());
|
|
||||||
assertRevert(crowdFund.refund());
|
|
||||||
});
|
|
||||||
|
|
||||||
it("refunds proportionally if majority is voting for refund after raise goal has been reached and sets reason to 2", async () => {
|
|
||||||
const tenthOfRaiseGoal = raiseGoal / 10;
|
|
||||||
await crowdFund.contribute({
|
|
||||||
from: fourthAccount,
|
|
||||||
value: tenthOfRaiseGoal * 2
|
|
||||||
});
|
|
||||||
await crowdFund.contribute({
|
|
||||||
from: thirdAccount,
|
|
||||||
value: tenthOfRaiseGoal * 8
|
|
||||||
});
|
|
||||||
const initBalanceFourthAccount = await web3.eth.getBalance(fourthAccount);
|
|
||||||
const initBalanceThirdAccount = await web3.eth.getBalance(thirdAccount);
|
|
||||||
assert.ok(await crowdFund.isRaiseGoalReached());
|
|
||||||
const afterContributionBalanceFourthAccount = await web3.eth.getBalance(
|
|
||||||
fourthAccount
|
|
||||||
);
|
|
||||||
const afterContributionBalanceThirdAccount = await web3.eth.getBalance(
|
|
||||||
thirdAccount
|
|
||||||
);
|
|
||||||
// fourthAccount contributed a tenth of the raise goal, compared to third account with a fourth
|
|
||||||
assert.ok(
|
|
||||||
afterContributionBalanceFourthAccount.gt(
|
|
||||||
afterContributionBalanceThirdAccount
|
|
||||||
)
|
|
||||||
);
|
|
||||||
await crowdFund.voteRefund(true, { from: thirdAccount });
|
|
||||||
await crowdFund.refund();
|
|
||||||
assert.equal((await crowdFund.getFreezeReason()), 2)
|
|
||||||
await crowdFund.withdraw(fourthAccount);
|
|
||||||
await crowdFund.withdraw(thirdAccount);
|
|
||||||
const finalBalanceFourthAccount = await web3.eth.getBalance(fourthAccount);
|
|
||||||
const finalBalanceThirdAccount = await web3.eth.getBalance(thirdAccount);
|
|
||||||
assert.ok(finalBalanceFourthAccount.gt(initBalanceFourthAccount));
|
|
||||||
assert.ok(finalBalanceThirdAccount.gt(initBalanceThirdAccount));
|
|
||||||
});
|
|
||||||
|
|
||||||
it("refunds full amounts even if raise goal isn't reached", async () => {
|
|
||||||
const initialBalance = await web3.eth.getBalance(fourthAccount);
|
|
||||||
const contribution = raiseGoal / 2;
|
|
||||||
const receipt = await crowdFund.contribute({
|
|
||||||
from: fourthAccount,
|
|
||||||
value: contribution,
|
|
||||||
gasPrice: 0,
|
|
||||||
});
|
|
||||||
await crowdFund.refund({ from: firstTrusteeAccount });
|
|
||||||
await crowdFund.withdraw(fourthAccount);
|
|
||||||
const balance = await web3.eth.getBalance(fourthAccount);
|
|
||||||
const diff = initialBalance.minus(balance);
|
|
||||||
assert(
|
|
||||||
balance.equals(initialBalance),
|
|
||||||
`Expected full refund, but refund was short ${diff.toString()} wei`
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
// [END] refund
|
|
||||||
// [BEGIN] getContributorMilestoneVote
|
|
||||||
|
|
||||||
it("returns milestone vote for a contributor", async () => {
|
|
||||||
await crowdFund.contribute({ from: thirdAccount, value: raiseGoal });
|
|
||||||
await crowdFund.requestMilestonePayout(0, { from: firstTrusteeAccount });
|
|
||||||
await crowdFund.voteMilestonePayout(0, true, { from: thirdAccount });
|
|
||||||
await increaseTime(AFTER_VOTING_EXPIRES);
|
|
||||||
const milestoneVote = await crowdFund.getContributorMilestoneVote.call(thirdAccount, 0);
|
|
||||||
assert.equal(true, milestoneVote)
|
|
||||||
});
|
|
||||||
|
|
||||||
|
|
||||||
// [END] getContributorMilestoneVote
|
|
||||||
|
|
||||||
// [BEGIN] getContributorContributionAmount
|
|
||||||
|
|
||||||
it("returns amount a contributor has contributed", async () => {
|
|
||||||
const constributionAmount = raiseGoal / 5
|
|
||||||
await crowdFund.contribute({ from: thirdAccount, value: constributionAmount });
|
|
||||||
const contractContributionAmount = await crowdFund.getContributorContributionAmount(thirdAccount)
|
|
||||||
assert.equal(contractContributionAmount.toNumber(), constributionAmount)
|
|
||||||
});
|
|
||||||
|
|
||||||
});
|
|
|
@ -1,26 +0,0 @@
|
||||||
const Forward = artifacts.require("Forward");
|
|
||||||
|
|
||||||
contract("Forward", accounts => {
|
|
||||||
const [creatorAccount, destinationAddress] = accounts;
|
|
||||||
const amount = 1;
|
|
||||||
|
|
||||||
let forward;
|
|
||||||
|
|
||||||
beforeEach(async () => {
|
|
||||||
forward = await Forward.new(destinationAddress, { from: creatorAccount });
|
|
||||||
});
|
|
||||||
|
|
||||||
it("deposits", async () => {
|
|
||||||
await forward.sendTransaction({ from: creatorAccount, value: amount });
|
|
||||||
const forwardBalance = await web3.eth.getBalance(forward.address);
|
|
||||||
assert.equal(forwardBalance.toNumber(), amount);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("forwards", async () => {
|
|
||||||
const initBalance = await web3.eth.getBalance(destinationAddress);
|
|
||||||
await forward.sendTransaction({ from: creatorAccount, value: amount });
|
|
||||||
await forward.payOut({ from: creatorAccount });
|
|
||||||
const finalBalance = await web3.eth.getBalance(destinationAddress);
|
|
||||||
assert.ok(finalBalance.gt(initBalance));
|
|
||||||
});
|
|
||||||
});
|
|
|
@ -1,235 +0,0 @@
|
||||||
// test/CrowdFundTest.js
|
|
||||||
// References https://michalzalecki.com/ethereum-test-driven-introduction-to-solidity/
|
|
||||||
const PrivateFund = artifacts.require("PrivateFund");
|
|
||||||
const { assertRevert } = require("./utils");
|
|
||||||
|
|
||||||
const ETHER = 10 ** 18;
|
|
||||||
|
|
||||||
contract("PrivateFund", accounts => {
|
|
||||||
const [
|
|
||||||
funderAccount,
|
|
||||||
firstTrusteeAccount,
|
|
||||||
refundAccount,
|
|
||||||
boardOne,
|
|
||||||
boardTwo,
|
|
||||||
boardThree
|
|
||||||
] = accounts;
|
|
||||||
const raiseGoal = 1 * ETHER;
|
|
||||||
const halfRaiseGoal = raiseGoal / 2;
|
|
||||||
const beneficiary = firstTrusteeAccount;
|
|
||||||
// TODO - set multiple trustees and add tests
|
|
||||||
const trustees = [beneficiary];
|
|
||||||
const quorum = 2;
|
|
||||||
const boardMembers = [boardOne, boardTwo, boardThree];
|
|
||||||
const milestones = [halfRaiseGoal, halfRaiseGoal];
|
|
||||||
const funder = funderAccount;
|
|
||||||
const useQuroumForRefund = false;
|
|
||||||
|
|
||||||
let privateFund;
|
|
||||||
|
|
||||||
beforeEach(async () => {
|
|
||||||
privateFund = await PrivateFund.new(
|
|
||||||
raiseGoal,
|
|
||||||
beneficiary,
|
|
||||||
trustees,
|
|
||||||
quorum,
|
|
||||||
boardMembers,
|
|
||||||
milestones,
|
|
||||||
funder,
|
|
||||||
useQuroumForRefund,
|
|
||||||
{ from: funderAccount }
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
// [BEGIN] constructor
|
|
||||||
// TODO - test all initial variables have expected values
|
|
||||||
it("initializes", async () => {
|
|
||||||
assert.equal(await privateFund.raiseGoal.call(), raiseGoal);
|
|
||||||
assert.equal(await privateFund.beneficiary.call(), beneficiary);
|
|
||||||
|
|
||||||
trustees.forEach(async (address, i) => {
|
|
||||||
assert.equal(await privateFund.trustees.call(i), trustees[i]);
|
|
||||||
});
|
|
||||||
|
|
||||||
// TODO - get working
|
|
||||||
// milestones.forEach(async (milestoneAmount, i) => {
|
|
||||||
// console.log(i)
|
|
||||||
// assert.equal(await privateFund.milestones(i)[0], milestoneAmount);
|
|
||||||
// });
|
|
||||||
});
|
|
||||||
|
|
||||||
// [END] constructor
|
|
||||||
// [BEGIN] contribute
|
|
||||||
|
|
||||||
it("revert on next contribution once raise goal is reached", async () => {
|
|
||||||
await privateFund.contribute({
|
|
||||||
from: funderAccount,
|
|
||||||
value: raiseGoal
|
|
||||||
});
|
|
||||||
assertRevert(
|
|
||||||
privateFund.contribute({
|
|
||||||
from: funderAccount,
|
|
||||||
value: raiseGoal
|
|
||||||
})
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("revert when raiseGoal isn't paid in full", async () => {
|
|
||||||
assertRevert(
|
|
||||||
privateFund.contribute({
|
|
||||||
from: funderAccount,
|
|
||||||
value: raiseGoal / 5
|
|
||||||
})
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("amountRaised is set after contribution", async () => {
|
|
||||||
await privateFund.contribute({
|
|
||||||
from: funderAccount,
|
|
||||||
value: raiseGoal
|
|
||||||
});
|
|
||||||
assert.equal(
|
|
||||||
(await privateFund.amountRaised()).toNumber(),
|
|
||||||
(await privateFund.raiseGoal()).toNumber()
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
// [BEGIN] requestMilestonePayout
|
|
||||||
|
|
||||||
it("does not request milestone when earlier milestone is unpaid", async () => {
|
|
||||||
await privateFund.contribute({ from: funderAccount, value: raiseGoal });
|
|
||||||
assertRevert(
|
|
||||||
privateFund.requestMilestonePayout(1, { from: firstTrusteeAccount })
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("does not allow milestone request when caller is not trustee", async () => {
|
|
||||||
assertRevert(
|
|
||||||
privateFund.requestMilestonePayout(0, { from: funderAccount })
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("does not allow milestone request when milestone has already been paid", async () => {
|
|
||||||
await privateFund.contribute({ from: funderAccount, value: raiseGoal });
|
|
||||||
const initBalance = await web3.eth.getBalance(beneficiary);
|
|
||||||
await privateFund.requestMilestonePayout(0, { from: beneficiary });
|
|
||||||
await privateFund.voteMilestonePayout(0, true, { from: boardOne });
|
|
||||||
await privateFund.voteMilestonePayout(0, true, { from: boardTwo });
|
|
||||||
await privateFund.payMilestonePayout(0);
|
|
||||||
const finalBalance = await web3.eth.getBalance(beneficiary);
|
|
||||||
assert.ok(finalBalance.greaterThan(initBalance));
|
|
||||||
assertRevert(privateFund.requestMilestonePayout(0, { from: beneficiary }));
|
|
||||||
});
|
|
||||||
|
|
||||||
// [END] requestMilestonePayout
|
|
||||||
// [BEGIN] voteMilestonePayout
|
|
||||||
|
|
||||||
it("persists board member votes", async () => {
|
|
||||||
await privateFund.contribute({
|
|
||||||
from: funderAccount,
|
|
||||||
value: raiseGoal
|
|
||||||
});
|
|
||||||
|
|
||||||
await privateFund.requestMilestonePayout(0, { from: firstTrusteeAccount });
|
|
||||||
|
|
||||||
await privateFund.voteMilestonePayout(0, true, { from: boardOne });
|
|
||||||
await privateFund.voteMilestonePayout(0, true, { from: boardTwo });
|
|
||||||
assert.equal((await privateFund.getBoardMemberMilestoneVote(boardOne, 0)), true )
|
|
||||||
});
|
|
||||||
|
|
||||||
it("only allows board members to vote", async () => {
|
|
||||||
await privateFund.contribute({
|
|
||||||
from: funderAccount,
|
|
||||||
value: raiseGoal
|
|
||||||
});
|
|
||||||
|
|
||||||
await privateFund.requestMilestonePayout(0, { from: firstTrusteeAccount });
|
|
||||||
|
|
||||||
assertRevert(
|
|
||||||
privateFund.voteMilestonePayout(0, true, { from: funderAccount }) // even funders can't vote unless they are also part of the board
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("does not allow milestone voting before vote period has started", async () => {
|
|
||||||
await privateFund.contribute({
|
|
||||||
from: funderAccount,
|
|
||||||
value: raiseGoal
|
|
||||||
});
|
|
||||||
|
|
||||||
assertRevert(
|
|
||||||
privateFund.voteMilestonePayout(0, true, { from: boardThree })
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
// [END] voteMilestonePayout
|
|
||||||
// [BEGIN] payMilestonePayout
|
|
||||||
|
|
||||||
it("pays milestone when milestone is unpaid, quorum is reached, caller is trustee, and no earlier milestone is unpaid", async () => {
|
|
||||||
await privateFund.contribute({ from: funderAccount, value: raiseGoal });
|
|
||||||
const initBalance = await web3.eth.getBalance(beneficiary);
|
|
||||||
|
|
||||||
await privateFund.requestMilestonePayout(0, { from: firstTrusteeAccount });
|
|
||||||
// quorum of two needed
|
|
||||||
await privateFund.voteMilestonePayout(0, true, { from: boardOne });
|
|
||||||
await privateFund.voteMilestonePayout(0, true, { from: boardTwo });
|
|
||||||
|
|
||||||
await privateFund.payMilestonePayout(0);
|
|
||||||
const finalBalance = await web3.eth.getBalance(firstTrusteeAccount);
|
|
||||||
assert.ok(finalBalance.greaterThan(initBalance));
|
|
||||||
});
|
|
||||||
|
|
||||||
it("does not pay milestone when raise goal is not met", async () => {
|
|
||||||
assert.ok(
|
|
||||||
(await privateFund.raiseGoal()).gt(await privateFund.amountRaised())
|
|
||||||
);
|
|
||||||
assertRevert(
|
|
||||||
privateFund.requestMilestonePayout(0, { from: firstTrusteeAccount })
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("does not pay milestone when quorum is not reached", async () => {
|
|
||||||
await privateFund.contribute({ from: funderAccount, value: raiseGoal });
|
|
||||||
await privateFund.requestMilestonePayout(0, { from: firstTrusteeAccount });
|
|
||||||
// only one vote in favor
|
|
||||||
await privateFund.voteMilestonePayout(0, true, { from: boardOne });
|
|
||||||
assertRevert(privateFund.payMilestonePayout(0));
|
|
||||||
});
|
|
||||||
|
|
||||||
// [END] payMilestonePayout
|
|
||||||
// [BEGIN] voteRefundAddress
|
|
||||||
|
|
||||||
it("keeps track of refund vote address choices", async () => {
|
|
||||||
await privateFund.contribute({ from: funderAccount, value: raiseGoal });
|
|
||||||
await privateFund.voteRefundAddress(refundAccount, { from: boardOne });
|
|
||||||
await privateFund.voteRefundAddress(refundAccount, { from: boardTwo });
|
|
||||||
assert.equal((await privateFund.boardMembers(boardOne))[0], refundAccount);
|
|
||||||
assert.equal((await privateFund.boardMembers(boardTwo))[0], refundAccount);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("does not allow non-contributors to vote", async () => {
|
|
||||||
await privateFund.contribute({
|
|
||||||
from: funderAccount,
|
|
||||||
value: raiseGoal
|
|
||||||
});
|
|
||||||
assertRevert(privateFund.voteRefundAddress(true, { from: funderAccount }));
|
|
||||||
});
|
|
||||||
|
|
||||||
// [BEGIN] refund
|
|
||||||
|
|
||||||
// TODO - fix up
|
|
||||||
// it("refunds to voted refund address", async () => {
|
|
||||||
// const refundBalanceInit = await web3.eth.getBalance(refundAccount);
|
|
||||||
//
|
|
||||||
// await privateFund.contribute({ from: funderAccount, value: raiseGoal });
|
|
||||||
// await privateFund.voteRefundAddress(refundAccount, { from: boardOne });
|
|
||||||
// await privateFund.voteRefundAddress(refundAccount, { from: boardTwo });
|
|
||||||
// await privateFund.voteRefundAddress(refundAccount, { from: boardThree });
|
|
||||||
// await privateFund.refund(refundAccount, { from: boardTwo });
|
|
||||||
//
|
|
||||||
// const refundBalancePostRefund = await web3.eth.getBalance(refundAccount);
|
|
||||||
//
|
|
||||||
// assert.ok(refundBalancePostRefund.gt(refundBalanceInit));
|
|
||||||
// });
|
|
||||||
//
|
|
||||||
// [END] refund
|
|
||||||
});
|
|
|
@ -1,65 +0,0 @@
|
||||||
// source: https://github.com/OpenZeppelin/zeppelin-solidity/blob/master/test/helpers/increaseTime.js
|
|
||||||
|
|
||||||
const should = require("chai").should();
|
|
||||||
|
|
||||||
async function assertRevert(promise) {
|
|
||||||
try {
|
|
||||||
await promise;
|
|
||||||
} catch (error) {
|
|
||||||
error.message.should.include(
|
|
||||||
"revert",
|
|
||||||
`Expected "revert", got ${error} instead`
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
should.fail("Expected revert not received");
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
async function assertVMException(promise) {
|
|
||||||
try {
|
|
||||||
await promise;
|
|
||||||
} catch (error) {
|
|
||||||
error.message.should.include(
|
|
||||||
"VM Exception",
|
|
||||||
`Expected "VM Exception", got ${error} instead`
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
should.fail("Expected VM Exception not received");
|
|
||||||
}
|
|
||||||
|
|
||||||
async function increaseTime(duration) {
|
|
||||||
const id = Date.now();
|
|
||||||
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
web3.currentProvider.sendAsync(
|
|
||||||
{
|
|
||||||
jsonrpc: "2.0",
|
|
||||||
method: "evm_increaseTime",
|
|
||||||
params: [duration],
|
|
||||||
id: id
|
|
||||||
},
|
|
||||||
err1 => {
|
|
||||||
if (err1) return reject(err1);
|
|
||||||
|
|
||||||
web3.currentProvider.sendAsync(
|
|
||||||
{
|
|
||||||
jsonrpc: "2.0",
|
|
||||||
method: "evm_mine",
|
|
||||||
id: id + 1
|
|
||||||
},
|
|
||||||
(err2, res) => {
|
|
||||||
return err2 ? reject(err2) : resolve(res);
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = {
|
|
||||||
assertRevert,
|
|
||||||
increaseTime,
|
|
||||||
assertVMException
|
|
||||||
};
|
|
|
@ -1,108 +0,0 @@
|
||||||
/**
|
|
||||||
* Use this file to configure your truffle project. It's seeded with some
|
|
||||||
* common settings for different networks and features like migrations,
|
|
||||||
* compilation and testing. Uncomment the ones you need or modify
|
|
||||||
* them to suit your project as necessary.
|
|
||||||
*
|
|
||||||
* More information about configuration can be found at:
|
|
||||||
*
|
|
||||||
* truffleframework.com/docs/advanced/configuration
|
|
||||||
*
|
|
||||||
* To deploy via Infura you'll need a wallet provider (like truffle-hdwallet-provider)
|
|
||||||
* to sign your transactions before they're sent to a remote public node. Infura API
|
|
||||||
* keys are available for free at: infura.io/register
|
|
||||||
*
|
|
||||||
* > > Using Truffle V5 or later? Make sure you install the `web3-one` version.
|
|
||||||
*
|
|
||||||
* > > $ npm install truffle-hdwallet-provider@web3-one
|
|
||||||
*
|
|
||||||
* You'll also need a mnemonic - the twelve word phrase the wallet uses to generate
|
|
||||||
* public/private key pairs. If you're publishing your code to GitHub make sure you load this
|
|
||||||
* phrase from a file you've .gitignored so it doesn't accidentally become public.
|
|
||||||
*
|
|
||||||
*/
|
|
||||||
|
|
||||||
const HDWallet = require('truffle-hdwallet-provider');
|
|
||||||
const infuraKey = process.env.INFURA_KEY;
|
|
||||||
//
|
|
||||||
// const fs = require('fs');
|
|
||||||
const mnemonic = process.env.MNEMONIC;
|
|
||||||
|
|
||||||
module.exports = {
|
|
||||||
/**
|
|
||||||
* Networks define how you connect to your ethereum client and let you set the
|
|
||||||
* defaults web3 uses to send transactions. If you don't specify one truffle
|
|
||||||
* will spin up a development blockchain for you on port 9545 when you
|
|
||||||
* run `develop` or `test`. You can ask a truffle command to use a specific
|
|
||||||
* network from the command line, e.g
|
|
||||||
*
|
|
||||||
* $ truffle test --network <network-name>
|
|
||||||
*/
|
|
||||||
|
|
||||||
networks: {
|
|
||||||
// Useful for testing. The `development` name is special - truffle uses it by default
|
|
||||||
// if it's defined here and no other network is specified at the command line.
|
|
||||||
// You should run a client (like ganache-cli, geth or parity) in a separate terminal
|
|
||||||
// tab if you use this network and you must also set the `host`, `port` and `network_id`
|
|
||||||
// options below to some value.
|
|
||||||
//
|
|
||||||
development: {
|
|
||||||
host: "127.0.0.1", // Localhost (default: none)
|
|
||||||
port: 8545, // Standard Ethereum port (default: none)
|
|
||||||
network_id: "*" // Any network (default: none)
|
|
||||||
},
|
|
||||||
|
|
||||||
// Another network with more advanced options...
|
|
||||||
advanced: {
|
|
||||||
// port: 8777, // Custom port
|
|
||||||
// network_id: 1342, // Custom network
|
|
||||||
// gas: 8500000, // Gas sent with each transaction (default: ~6700000)
|
|
||||||
// gasPrice: 20000000000, // 20 gwei (in wei) (default: 100 gwei)
|
|
||||||
// from: <address>, // Account to send txs from (default: accounts[0])
|
|
||||||
// websockets: true // Enable EventEmitter interface for web3 (default: false)
|
|
||||||
},
|
|
||||||
|
|
||||||
// Useful for deploying to a public network.
|
|
||||||
// NB: It's important to wrap the provider as a function.
|
|
||||||
ropsten: {
|
|
||||||
provider: function () { return new HDWallet(mnemonic, 'https://ropsten.infura.io/' + infuraKey) },
|
|
||||||
network_id: 3, // Ropsten's id
|
|
||||||
gas: 5500000, // Ropsten has a lower block limit than mainnet
|
|
||||||
gasPrice: 20,
|
|
||||||
confirmations: 2, // # of confs to wait between deployments. (default: 0)
|
|
||||||
timeoutBlocks: 200, // # of blocks before a deployment times out (minimum/default: 50)
|
|
||||||
skipDryRun: true // Skip dry run before migrations? (default: false for public nets )
|
|
||||||
},
|
|
||||||
|
|
||||||
// Useful for private networks
|
|
||||||
private: {
|
|
||||||
// provider: () => new HDWalletProvider(mnemonic, `https://network.io`),
|
|
||||||
// network_id: 2111, // This network is yours, in the cloud.
|
|
||||||
// production: true // Treats this network as if it was a public net. (default: false)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
// Set default mocha options here, use special reporters etc.
|
|
||||||
mocha: {
|
|
||||||
reporter: "eth-gas-reporter",
|
|
||||||
reporterOptions: {
|
|
||||||
currency: "USD",
|
|
||||||
gasPrice: 21
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
// Configure your compilers
|
|
||||||
compilers: {
|
|
||||||
solc: {
|
|
||||||
// version: "0.5.1", // Fetch exact version from solc-bin (default: truffle's version)
|
|
||||||
// docker: true, // Use "0.5.1" you've installed locally with docker (default: false)
|
|
||||||
// settings: { // See the solidity docs for advice about optimization and evmVersion
|
|
||||||
// optimizer: {
|
|
||||||
// enabled: false,
|
|
||||||
// runs: 200
|
|
||||||
// },
|
|
||||||
// evmVersion: "byzantium"
|
|
||||||
// }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
2888
contract/yarn.lock
2888
contract/yarn.lock
File diff suppressed because it is too large
Load Diff
|
@ -1,6 +1,3 @@
|
||||||
# Funds these addresses when `npm run truffle` runs. Comma separated.
|
|
||||||
FUND_ETH_ADDRESSES=0x4bbeEB066eD09B7AEd07bF39EEe0460DFa261520,0xDECAF9CD2367cdbb726E904cD6397eDFcAe6068DEe0460DFa261520,0xDECAF9CD2367cdbb726E904cD6397eDFcAe6068D
|
|
||||||
|
|
||||||
# Disable typescript checking for dev building (reduce build time & resource usage)
|
# Disable typescript checking for dev building (reduce build time & resource usage)
|
||||||
NO_DEV_TS_CHECK=true
|
NO_DEV_TS_CHECK=true
|
||||||
|
|
||||||
|
@ -14,9 +11,3 @@ BACKEND_URL=http://localhost:5000
|
||||||
# sentry
|
# sentry
|
||||||
SENTRY_DSN=https://PUBLICKEY@sentry.io/PROJECTID
|
SENTRY_DSN=https://PUBLICKEY@sentry.io/PROJECTID
|
||||||
SENTRY_RELEASE="optional, overrides git hash"
|
SENTRY_RELEASE="optional, overrides git hash"
|
||||||
|
|
||||||
# CROWD_FUND_URL=https://eip-712.herokuapp.com/contract/crowd-fund
|
|
||||||
# CROWD_FUND_FACTORY_URL=https://eip-712.herokuapp.com/contract/factory
|
|
||||||
|
|
||||||
CROWD_FUND_URL=http://localhost:5000/dev-contracts/CrowdFund.json
|
|
||||||
CROWD_FUND_FACTORY_URL=http://localhost:5000/dev-contracts/CrowdFundFactory.json
|
|
|
@ -8,5 +8,4 @@ dist
|
||||||
*.log
|
*.log
|
||||||
.env
|
.env
|
||||||
*.pid
|
*.pid
|
||||||
client/lib/contracts
|
|
||||||
.vscode
|
.vscode
|
||||||
|
|
|
@ -1,37 +1,23 @@
|
||||||
# Grant.io Front-End
|
# Grant.io Front-End
|
||||||
|
|
||||||
This is the front-end component of [Grant.io](http://grant.io).
|
This is the front-end component of ZCash Grant System.
|
||||||
|
|
||||||
|
## Setup
|
||||||
|
|
||||||
|
1. Install (Nodejs 8.13)[https://nodejs.org/en/blog/release/v0.8.13/] and (yarn)[https://yarnpkg.com/en/].
|
||||||
|
|
||||||
|
1. Run `yarn` to install dependencies.
|
||||||
|
|
||||||
## Development
|
## Development
|
||||||
|
|
||||||
1. Install local project dependencies, and also install Truffle & Ganache globally:
|
1. Make sure the `backend` component is running.
|
||||||
```bash
|
|
||||||
# Local dependencies
|
|
||||||
yarn
|
|
||||||
# Global dependencies
|
|
||||||
yarn global add truffle ganache-cli
|
|
||||||
```
|
|
||||||
|
|
||||||
2. (In a separate terminal) Run the ganache development blockchain:
|
1. Run the webpack dev-server:
|
||||||
```bash
|
|
||||||
yarn run ganache
|
|
||||||
```
|
|
||||||
|
|
||||||
3. Ensure you have grant-contract cloned locally and setup.
|
|
||||||
|
|
||||||
|
|
||||||
4. (In a separate terminal) Initialize truffle, open up the repl (Changes to smart contracts will require you to re-run this):
|
|
||||||
```bash
|
|
||||||
yarn run truffle
|
|
||||||
```
|
|
||||||
|
|
||||||
5. Run the next.js server / webpack build for the front-end:
|
|
||||||
```bash
|
```bash
|
||||||
yarn run dev
|
yarn run dev
|
||||||
```
|
```
|
||||||
|
|
||||||
5. Go to the dapp on localhost:3000. You'll need to setup metamask to connect to the ganache network. You'll want to add a custom "RPC" network, and point it towards localhost:8545.
|
1. Go to the app on localhost:3000.
|
||||||
|
|
||||||
|
|
||||||
## Testing
|
## Testing
|
||||||
|
@ -40,14 +26,3 @@ This is the front-end component of [Grant.io](http://grant.io).
|
||||||
|
|
||||||
TBD
|
TBD
|
||||||
|
|
||||||
### Smart Contract
|
|
||||||
|
|
||||||
Truffle can run tests written in Solidity or JavaScript against your smart contracts. Note the command varies slightly if you're in or outside of the development console.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# If inside the truffle console
|
|
||||||
test
|
|
||||||
|
|
||||||
# If outside the truffle console
|
|
||||||
truffle test
|
|
||||||
```
|
|
||||||
|
|
|
@ -9,7 +9,6 @@ const webpackDevMiddleware = require('webpack-dev-middleware');
|
||||||
const webpackHotMiddleware = require('webpack-hot-middleware');
|
const webpackHotMiddleware = require('webpack-hot-middleware');
|
||||||
const express = require('express');
|
const express = require('express');
|
||||||
const paths = require('../config/paths');
|
const paths = require('../config/paths');
|
||||||
const truffleUtil = require('./truffle-util');
|
|
||||||
const { logMessage } = require('./utils');
|
const { logMessage } = require('./utils');
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
|
@ -22,8 +21,6 @@ const start = async () => {
|
||||||
rimraf.sync(paths.clientBuild);
|
rimraf.sync(paths.clientBuild);
|
||||||
rimraf.sync(paths.serverBuild);
|
rimraf.sync(paths.serverBuild);
|
||||||
|
|
||||||
await truffleUtil.ethereumCheck();
|
|
||||||
|
|
||||||
const [clientConfig, serverConfig] = webpackConfig;
|
const [clientConfig, serverConfig] = webpackConfig;
|
||||||
clientConfig.entry.bundle = [
|
clientConfig.entry.bundle = [
|
||||||
`webpack-hot-middleware/client?path=http://localhost:${WEBPACK_PORT}/__webpack_hmr`,
|
`webpack-hot-middleware/client?path=http://localhost:${WEBPACK_PORT}/__webpack_hmr`,
|
||||||
|
|
|
@ -1,53 +0,0 @@
|
||||||
// Initialize the truffle environment however we want, web3 is available
|
|
||||||
const rimraf = require('rimraf');
|
|
||||||
const path = require('path');
|
|
||||||
const fs = require('fs');
|
|
||||||
const childProcess = require('child_process');
|
|
||||||
|
|
||||||
require('dotenv').config({path: path.resolve(__dirname, '../.env')});
|
|
||||||
|
|
||||||
const contractsDir = path.resolve(__dirname, '../client/lib/contracts');
|
|
||||||
|
|
||||||
const CONTRACTS_REPO_BASE_PATH = path.resolve(
|
|
||||||
__dirname,
|
|
||||||
'../../contract',
|
|
||||||
);
|
|
||||||
|
|
||||||
const externalBuildContractsDir = path.join(
|
|
||||||
CONTRACTS_REPO_BASE_PATH,
|
|
||||||
'/build/contracts',
|
|
||||||
);
|
|
||||||
|
|
||||||
module.exports = function (done) {
|
|
||||||
// Remove the old contracts
|
|
||||||
rimraf.sync(contractsDir);
|
|
||||||
|
|
||||||
// Fund ETH accounts
|
|
||||||
const ethAccounts = process.env.FUND_ETH_ADDRESSES
|
|
||||||
? process.env.FUND_ETH_ADDRESSES.split(',').map(a => a.trim())
|
|
||||||
: [];
|
|
||||||
|
|
||||||
if (ethAccounts.length) {
|
|
||||||
console.info('Sending 50 ETH to the following addresses...');
|
|
||||||
ethAccounts.forEach((addr, i) => {
|
|
||||||
web3.eth.sendTransaction({
|
|
||||||
to: addr,
|
|
||||||
from: web3.eth.accounts[i],
|
|
||||||
value: web3.toWei('50', 'ether'),
|
|
||||||
});
|
|
||||||
console.info(` ${addr} <- from ${web3.eth.accounts[i]}`);
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
console.info('No accounts specified for funding in .env file...');
|
|
||||||
}
|
|
||||||
|
|
||||||
console.info('Changing working directory to: ' + process.cwd());
|
|
||||||
console.info('Compiling smart contracts...');
|
|
||||||
childProcess.execSync('yarn build', {cwd: CONTRACTS_REPO_BASE_PATH});
|
|
||||||
console.info('Running migrations...');
|
|
||||||
childProcess.execSync('truffle migrate', {cwd: CONTRACTS_REPO_BASE_PATH});
|
|
||||||
console.info('Linking contracts to client/lib/contracts...');
|
|
||||||
fs.symlinkSync(externalBuildContractsDir, contractsDir);
|
|
||||||
console.info('Truffle initialized, starting repl console!');
|
|
||||||
done();
|
|
||||||
};
|
|
|
@ -1,166 +0,0 @@
|
||||||
const rimraf = require('rimraf');
|
|
||||||
const path = require('path');
|
|
||||||
const fs = require('fs');
|
|
||||||
const childProcess = require('child_process');
|
|
||||||
const Web3 = require('web3');
|
|
||||||
|
|
||||||
const paths = require('../config/paths');
|
|
||||||
const truffleConfig = require('../truffle');
|
|
||||||
const { logMessage } = require('./utils');
|
|
||||||
|
|
||||||
require('../config/env');
|
|
||||||
|
|
||||||
module.exports = {};
|
|
||||||
|
|
||||||
const CHECK_CONTRACT_IDS = ['CrowdFundFactory.json']
|
|
||||||
|
|
||||||
const clean = (module.exports.clean = () => {
|
|
||||||
rimraf.sync(paths.contractsBuild);
|
|
||||||
});
|
|
||||||
|
|
||||||
const compile = (module.exports.compile = () => {
|
|
||||||
logMessage('truffle compile, please wait...', 'info');
|
|
||||||
try {
|
|
||||||
childProcess.execSync('yarn build', { cwd: paths.contractsBase });
|
|
||||||
} catch (e) {
|
|
||||||
logMessage(e.stdout.toString('utf8'), 'error');
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const migrate = (module.exports.migrate = () => {
|
|
||||||
logMessage('truffle migrate, please wait...', 'info');
|
|
||||||
try {
|
|
||||||
childProcess.execSync('truffle migrate', { cwd: paths.contractsBase });
|
|
||||||
} catch (e) {
|
|
||||||
logMessage(e.stdout.toString('utf8'), 'error');
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const makeWeb3Conn = () => {
|
|
||||||
const { host, port } = truffleConfig.networks.development;
|
|
||||||
return `ws://${host}:${port}`;
|
|
||||||
};
|
|
||||||
|
|
||||||
const createWeb3 = () => {
|
|
||||||
return new Web3(makeWeb3Conn());
|
|
||||||
};
|
|
||||||
|
|
||||||
const isGanacheUp = (module.exports.isGanacheUp = verbose =>
|
|
||||||
new Promise((res, rej) => {
|
|
||||||
verbose && logMessage(`Testing ganache @ ${makeWeb3Conn()}...`, 'info');
|
|
||||||
// console.log('curProv', web3.eth.currentProvider);
|
|
||||||
const web3 = createWeb3();
|
|
||||||
web3.eth.net
|
|
||||||
.isListening()
|
|
||||||
.then(() => {
|
|
||||||
verbose && logMessage('Ganache is UP!', 'info');
|
|
||||||
res(true);
|
|
||||||
web3.currentProvider.connection.close();
|
|
||||||
})
|
|
||||||
.catch(e => {
|
|
||||||
logMessage('Ganache appears to be down, unable to connect.', 'error');
|
|
||||||
res(false);
|
|
||||||
});
|
|
||||||
}));
|
|
||||||
|
|
||||||
const getGanacheNetworkId = (module.exports.getGanacheNetworkId = () => {
|
|
||||||
const web3 = createWeb3();
|
|
||||||
return web3.eth.net
|
|
||||||
.getId()
|
|
||||||
.then(id => {
|
|
||||||
web3.currentProvider.connection.close();
|
|
||||||
return id;
|
|
||||||
})
|
|
||||||
.catch(() => -1);
|
|
||||||
});
|
|
||||||
|
|
||||||
const checkContractsNetworkIds = (id) =>
|
|
||||||
new Promise((res, rej) => {
|
|
||||||
const buildDir = paths.contractsBuild;
|
|
||||||
fs.readdir(buildDir, (err) => {
|
|
||||||
if (err) {
|
|
||||||
logMessage(`No contracts build directory @ ${buildDir}`, 'error');
|
|
||||||
res(false);
|
|
||||||
} else {
|
|
||||||
const allHaveId = CHECK_CONTRACT_IDS.reduce((ok, name) => {
|
|
||||||
const contractPath = path.join(buildDir, name);
|
|
||||||
if (!fs.existsSync(contractPath)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
const contract = require(contractPath);
|
|
||||||
const contractHasKeys = Object.keys(contract.networks).length > 0;
|
|
||||||
if (!contractHasKeys) {
|
|
||||||
logMessage('Contract does not contain network keys.', 'error');
|
|
||||||
return false;
|
|
||||||
} else {
|
|
||||||
if (contractHasKeys && !contract.networks[id]) {
|
|
||||||
const actual = Object.keys(contract.networks).join(', ');
|
|
||||||
logMessage(
|
|
||||||
`${name} should have networks[${id}], it has ${actual}`,
|
|
||||||
'error',
|
|
||||||
);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return true && ok;
|
|
||||||
}, true);
|
|
||||||
res(allHaveId);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
module.exports.checkContractsNetworkIds = checkContractsNetworkIds;
|
|
||||||
|
|
||||||
const fundWeb3v1 = (module.exports.fundWeb3v1 = () => {
|
|
||||||
// Fund ETH accounts
|
|
||||||
const ethAccounts = process.env.FUND_ETH_ADDRESSES
|
|
||||||
? process.env.FUND_ETH_ADDRESSES.split(',').map(a => a.trim())
|
|
||||||
: [];
|
|
||||||
const web3 = createWeb3();
|
|
||||||
return web3.eth.getAccounts().then(accts => {
|
|
||||||
if (ethAccounts.length) {
|
|
||||||
logMessage('Sending 50% of ETH balance from accounts...', 'info');
|
|
||||||
const txs = ethAccounts.map((addr, i) => {
|
|
||||||
return web3.eth
|
|
||||||
.getBalance(accts[i])
|
|
||||||
.then(parseInt)
|
|
||||||
.then(bal => {
|
|
||||||
const amount = '' + Math.round(bal / 2);
|
|
||||||
const amountEth = web3.utils.fromWei(amount);
|
|
||||||
return web3.eth
|
|
||||||
.sendTransaction({
|
|
||||||
to: addr,
|
|
||||||
from: accts[i],
|
|
||||||
value: amount,
|
|
||||||
})
|
|
||||||
.then(() => logMessage(` ${addr} <- ${amountEth} from ${accts[i]}`))
|
|
||||||
.catch(e =>
|
|
||||||
logMessage(` Error sending funds to ${addr} : ${e}`, 'error'),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
return Promise.all(txs).then(() => web3.currentProvider.connection.close());
|
|
||||||
} else {
|
|
||||||
logMessage('No accounts specified for funding in .env file...', 'warning');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
module.exports.ethereumCheck = () =>
|
|
||||||
isGanacheUp(true)
|
|
||||||
.then(isUp => !isUp && Promise.reject('network down'))
|
|
||||||
.then(getGanacheNetworkId)
|
|
||||||
.then(checkContractsNetworkIds)
|
|
||||||
.then(allHaveId => {
|
|
||||||
if (!allHaveId) {
|
|
||||||
logMessage('Contract problems, will compile & migrate.', 'warning');
|
|
||||||
clean();
|
|
||||||
compile();
|
|
||||||
migrate();
|
|
||||||
fundWeb3v1();
|
|
||||||
} else {
|
|
||||||
logMessage('OK, Contracts have correct network id.', 'info');
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.catch(e => logMessage('WARNING: ethereum setup has a problem: ' + e, 'error'));
|
|
|
@ -34,7 +34,7 @@ import 'styles/style.less';
|
||||||
interface RouteConfig extends RouteProps {
|
interface RouteConfig extends RouteProps {
|
||||||
route: RouteProps;
|
route: RouteProps;
|
||||||
template: TemplateProps;
|
template: TemplateProps;
|
||||||
requiresWeb3?: boolean;
|
requiresAuth?: boolean;
|
||||||
onlyLoggedIn?: boolean;
|
onlyLoggedIn?: boolean;
|
||||||
onlyLoggedOut?: boolean;
|
onlyLoggedOut?: boolean;
|
||||||
}
|
}
|
||||||
|
@ -61,7 +61,7 @@ const routeConfigs: RouteConfig[] = [
|
||||||
},
|
},
|
||||||
template: {
|
template: {
|
||||||
title: 'Create a Proposal',
|
title: 'Create a Proposal',
|
||||||
requiresWeb3: true,
|
requiresAuth: true,
|
||||||
},
|
},
|
||||||
onlyLoggedIn: true,
|
onlyLoggedIn: true,
|
||||||
},
|
},
|
||||||
|
@ -74,7 +74,7 @@ const routeConfigs: RouteConfig[] = [
|
||||||
},
|
},
|
||||||
template: {
|
template: {
|
||||||
title: 'Browse proposals',
|
title: 'Browse proposals',
|
||||||
requiresWeb3: false,
|
requiresAuth: false,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
@ -87,7 +87,7 @@ const routeConfigs: RouteConfig[] = [
|
||||||
title: 'Edit proposal',
|
title: 'Edit proposal',
|
||||||
isFullScreen: true,
|
isFullScreen: true,
|
||||||
hideFooter: true,
|
hideFooter: true,
|
||||||
requiresWeb3: true,
|
requiresAuth: true,
|
||||||
},
|
},
|
||||||
onlyLoggedIn: true,
|
onlyLoggedIn: true,
|
||||||
},
|
},
|
||||||
|
@ -99,7 +99,7 @@ const routeConfigs: RouteConfig[] = [
|
||||||
},
|
},
|
||||||
template: {
|
template: {
|
||||||
title: 'Proposal',
|
title: 'Proposal',
|
||||||
requiresWeb3: false,
|
requiresAuth: false,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
|
@ -55,27 +55,42 @@ export function getUser(address: string): Promise<{ data: User }> {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createUser(payload: {
|
export function createUser(user: {
|
||||||
accountAddress: string;
|
email: string;
|
||||||
emailAddress: string;
|
password: string;
|
||||||
displayName: string;
|
name: string;
|
||||||
title: string;
|
title: string;
|
||||||
signedMessage: string;
|
|
||||||
rawTypedData: string;
|
|
||||||
}): Promise<{ data: User }> {
|
}): Promise<{ data: User }> {
|
||||||
|
const payload = {
|
||||||
|
emailAddress: user.email,
|
||||||
|
password: user.password,
|
||||||
|
displayName: user.name,
|
||||||
|
title: user.title,
|
||||||
|
};
|
||||||
return axios.post('/api/v1/users', payload);
|
return axios.post('/api/v1/users', payload);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function authUser(payload: {
|
export function authUser(payload: {
|
||||||
accountAddress: string;
|
email: string;
|
||||||
signedMessage: string;
|
password: string;
|
||||||
rawTypedData: string;
|
|
||||||
}): Promise<{ data: User }> {
|
}): Promise<{ data: User }> {
|
||||||
return axios.post('/api/v1/users/auth', payload);
|
return axios.post('/api/v1/users/auth', payload);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function logoutUser() {
|
||||||
|
return axios.post('/api/v1/users/logout');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function checkUserAuth(): Promise<{ data: User }> {
|
||||||
|
return axios.get(`/api/v1/users/me`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateUserPassword(currentPassword: string, password: string) {
|
||||||
|
return axios.put(`/api/v1/users/password`, { currentPassword, password });
|
||||||
|
}
|
||||||
|
|
||||||
export function updateUser(user: User): Promise<{ data: User }> {
|
export function updateUser(user: User): Promise<{ data: User }> {
|
||||||
return axios.put(`/api/v1/users/${user.accountAddress}`, formatUserForPost(user));
|
return axios.put(`/api/v1/users/${user.userid}`, formatUserForPost(user));
|
||||||
}
|
}
|
||||||
|
|
||||||
export function verifyEmail(code: string): Promise<any> {
|
export function verifyEmail(code: string): Promise<any> {
|
||||||
|
|
|
@ -3,6 +3,8 @@ import axios from 'axios';
|
||||||
const instance = axios.create({
|
const instance = axios.create({
|
||||||
baseURL: process.env.BACKEND_URL,
|
baseURL: process.env.BACKEND_URL,
|
||||||
headers: {},
|
headers: {},
|
||||||
|
// for session cookies
|
||||||
|
withCredentials: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
instance.interceptors.response.use(
|
instance.interceptors.response.use(
|
||||||
|
|
|
@ -3,7 +3,7 @@ import classnames from 'classnames';
|
||||||
import { Form, Input } from 'antd';
|
import { Form, Input } from 'antd';
|
||||||
import { InputProps } from 'antd/lib/input';
|
import { InputProps } from 'antd/lib/input';
|
||||||
import { FormItemProps } from 'antd/lib/form';
|
import { FormItemProps } from 'antd/lib/form';
|
||||||
import { isValidEthAddress } from 'utils/validators';
|
import { isValidAddress } from 'utils/validators';
|
||||||
import Identicon from 'components/Identicon';
|
import Identicon from 'components/Identicon';
|
||||||
import { DONATION } from 'utils/constants';
|
import { DONATION } from 'utils/constants';
|
||||||
import './AddressInput.less';
|
import './AddressInput.less';
|
||||||
|
@ -22,7 +22,7 @@ export default class AddressInput extends React.Component<Props> {
|
||||||
const { value, onChange, className, showIdenticon } = this.props;
|
const { value, onChange, className, showIdenticon } = this.props;
|
||||||
const passedFormItemProps = this.props.formItemProps || {};
|
const passedFormItemProps = this.props.formItemProps || {};
|
||||||
const passedInputProps = this.props.inputProps || {};
|
const passedInputProps = this.props.inputProps || {};
|
||||||
const isInvalid = value && !isValidEthAddress(value);
|
const isInvalid = value && !isValidAddress(value);
|
||||||
|
|
||||||
const formItemProps = {
|
const formItemProps = {
|
||||||
validateStatus: (isInvalid
|
validateStatus: (isInvalid
|
||||||
|
|
|
@ -1,14 +0,0 @@
|
||||||
.ProvideIdentity {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
width: 100%;
|
|
||||||
margin: 0 auto;
|
|
||||||
|
|
||||||
&-back {
|
|
||||||
margin-top: 2rem;
|
|
||||||
opacity: 0.7;
|
|
||||||
font-size: 0.8rem;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,34 +0,0 @@
|
||||||
import React from 'react';
|
|
||||||
import loadable from 'loadable-components';
|
|
||||||
import { AUTH_PROVIDER } from 'utils/auth';
|
|
||||||
import './ProvideIdentity.less';
|
|
||||||
|
|
||||||
const AddressProvider = loadable(() => import('./providers/Address'));
|
|
||||||
const LedgerProvider = loadable(() => import('./providers/Ledger'));
|
|
||||||
const TrezorProvider = loadable(() => import('./providers/Trezor'));
|
|
||||||
const Web3Provider = loadable(() => import('./providers/Web3'));
|
|
||||||
|
|
||||||
const PROVIDER_COMPONENTS = {
|
|
||||||
[AUTH_PROVIDER.ADDRESS]: AddressProvider,
|
|
||||||
[AUTH_PROVIDER.LEDGER]: LedgerProvider,
|
|
||||||
[AUTH_PROVIDER.TREZOR]: TrezorProvider,
|
|
||||||
[AUTH_PROVIDER.WEB3]: Web3Provider,
|
|
||||||
};
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
provider: AUTH_PROVIDER;
|
|
||||||
onSelectAddress(addr: string): void;
|
|
||||||
reset(): void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default (props: Props) => {
|
|
||||||
const ProviderComponent = PROVIDER_COMPONENTS[props.provider];
|
|
||||||
return (
|
|
||||||
<div className="ProvideIdentity">
|
|
||||||
<ProviderComponent onSelectAddress={props.onSelectAddress} />
|
|
||||||
<p className="ProvideIdentity-back">
|
|
||||||
Want to use a different method? <a onClick={props.reset}>Click here</a>.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
|
@ -1,23 +0,0 @@
|
||||||
.SelectProvider {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
|
|
||||||
&-provider {
|
|
||||||
display: flex;
|
|
||||||
max-width: 360px;
|
|
||||||
width: 100%;
|
|
||||||
height: 4rem;
|
|
||||||
justify-content: center;
|
|
||||||
align-items: center;
|
|
||||||
margin-bottom: 0.75rem;
|
|
||||||
border: 1px solid rgba(#000, 0.15);
|
|
||||||
border-radius: 2px;
|
|
||||||
cursor: pointer;
|
|
||||||
font-size: 1.2rem;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
border: 1px solid rgba(#000, 0.3);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,25 +0,0 @@
|
||||||
import React from 'react';
|
|
||||||
import { AUTH_PROVIDER, AUTH_PROVIDERS } from 'utils/auth';
|
|
||||||
import './SelectProvider.less';
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
onSelect(provider: AUTH_PROVIDER): void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default class SelectProvider extends React.PureComponent<Props> {
|
|
||||||
render() {
|
|
||||||
return (
|
|
||||||
<div className="SelectProvider">
|
|
||||||
{Object.values(AUTH_PROVIDERS).map(provider => (
|
|
||||||
<button
|
|
||||||
key={provider.type}
|
|
||||||
className="SelectProvider-provider"
|
|
||||||
onClick={() => this.props.onSelect(provider.type)}
|
|
||||||
>
|
|
||||||
Connect with {provider.name}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -7,36 +7,10 @@
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
padding: 1rem;
|
padding: 1rem;
|
||||||
box-shadow: 0 1px 2px rgba(#000, 0.2);
|
box-shadow: 0 1px 2px rgba(#000, 0.2);
|
||||||
}
|
|
||||||
|
|
||||||
&-identity {
|
& button,
|
||||||
display: flex;
|
& input {
|
||||||
align-items: center;
|
margin-bottom: 0.5rem;
|
||||||
margin-bottom: 1.25rem;
|
|
||||||
|
|
||||||
&-identicon {
|
|
||||||
border-radius: 100%;
|
|
||||||
width: 3.6rem;
|
|
||||||
height: 3.6rem;
|
|
||||||
margin-right: 0.75rem;
|
|
||||||
box-shadow: 0 1px 2px rgba(#000, 0.3);
|
|
||||||
}
|
|
||||||
|
|
||||||
&-info {
|
|
||||||
width: 0;
|
|
||||||
flex: 1 1 auto;
|
|
||||||
|
|
||||||
&-name {
|
|
||||||
font-size: 1.4rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
&-address {
|
|
||||||
font-size: 0.8rem;
|
|
||||||
// Bug: <code /> doesn't seem to like opacity, so apply to children
|
|
||||||
> * {
|
|
||||||
opacity: 0.7;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -1,12 +1,8 @@
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { connect } from 'react-redux';
|
import { connect } from 'react-redux';
|
||||||
import { Button, Alert } from 'antd';
|
import { Button, Alert, Input } from 'antd';
|
||||||
import { authActions } from 'modules/auth';
|
import { authActions } from 'modules/auth';
|
||||||
import { User } from 'types';
|
|
||||||
import { AppState } from 'store/reducers';
|
import { AppState } from 'store/reducers';
|
||||||
import { AUTH_PROVIDER } from 'utils/auth';
|
|
||||||
import Identicon from 'components/Identicon';
|
|
||||||
import ShortAddress from 'components/ShortAddress';
|
|
||||||
import './SignIn.less';
|
import './SignIn.less';
|
||||||
|
|
||||||
interface StateProps {
|
interface StateProps {
|
||||||
|
@ -18,40 +14,56 @@ interface DispatchProps {
|
||||||
authUser: typeof authActions['authUser'];
|
authUser: typeof authActions['authUser'];
|
||||||
}
|
}
|
||||||
|
|
||||||
interface OwnProps {
|
type Props = StateProps & DispatchProps;
|
||||||
// TODO: Use common use User type instead
|
|
||||||
user: User;
|
|
||||||
provider: AUTH_PROVIDER;
|
|
||||||
reset(): void;
|
|
||||||
}
|
|
||||||
|
|
||||||
type Props = StateProps & DispatchProps & OwnProps;
|
const STATE = {
|
||||||
|
password: '',
|
||||||
|
email: '',
|
||||||
|
isAttemptedAuth: false,
|
||||||
|
};
|
||||||
|
|
||||||
class SignIn extends React.Component<Props> {
|
type State = typeof STATE;
|
||||||
|
|
||||||
|
class SignIn extends React.Component<Props, State> {
|
||||||
|
state: State = { ...STATE };
|
||||||
render() {
|
render() {
|
||||||
const { user, authUserError } = this.props;
|
const { authUserError, isAuthingUser } = this.props;
|
||||||
|
const { email, password, isAttemptedAuth } = this.state;
|
||||||
return (
|
return (
|
||||||
<div className="SignIn">
|
<div className="SignIn">
|
||||||
<div className="SignIn-container">
|
<div className="SignIn-container">
|
||||||
<div className="SignIn-identity">
|
<form onSubmit={this.handleLogin}>
|
||||||
<Identicon
|
<Input
|
||||||
address={user.accountAddress}
|
value={email}
|
||||||
className="SignIn-identity-identicon"
|
placeholder="email"
|
||||||
|
onChange={e => this.setState({ email: e.currentTarget.value })}
|
||||||
|
size="large"
|
||||||
|
autoComplete="email"
|
||||||
|
required={true}
|
||||||
/>
|
/>
|
||||||
<div className="SignIn-identity-info">
|
<Input
|
||||||
<div className="SignIn-identity-info-name">{user.displayName}</div>
|
value={password}
|
||||||
<code className="SignIn-identity-info-address">
|
placeholder="password"
|
||||||
<ShortAddress address={user.accountAddress} />
|
type="password"
|
||||||
</code>
|
onChange={e => this.setState({ password: e.currentTarget.value })}
|
||||||
</div>
|
size="large"
|
||||||
</div>
|
autoComplete="current-password"
|
||||||
|
required={true}
|
||||||
<Button type="primary" size="large" block onClick={this.authUser}>
|
/>
|
||||||
Prove identity
|
<Button
|
||||||
|
type="primary"
|
||||||
|
size="large"
|
||||||
|
loading={isAuthingUser}
|
||||||
|
htmlType="submit"
|
||||||
|
block
|
||||||
|
>
|
||||||
|
Sign in
|
||||||
</Button>
|
</Button>
|
||||||
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{authUserError && (
|
{isAttemptedAuth &&
|
||||||
|
authUserError && (
|
||||||
<Alert
|
<Alert
|
||||||
className="SignIn-error"
|
className="SignIn-error"
|
||||||
type="error"
|
type="error"
|
||||||
|
@ -60,23 +72,19 @@ class SignIn extends React.Component<Props> {
|
||||||
showIcon
|
showIcon
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/*
|
|
||||||
Temporarily only supporting web3, so there are no other identites
|
|
||||||
<p className="SignIn-back">
|
|
||||||
Want to use a different identity? <a onClick={this.props.reset}>Click here</a>.
|
|
||||||
</p>
|
|
||||||
*/}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
private authUser = () => {
|
private handleLogin = (ev: React.FormEvent<HTMLFormElement>) => {
|
||||||
this.props.authUser(this.props.user.accountAddress);
|
ev.preventDefault();
|
||||||
|
const { email, password } = this.state;
|
||||||
|
this.setState({ isAttemptedAuth: true });
|
||||||
|
this.props.authUser(email, password);
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export default connect<StateProps, DispatchProps, OwnProps, AppState>(
|
export default connect<StateProps, DispatchProps, {}, AppState>(
|
||||||
state => ({
|
state => ({
|
||||||
isAuthingUser: state.auth.isAuthingUser,
|
isAuthingUser: state.auth.isAuthingUser,
|
||||||
authUserError: state.auth.authUserError,
|
authUserError: state.auth.authUserError,
|
||||||
|
|
|
@ -7,41 +7,17 @@
|
||||||
box-shadow: 0 1px 2px rgba(#000, 0.2);
|
box-shadow: 0 1px 2px rgba(#000, 0.2);
|
||||||
}
|
}
|
||||||
|
|
||||||
&-identity {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
margin-bottom: 1rem;
|
|
||||||
|
|
||||||
&-identicon {
|
|
||||||
border-radius: 100%;
|
|
||||||
width: 3.6rem;
|
|
||||||
height: 3.6rem;
|
|
||||||
margin-right: 0.75rem;
|
|
||||||
box-shadow: 0 1px 2px rgba(#000, 0.3);
|
|
||||||
}
|
|
||||||
|
|
||||||
&-address {
|
|
||||||
width: 0;
|
|
||||||
flex: 1 1 auto;
|
|
||||||
font-size: 1rem;
|
|
||||||
opacity: 0.8;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
&-form {
|
&-form {
|
||||||
&-item {
|
&-item {
|
||||||
margin-bottom: 0.4rem;
|
margin-bottom: 0.5rem;
|
||||||
|
|
||||||
.ant-form-item-label {
|
|
||||||
padding-bottom: 0.2rem;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
&-back {
|
&-controls {
|
||||||
margin-top: 2rem;
|
margin-top: 0.5rem;
|
||||||
opacity: 0.7;
|
}
|
||||||
font-size: 0.8rem;
|
|
||||||
text-align: center;
|
&-alert {
|
||||||
|
margin-top: 1rem;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
|
@ -1,11 +1,10 @@
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { connect } from 'react-redux';
|
import { connect } from 'react-redux';
|
||||||
import { Form, Input, Button, Alert } from 'antd';
|
import { Form, Input, Button, Alert } from 'antd';
|
||||||
import Identicon from 'components/Identicon';
|
import { FormComponentProps } from 'antd/lib/form';
|
||||||
import ShortAddress from 'components/ShortAddress';
|
|
||||||
import { AUTH_PROVIDER } from 'utils/auth';
|
|
||||||
import { authActions } from 'modules/auth';
|
import { authActions } from 'modules/auth';
|
||||||
import { AppState } from 'store/reducers';
|
import { AppState } from 'store/reducers';
|
||||||
|
import PasswordFormItems from 'components/PasswordFormItems';
|
||||||
import './SignUp.less';
|
import './SignUp.less';
|
||||||
|
|
||||||
interface StateProps {
|
interface StateProps {
|
||||||
|
@ -17,68 +16,58 @@ interface DispatchProps {
|
||||||
createUser: typeof authActions['createUser'];
|
createUser: typeof authActions['createUser'];
|
||||||
}
|
}
|
||||||
|
|
||||||
interface OwnProps {
|
type Props = StateProps & DispatchProps & FormComponentProps;
|
||||||
address: string;
|
|
||||||
provider: AUTH_PROVIDER;
|
|
||||||
reset(): void;
|
|
||||||
}
|
|
||||||
|
|
||||||
type Props = StateProps & DispatchProps & OwnProps;
|
|
||||||
|
|
||||||
interface State {
|
|
||||||
name: string;
|
|
||||||
title: string;
|
|
||||||
email: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
class SignUp extends React.Component<Props, State> {
|
|
||||||
state: State = {
|
|
||||||
name: '',
|
|
||||||
title: '',
|
|
||||||
email: '',
|
|
||||||
};
|
|
||||||
|
|
||||||
|
class SignUp extends React.Component<Props> {
|
||||||
render() {
|
render() {
|
||||||
const { address, isCreatingUser, createUserError } = this.props;
|
const { isCreatingUser, createUserError } = this.props;
|
||||||
const { name, title, email } = this.state;
|
const { getFieldDecorator } = this.props.form;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="SignUp">
|
<div className="SignUp">
|
||||||
<div className="SignUp-container">
|
<div className="SignUp-container">
|
||||||
<div className="SignUp-identity">
|
<Form className="SignUp-form" onSubmit={this.handleSubmit}>
|
||||||
<Identicon address={address} className="SignUp-identity-identicon" />
|
|
||||||
<ShortAddress address={address} className="SignUp-identity-address" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Form className="SignUp-form" onSubmit={this.handleSubmit} layout="vertical">
|
|
||||||
<Form.Item className="SignUp-form-item" label="Display name">
|
<Form.Item className="SignUp-form-item" label="Display name">
|
||||||
|
{getFieldDecorator('name', {
|
||||||
|
rules: [{ required: true, message: 'Please add a display name' }],
|
||||||
|
})(
|
||||||
<Input
|
<Input
|
||||||
name="name"
|
name="name"
|
||||||
value={name}
|
|
||||||
onChange={this.handleChange}
|
|
||||||
placeholder="Non-unique name that others will see you as"
|
placeholder="Non-unique name that others will see you as"
|
||||||
size="large"
|
autoComplete="name"
|
||||||
/>
|
/>,
|
||||||
|
)}
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
|
||||||
<Form.Item className="SignUp-form-item" label="Title">
|
<Form.Item className="SignUp-form-item" label="Title">
|
||||||
|
{getFieldDecorator('title', {
|
||||||
|
rules: [{ required: true, message: 'Please add your title' }],
|
||||||
|
})(
|
||||||
<Input
|
<Input
|
||||||
name="title"
|
name="title"
|
||||||
value={title}
|
|
||||||
onChange={this.handleChange}
|
|
||||||
placeholder="A short description about you, e.g. Core Ethereum Developer"
|
placeholder="A short description about you, e.g. Core Ethereum Developer"
|
||||||
/>
|
/>,
|
||||||
|
)}
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
|
||||||
<Form.Item className="SignUp-form-item" label="Email address">
|
<Form.Item className="SignUp-form-item" label="Email address">
|
||||||
|
{getFieldDecorator('email', {
|
||||||
|
rules: [
|
||||||
|
{ type: 'email', message: 'Invalid email' },
|
||||||
|
{ required: true, message: 'Please enter your email' },
|
||||||
|
],
|
||||||
|
})(
|
||||||
<Input
|
<Input
|
||||||
name="email"
|
name="email"
|
||||||
value={email}
|
|
||||||
onChange={this.handleChange}
|
|
||||||
placeholder="We promise not to spam you or share your email"
|
placeholder="We promise not to spam you or share your email"
|
||||||
/>
|
autoComplete="username"
|
||||||
|
/>,
|
||||||
|
)}
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
|
||||||
|
<PasswordFormItems form={this.props.form} />
|
||||||
|
|
||||||
|
<div className="SignUp-form-controls">
|
||||||
<Button
|
<Button
|
||||||
type="primary"
|
type="primary"
|
||||||
htmlType="submit"
|
htmlType="submit"
|
||||||
|
@ -86,45 +75,39 @@ class SignUp extends React.Component<Props, State> {
|
||||||
block
|
block
|
||||||
loading={isCreatingUser}
|
loading={isCreatingUser}
|
||||||
>
|
>
|
||||||
Claim Identity
|
Create account
|
||||||
</Button>
|
</Button>
|
||||||
|
</div>
|
||||||
{createUserError && (
|
{createUserError && (
|
||||||
<Alert
|
<Alert
|
||||||
type="error"
|
type="error"
|
||||||
message={createUserError}
|
message={createUserError}
|
||||||
showIcon
|
showIcon
|
||||||
closable
|
closable
|
||||||
style={{ marginTop: '1rem' }}
|
className="SignUp-form-alert"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</Form>
|
</Form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/*
|
|
||||||
Temporarily only supporting web3, so there are no other identites
|
|
||||||
<p className="SignUp-back">
|
|
||||||
Want to use a different identity? <a onClick={this.props.reset}>Click here</a>.
|
|
||||||
</p>
|
|
||||||
*/}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
private handleChange = (ev: React.ChangeEvent<HTMLInputElement>) => {
|
|
||||||
const { name, value } = ev.currentTarget;
|
|
||||||
this.setState({ [name]: value } as any);
|
|
||||||
};
|
|
||||||
|
|
||||||
private handleSubmit = (ev: React.FormEvent<HTMLFormElement>) => {
|
private handleSubmit = (ev: React.FormEvent<HTMLFormElement>) => {
|
||||||
ev.preventDefault();
|
ev.preventDefault();
|
||||||
const { address, createUser } = this.props;
|
const { createUser } = this.props;
|
||||||
const { name, title, email } = this.state;
|
this.props.form.validateFieldsAndScroll((err: any, values: any) => {
|
||||||
createUser({ address, name, title, email });
|
if (!err) {
|
||||||
|
delete values.passwordConfirm;
|
||||||
|
createUser(values);
|
||||||
|
}
|
||||||
|
});
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export default connect<StateProps, DispatchProps, OwnProps, AppState>(
|
const FormWrappedSignUp = Form.create()(SignUp);
|
||||||
|
|
||||||
|
export default connect<StateProps, DispatchProps, {}, AppState>(
|
||||||
state => ({
|
state => ({
|
||||||
isCreatingUser: state.auth.isCreatingUser,
|
isCreatingUser: state.auth.isCreatingUser,
|
||||||
createUserError: state.auth.createUserError,
|
createUserError: state.auth.createUserError,
|
||||||
|
@ -132,4 +115,4 @@ export default connect<StateProps, DispatchProps, OwnProps, AppState>(
|
||||||
{
|
{
|
||||||
createUser: authActions.createUser,
|
createUser: authActions.createUser,
|
||||||
},
|
},
|
||||||
)(SignUp);
|
)(FormWrappedSignUp);
|
||||||
|
|
|
@ -11,4 +11,10 @@
|
||||||
opacity: 0.7;
|
opacity: 0.7;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&-switch {
|
||||||
|
font-size: 1.2rem;
|
||||||
|
margin-top: 2rem;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
}
|
}
|
|
@ -2,163 +2,71 @@ import React from 'react';
|
||||||
import { connect } from 'react-redux';
|
import { connect } from 'react-redux';
|
||||||
import { Spin } from 'antd';
|
import { Spin } from 'antd';
|
||||||
import { AppState } from 'store/reducers';
|
import { AppState } from 'store/reducers';
|
||||||
import { AUTH_PROVIDER } from 'utils/auth';
|
|
||||||
import { authActions } from 'modules/auth';
|
import { authActions } from 'modules/auth';
|
||||||
import SignIn from './SignIn';
|
import SignIn from './SignIn';
|
||||||
import SignUp from './SignUp';
|
import SignUp from './SignUp';
|
||||||
import SelectProvider from './SelectProvider';
|
|
||||||
import ProvideIdentity from './ProvideIdentity';
|
|
||||||
import './index.less';
|
import './index.less';
|
||||||
|
|
||||||
interface StateProps {
|
interface StateProps {
|
||||||
web3Accounts: AppState['web3']['accounts'];
|
authUser: AppState['auth']['user'];
|
||||||
checkedUsers: AppState['auth']['checkedUsers'];
|
|
||||||
isCheckingUser: AppState['auth']['isCheckingUser'];
|
isCheckingUser: AppState['auth']['isCheckingUser'];
|
||||||
}
|
}
|
||||||
|
|
||||||
interface DispatchProps {
|
type Props = StateProps;
|
||||||
checkUser: typeof authActions['checkUser'];
|
|
||||||
}
|
|
||||||
|
|
||||||
type Props = StateProps & DispatchProps;
|
|
||||||
|
|
||||||
interface State {
|
|
||||||
provider: AUTH_PROVIDER | null;
|
|
||||||
address: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const DEFAULT_STATE: State = {
|
|
||||||
// Temporarily hardcode to web3, change to null when others are supported
|
|
||||||
provider: AUTH_PROVIDER.WEB3,
|
|
||||||
address: null,
|
|
||||||
};
|
|
||||||
|
|
||||||
class AuthFlow extends React.Component<Props> {
|
class AuthFlow extends React.Component<Props> {
|
||||||
state: State = { ...DEFAULT_STATE };
|
state: { page: 'SIGN_IN' | 'SIGN_UP' } = { page: 'SIGN_IN' };
|
||||||
|
|
||||||
private pages = {
|
private pages = {
|
||||||
SIGN_IN: {
|
SIGN_IN: {
|
||||||
title: () => 'Prove your Identity',
|
title: 'Sign in',
|
||||||
subtitle: () => 'Log into your Grant.io account by proving your identity',
|
subtitle: '',
|
||||||
render: () => {
|
render: () => {
|
||||||
const { address, provider } = this.state;
|
return <SignIn />;
|
||||||
const user = address && this.props.checkedUsers[address];
|
|
||||||
return (
|
|
||||||
user &&
|
|
||||||
provider && <SignIn provider={provider} user={user} reset={this.resetState} />
|
|
||||||
);
|
|
||||||
},
|
},
|
||||||
|
renderSwitch: () => (
|
||||||
|
<>
|
||||||
|
No account?{' '}
|
||||||
|
<a onClick={() => this.setState({ page: 'SIGN_UP' })}>Create a new account</a>.
|
||||||
|
</>
|
||||||
|
),
|
||||||
},
|
},
|
||||||
SIGN_UP: {
|
SIGN_UP: {
|
||||||
title: () => 'Claim your Identity',
|
title: 'Create your Account',
|
||||||
subtitle: () => 'Create a Grant.io account by claiming your identity',
|
subtitle: 'Please enter your details below',
|
||||||
render: () => {
|
render: () => {
|
||||||
const { address, provider } = this.state;
|
return <SignUp />;
|
||||||
return (
|
|
||||||
address &&
|
|
||||||
provider && (
|
|
||||||
<SignUp address={address} provider={provider} reset={this.resetState} />
|
|
||||||
)
|
|
||||||
);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
SELECT_PROVIDER: {
|
|
||||||
title: () => 'Provide an Identity',
|
|
||||||
subtitle: () =>
|
|
||||||
'Sign in or create a new account by selecting your identity provider',
|
|
||||||
render: () => <SelectProvider onSelect={this.setProvider} />,
|
|
||||||
},
|
|
||||||
PROVIDE_IDENTITY: {
|
|
||||||
title: () => 'Provide an Identity',
|
|
||||||
subtitle: () => {
|
|
||||||
switch (this.state.provider) {
|
|
||||||
case AUTH_PROVIDER.ADDRESS:
|
|
||||||
return 'Enter your Ethereum Address';
|
|
||||||
case AUTH_PROVIDER.LEDGER:
|
|
||||||
return 'Connect with your Ledger';
|
|
||||||
case AUTH_PROVIDER.TREZOR:
|
|
||||||
return 'Connect with your TREZOR';
|
|
||||||
case AUTH_PROVIDER.WEB3:
|
|
||||||
// TODO: Dynamically use web3 name
|
|
||||||
return 'Connect with MetaMask';
|
|
||||||
}
|
|
||||||
},
|
|
||||||
render: () => {
|
|
||||||
return (
|
|
||||||
this.state.provider && (
|
|
||||||
<ProvideIdentity
|
|
||||||
provider={this.state.provider}
|
|
||||||
onSelectAddress={this.setAddress}
|
|
||||||
reset={this.resetState}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
);
|
|
||||||
},
|
},
|
||||||
|
renderSwitch: () => (
|
||||||
|
<>
|
||||||
|
Already have an account?{' '}
|
||||||
|
<a onClick={() => this.setState({ page: 'SIGN_IN' })}>Sign in</a>.
|
||||||
|
</>
|
||||||
|
),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
componentDidMount() {
|
|
||||||
// If web3 is available, default to it
|
|
||||||
const { web3Accounts } = this.props;
|
|
||||||
if (web3Accounts && web3Accounts[0]) {
|
|
||||||
this.setState({
|
|
||||||
provider: AUTH_PROVIDER.WEB3,
|
|
||||||
address: web3Accounts[0],
|
|
||||||
});
|
|
||||||
this.props.checkUser(web3Accounts[0]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
const { checkedUsers, isCheckingUser } = this.props;
|
const { isCheckingUser } = this.props;
|
||||||
const { provider, address } = this.state;
|
const page = this.pages[this.state.page];
|
||||||
const checkedUser = address && checkedUsers[address];
|
|
||||||
let page;
|
|
||||||
|
|
||||||
if (provider) {
|
|
||||||
if (address) {
|
|
||||||
// TODO: If address results in user, show SIGN_IN.
|
|
||||||
if (isCheckingUser) {
|
if (isCheckingUser) {
|
||||||
return <Spin size="large" />;
|
return <Spin size="large" />;
|
||||||
} else if (checkedUser) {
|
|
||||||
page = this.pages.SIGN_IN;
|
|
||||||
} else {
|
|
||||||
page = this.pages.SIGN_UP;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
page = this.pages.PROVIDE_IDENTITY;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
page = this.pages.SELECT_PROVIDER;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="AuthFlow">
|
<div className="AuthFlow">
|
||||||
<h1 className="AuthFlow-title">{page.title()}</h1>
|
{page.title && <h1 className="AuthFlow-title">{page.title}</h1>}
|
||||||
<p className="AuthFlow-subtitle">{page.subtitle()}</p>
|
{page.subtitle && <p className="AuthFlow-subtitle">{page.subtitle}</p>}
|
||||||
<div className="AuthFlow-content">{page.render()}</div>
|
<div className="AuthFlow-content">{page.render()}</div>
|
||||||
|
<div className="AuthFlow-switch">{page.renderSwitch()}</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
private setProvider = (provider: AUTH_PROVIDER) => {
|
|
||||||
this.setState({ provider });
|
|
||||||
};
|
|
||||||
|
|
||||||
private setAddress = (address: string) => {
|
|
||||||
this.setState({ address });
|
|
||||||
this.props.checkUser(address);
|
|
||||||
};
|
|
||||||
|
|
||||||
private resetState = () => {
|
|
||||||
this.setState({ ...DEFAULT_STATE });
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default connect<StateProps, DispatchProps, {}, AppState>(
|
export default connect<StateProps, {}, {}, AppState>(
|
||||||
state => ({
|
state => ({
|
||||||
web3Accounts: state.web3.accounts,
|
authUser: state.auth.user,
|
||||||
checkedUsers: state.auth.checkedUsers,
|
|
||||||
isCheckingUser: state.auth.isCheckingUser,
|
isCheckingUser: state.auth.isCheckingUser,
|
||||||
}),
|
}),
|
||||||
{
|
{
|
||||||
|
|
|
@ -1,9 +0,0 @@
|
||||||
.AddressProvider {
|
|
||||||
width: 100%;
|
|
||||||
max-width: 360px;
|
|
||||||
margin: -0.5rem auto 0;
|
|
||||||
|
|
||||||
&-address {
|
|
||||||
margin-bottom: 0.5rem;
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,52 +0,0 @@
|
||||||
import React from 'react';
|
|
||||||
import { Form, Button } from 'antd';
|
|
||||||
import { isValidEthAddress } from 'utils/validators';
|
|
||||||
import AddressInput from 'components/AddressInput';
|
|
||||||
import './Address.less';
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
onSelectAddress(addr: string): void;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface State {
|
|
||||||
address: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default class AddressProvider extends React.Component<Props, State> {
|
|
||||||
state: State = {
|
|
||||||
address: '',
|
|
||||||
};
|
|
||||||
|
|
||||||
render() {
|
|
||||||
const { address } = this.state;
|
|
||||||
return (
|
|
||||||
<Form className="AddressProvider" onSubmit={this.handleSubmit}>
|
|
||||||
<AddressInput
|
|
||||||
className="AddressProvider-address"
|
|
||||||
value={address}
|
|
||||||
onChange={this.handleChange}
|
|
||||||
inputProps={{ size: 'large' }}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
type="primary"
|
|
||||||
htmlType="submit"
|
|
||||||
size="large"
|
|
||||||
disabled={!isValidEthAddress(address)}
|
|
||||||
block
|
|
||||||
>
|
|
||||||
Continue
|
|
||||||
</Button>
|
|
||||||
</Form>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
private handleChange = (ev: React.ChangeEvent<HTMLInputElement>) => {
|
|
||||||
this.setState({ address: ev.currentTarget.value });
|
|
||||||
};
|
|
||||||
|
|
||||||
private handleSubmit = (ev: React.FormEvent<HTMLFormElement>) => {
|
|
||||||
ev.preventDefault();
|
|
||||||
this.props.onSelectAddress(this.state.address);
|
|
||||||
};
|
|
||||||
}
|
|
|
@ -1,110 +0,0 @@
|
||||||
@addresses-max-width: 36rem;
|
|
||||||
@addresses-width: 10rem;
|
|
||||||
@addresses-padding: 1rem;
|
|
||||||
|
|
||||||
|
|
||||||
.ChooseAddress {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
justify-content: center;
|
|
||||||
|
|
||||||
// Shared styles between addresses and loader
|
|
||||||
&-addresses,
|
|
||||||
&-loading {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
justify-content: center;
|
|
||||||
align-items: center;
|
|
||||||
max-width: 36rem;
|
|
||||||
margin-bottom: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
&-buttons {
|
|
||||||
display: flex;
|
|
||||||
justify-content: center;
|
|
||||||
|
|
||||||
&-button {
|
|
||||||
margin: 0 0.25rem;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
&-error {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
max-width: 30rem;
|
|
||||||
margin: 0 auto 2rem;
|
|
||||||
|
|
||||||
.ant-alert {
|
|
||||||
margin-bottom: 1rem;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.AddressChoice {
|
|
||||||
width: 10rem;
|
|
||||||
padding: 1rem;
|
|
||||||
margin: 0 0.75rem 1rem;
|
|
||||||
background: #FFF;
|
|
||||||
border: 1px solid rgba(#000, 0.12);
|
|
||||||
border-radius: 4px;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: transform 100ms ease, border-color 100ms ease;
|
|
||||||
outline: none;
|
|
||||||
|
|
||||||
&:hover,
|
|
||||||
&:focus {
|
|
||||||
transform: translateY(-2px);
|
|
||||||
border-color: rgba(#000, 0.2);
|
|
||||||
}
|
|
||||||
|
|
||||||
&:active {
|
|
||||||
transform: translateY(0);
|
|
||||||
border-color: rgba(#000, 0.28);
|
|
||||||
}
|
|
||||||
|
|
||||||
&-avatar {
|
|
||||||
display: block;
|
|
||||||
width: 6rem;
|
|
||||||
height: 6rem;
|
|
||||||
margin: 0 auto 1rem;
|
|
||||||
border-radius: 100%;
|
|
||||||
|
|
||||||
.is-fake & {
|
|
||||||
background: #000;
|
|
||||||
color: #000;
|
|
||||||
opacity: 0.2;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
&-name,
|
|
||||||
&-address {
|
|
||||||
margin: 0 auto;
|
|
||||||
|
|
||||||
.is-fake & {
|
|
||||||
background: #000;
|
|
||||||
color: #000;
|
|
||||||
transform: scaleY(0.8);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
&-name {
|
|
||||||
font-size: 1rem;
|
|
||||||
|
|
||||||
.is-fake & {
|
|
||||||
opacity: 0.2;
|
|
||||||
width: 60%;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
&-address {
|
|
||||||
opacity: 0.6;
|
|
||||||
font-size: 0.8rem;
|
|
||||||
|
|
||||||
.is-fake & {
|
|
||||||
opacity: 0.1;
|
|
||||||
width: 80%;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,162 +0,0 @@
|
||||||
import React from 'react';
|
|
||||||
import { Button, Spin, Icon, Alert } from 'antd';
|
|
||||||
import classnames from 'classnames';
|
|
||||||
import Identicon from 'components/Identicon';
|
|
||||||
import ShortAddress from 'components/ShortAddress';
|
|
||||||
import './ChooseAddress.less';
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
addresses: string[];
|
|
||||||
loadingMessage: string;
|
|
||||||
handleDeriveAddresses(index: number, numNeeded: number): Promise<void>;
|
|
||||||
onSelectAddress(address: string): void;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface State {
|
|
||||||
index: number;
|
|
||||||
isLoading: boolean;
|
|
||||||
error: null | string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const ADDRESSES_PER_PAGE = 6;
|
|
||||||
|
|
||||||
export default class ChooseAddress extends React.PureComponent<Props, State> {
|
|
||||||
state: State = {
|
|
||||||
index: 0,
|
|
||||||
isLoading: false,
|
|
||||||
error: null,
|
|
||||||
};
|
|
||||||
|
|
||||||
componentDidMount() {
|
|
||||||
this.deriveAddresses();
|
|
||||||
}
|
|
||||||
|
|
||||||
componentDidUpdate(prevProps: Props) {
|
|
||||||
// Detect resets of the array, kick off derive
|
|
||||||
if (prevProps.addresses !== this.props.addresses && !this.props.addresses.length) {
|
|
||||||
this.setState({ index: 0 }, () => {
|
|
||||||
this.deriveAddresses();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
render() {
|
|
||||||
const { addresses } = this.props;
|
|
||||||
const { index, isLoading, error } = this.state;
|
|
||||||
|
|
||||||
let content;
|
|
||||||
if (error) {
|
|
||||||
content = (
|
|
||||||
<div className="ChooseAddress-error">
|
|
||||||
<Alert
|
|
||||||
type="error"
|
|
||||||
message="Something went wrong"
|
|
||||||
description={error}
|
|
||||||
showIcon
|
|
||||||
/>
|
|
||||||
<Button size="large" onClick={this.deriveAddresses}>
|
|
||||||
Try again
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
if (isLoading) {
|
|
||||||
content = (
|
|
||||||
<Spin size="large">
|
|
||||||
<div className="ChooseAddress-loading">
|
|
||||||
{new Array(ADDRESSES_PER_PAGE).fill(null).map((_, idx) => (
|
|
||||||
<AddressChoice key={idx} isFake={true} name="Loading" address="0x0" />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</Spin>
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
const pageAddresses = addresses.slice(index, index + ADDRESSES_PER_PAGE);
|
|
||||||
content = (
|
|
||||||
<div className="ChooseAddress-addresses">
|
|
||||||
{pageAddresses.map(address => (
|
|
||||||
<AddressChoice
|
|
||||||
key={address}
|
|
||||||
address={address}
|
|
||||||
name={`Address #${addresses.indexOf(address) + 1}`}
|
|
||||||
onClick={this.props.onSelectAddress}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
content = (
|
|
||||||
<>
|
|
||||||
{content}
|
|
||||||
<div className="ChooseAddress-buttons">
|
|
||||||
<Button
|
|
||||||
className="ChooseAddress-buttons-button"
|
|
||||||
disabled={index <= 0}
|
|
||||||
onClick={this.prev}
|
|
||||||
>
|
|
||||||
<Icon type="arrow-left" />
|
|
||||||
</Button>
|
|
||||||
<Button className="ChooseAddress-buttons-button" onClick={this.next}>
|
|
||||||
<Icon type="arrow-right" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return <div className="ChooseAddress">{content}</div>;
|
|
||||||
}
|
|
||||||
|
|
||||||
private deriveAddresses = () => {
|
|
||||||
this.setState(
|
|
||||||
{
|
|
||||||
isLoading: true,
|
|
||||||
error: null,
|
|
||||||
},
|
|
||||||
() => {
|
|
||||||
this.props
|
|
||||||
.handleDeriveAddresses(this.state.index, ADDRESSES_PER_PAGE)
|
|
||||||
.then(() => this.setState({ isLoading: false }))
|
|
||||||
.catch(err => this.setState({ isLoading: false, error: err.message }));
|
|
||||||
},
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
private next = () => {
|
|
||||||
this.setState({ index: this.state.index + ADDRESSES_PER_PAGE }, () => {
|
|
||||||
if (!this.props.addresses[this.state.index + ADDRESSES_PER_PAGE]) {
|
|
||||||
this.deriveAddresses();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
private prev = () => {
|
|
||||||
this.setState({ index: Math.max(0, this.state.index - ADDRESSES_PER_PAGE) });
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
interface AddressChoiceProps {
|
|
||||||
address: string;
|
|
||||||
name: string;
|
|
||||||
isFake?: boolean;
|
|
||||||
onClick?(address: string): void;
|
|
||||||
}
|
|
||||||
|
|
||||||
const AddressChoice: React.SFC<AddressChoiceProps> = props => (
|
|
||||||
<button
|
|
||||||
className={classnames('AddressChoice', props.isFake && 'is-fake')}
|
|
||||||
onClick={() => props.onClick && props.onClick(props.address)}
|
|
||||||
>
|
|
||||||
{/* TODO: Use user avatar + name if they have an account */}
|
|
||||||
{props.isFake ? (
|
|
||||||
<div className="AddressChoice-avatar" />
|
|
||||||
) : (
|
|
||||||
<Identicon className="AddressChoice-avatar" address={props.address} />
|
|
||||||
)}
|
|
||||||
<div className="AddressChoice-name">{props.name}</div>
|
|
||||||
<div className="AddressChoice-address">
|
|
||||||
<ShortAddress address={props.address} />
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
);
|
|
|
@ -1,25 +0,0 @@
|
||||||
.LedgerProvider {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
justify-content: center;
|
|
||||||
|
|
||||||
&-type {
|
|
||||||
display: flex;
|
|
||||||
justify-content: center;
|
|
||||||
margin-top: -0.5rem;
|
|
||||||
margin-bottom: 1.25rem;
|
|
||||||
|
|
||||||
.ant-radio-button-wrapper {
|
|
||||||
min-width: 5rem;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
&-hint {
|
|
||||||
opacity: 0.7;
|
|
||||||
font-size: 0.8rem;
|
|
||||||
text-align: center;
|
|
||||||
margin-top: 1.5rem;
|
|
||||||
margin-bottom: -1rem;
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,117 +0,0 @@
|
||||||
import React from 'react';
|
|
||||||
import TransportU2F from '@ledgerhq/hw-transport-u2f';
|
|
||||||
import LedgerEth from '@ledgerhq/hw-app-eth';
|
|
||||||
import { Radio } from 'antd';
|
|
||||||
import { RadioChangeEvent } from 'antd/lib/radio';
|
|
||||||
import ChooseAddress from './ChooseAddress';
|
|
||||||
import { deriveAddressesFromPubKey, parseLedgerError } from 'utils/wallet';
|
|
||||||
import './Ledger.less';
|
|
||||||
|
|
||||||
enum ADDRESS_TYPE {
|
|
||||||
LEGACY = 'LEGACY',
|
|
||||||
LIVE = 'LIVE',
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
onSelectAddress(addr: string): void;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface State {
|
|
||||||
publicKey: null | string;
|
|
||||||
chainCode: null | string;
|
|
||||||
addresses: string[];
|
|
||||||
addressType: ADDRESS_TYPE;
|
|
||||||
}
|
|
||||||
|
|
||||||
const DPATHS = {
|
|
||||||
LEGACY: `m/44'/60'/0'/0`,
|
|
||||||
LIVE: `m/44'/60'/$index'/0/0`,
|
|
||||||
};
|
|
||||||
|
|
||||||
export default class LedgerProvider extends React.Component<Props, State> {
|
|
||||||
state: State = {
|
|
||||||
publicKey: null,
|
|
||||||
chainCode: null,
|
|
||||||
addresses: [],
|
|
||||||
addressType: ADDRESS_TYPE.LIVE,
|
|
||||||
};
|
|
||||||
|
|
||||||
render() {
|
|
||||||
const { addresses, addressType } = this.state;
|
|
||||||
return (
|
|
||||||
<div className="LedgerProvider">
|
|
||||||
<div className="LedgerProvider-type">
|
|
||||||
<Radio.Group onChange={this.changeAddressType} value={addressType} size="large">
|
|
||||||
<Radio.Button value={ADDRESS_TYPE.LIVE}>Live</Radio.Button>
|
|
||||||
<Radio.Button value={ADDRESS_TYPE.LEGACY}>Legacy</Radio.Button>
|
|
||||||
</Radio.Group>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<ChooseAddress
|
|
||||||
addresses={addresses}
|
|
||||||
loadingMessage="Waiting for Ledger..."
|
|
||||||
handleDeriveAddresses={this.deriveAddresses}
|
|
||||||
onSelectAddress={this.props.onSelectAddress}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="LedgerProvider-hint">
|
|
||||||
Don't see your address? Try changing between Live and Legacy addresses.
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
private deriveAddresses = async (index: number, numAddresses: number) => {
|
|
||||||
const { addressType } = this.state;
|
|
||||||
let addresses = [...this.state.addresses];
|
|
||||||
|
|
||||||
try {
|
|
||||||
if (addressType === ADDRESS_TYPE.LIVE) {
|
|
||||||
const app = await this.getEthApp();
|
|
||||||
for (let i = index; i < index + numAddresses; i++) {
|
|
||||||
const res = await app.getAddress(DPATHS.LIVE.replace('$index', i.toString()));
|
|
||||||
addresses.push(res.address);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
let { chainCode, publicKey } = this.state;
|
|
||||||
if (!chainCode || !publicKey) {
|
|
||||||
const app = await this.getEthApp();
|
|
||||||
const res = await app.getAddress(DPATHS.LEGACY, false, true);
|
|
||||||
chainCode = res.chainCode;
|
|
||||||
publicKey = res.publicKey;
|
|
||||||
this.setState({ chainCode, publicKey });
|
|
||||||
}
|
|
||||||
|
|
||||||
addresses = addresses.concat(
|
|
||||||
deriveAddressesFromPubKey({
|
|
||||||
chainCode,
|
|
||||||
publicKey,
|
|
||||||
index,
|
|
||||||
numAddresses,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
const msg = parseLedgerError(err);
|
|
||||||
throw new Error(msg);
|
|
||||||
}
|
|
||||||
|
|
||||||
this.setState({ addresses });
|
|
||||||
};
|
|
||||||
|
|
||||||
private getEthApp = async () => {
|
|
||||||
const transport = await TransportU2F.create();
|
|
||||||
return new LedgerEth(transport);
|
|
||||||
};
|
|
||||||
|
|
||||||
private changeAddressType = (ev: RadioChangeEvent) => {
|
|
||||||
const addressType = ev.target.value as ADDRESS_TYPE;
|
|
||||||
if (addressType === this.state.addressType) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
this.setState({
|
|
||||||
addresses: [],
|
|
||||||
addressType,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
}
|
|
|
@ -1,66 +0,0 @@
|
||||||
import React from 'react';
|
|
||||||
import TrezorConnect from 'trezor-connect';
|
|
||||||
import ChooseAddress from './ChooseAddress';
|
|
||||||
import { deriveAddressesFromPubKey } from 'utils/wallet';
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
onSelectAddress(addr: string): void;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface State {
|
|
||||||
publicKey: null | string;
|
|
||||||
chainCode: null | string;
|
|
||||||
addresses: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
const DPATHS = {
|
|
||||||
MAINNET: `m/44'/60'/0'/0`,
|
|
||||||
TESTNET: `m/44'/1'/0'/0`,
|
|
||||||
};
|
|
||||||
|
|
||||||
export default class TrezorProvider extends React.Component<Props, State> {
|
|
||||||
state: State = {
|
|
||||||
publicKey: null,
|
|
||||||
chainCode: null,
|
|
||||||
addresses: [],
|
|
||||||
};
|
|
||||||
|
|
||||||
render() {
|
|
||||||
return (
|
|
||||||
<ChooseAddress
|
|
||||||
addresses={this.state.addresses}
|
|
||||||
loadingMessage="Waiting for TREZOR..."
|
|
||||||
handleDeriveAddresses={this.deriveAddresses}
|
|
||||||
onSelectAddress={this.props.onSelectAddress}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
private deriveAddresses = async (index: number, numAddresses: number) => {
|
|
||||||
let { chainCode, publicKey } = this.state;
|
|
||||||
if (!chainCode || !publicKey) {
|
|
||||||
const res = await this.getPublicKey();
|
|
||||||
chainCode = res.chainCode;
|
|
||||||
publicKey = res.publicKey;
|
|
||||||
this.setState({ chainCode, publicKey });
|
|
||||||
}
|
|
||||||
|
|
||||||
const addresses = this.state.addresses.concat(
|
|
||||||
deriveAddressesFromPubKey({
|
|
||||||
chainCode,
|
|
||||||
publicKey,
|
|
||||||
index,
|
|
||||||
numAddresses,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
this.setState({ addresses });
|
|
||||||
};
|
|
||||||
|
|
||||||
private getPublicKey = async () => {
|
|
||||||
const res = await TrezorConnect.getPublicKey({ path: DPATHS.TESTNET });
|
|
||||||
if (res.success === false) {
|
|
||||||
throw new Error(res.payload.error);
|
|
||||||
}
|
|
||||||
return res.payload;
|
|
||||||
};
|
|
||||||
}
|
|
|
@ -1,16 +0,0 @@
|
||||||
.Web3Provider {
|
|
||||||
max-width: 360px;
|
|
||||||
margin: 0 auto;
|
|
||||||
text-align: center;
|
|
||||||
|
|
||||||
&-logo {
|
|
||||||
display: block;
|
|
||||||
max-width: 120px;
|
|
||||||
margin: 0 auto 1.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
&-description {
|
|
||||||
font-size: 0.9rem;
|
|
||||||
margin-bottom: 1rem;
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,90 +0,0 @@
|
||||||
import React from 'react';
|
|
||||||
import { connect } from 'react-redux';
|
|
||||||
import { Button, Alert, Spin } from 'antd';
|
|
||||||
import { enableWeb3 } from 'modules/web3/actions';
|
|
||||||
import { AppState } from 'store/reducers';
|
|
||||||
import MetamaskIcon from 'static/images/metamask.png';
|
|
||||||
import './Web3.less';
|
|
||||||
|
|
||||||
interface StateProps {
|
|
||||||
accounts: AppState['web3']['accounts'];
|
|
||||||
isEnablingWeb3: AppState['web3']['isEnablingWeb3'];
|
|
||||||
accountsLoading: AppState['web3']['accountsLoading'];
|
|
||||||
web3EnableError: AppState['web3']['web3EnableError'];
|
|
||||||
accountsError: AppState['web3']['accountsError'];
|
|
||||||
}
|
|
||||||
|
|
||||||
interface DispatchProps {
|
|
||||||
enableWeb3: typeof enableWeb3;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface OwnProps {
|
|
||||||
onSelectAddress(addr: string): void;
|
|
||||||
}
|
|
||||||
|
|
||||||
type Props = StateProps & DispatchProps & OwnProps;
|
|
||||||
|
|
||||||
class Web3Provider extends React.Component<Props> {
|
|
||||||
componentWillMount() {
|
|
||||||
if (!this.props.accounts || !this.props.accounts[0]) {
|
|
||||||
this.props.enableWeb3();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
componentDidUpdate() {
|
|
||||||
const { accounts } = this.props;
|
|
||||||
if (accounts && accounts[0]) {
|
|
||||||
this.props.onSelectAddress(accounts[0]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
render() {
|
|
||||||
const {
|
|
||||||
isEnablingWeb3,
|
|
||||||
accountsLoading,
|
|
||||||
web3EnableError,
|
|
||||||
accountsError,
|
|
||||||
} = this.props;
|
|
||||||
const isLoading = isEnablingWeb3 || accountsLoading;
|
|
||||||
const error = web3EnableError || accountsError;
|
|
||||||
return (
|
|
||||||
<div className="Web3Provider">
|
|
||||||
{isLoading ? (
|
|
||||||
<Spin tip="Connecting..." />
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<img className="Web3Provider-logo" src={MetamaskIcon} />
|
|
||||||
<p className="Web3Provider-description">
|
|
||||||
Make sure you have MetaMask or another web3 provider installed and unlocked,
|
|
||||||
then click below.
|
|
||||||
</p>
|
|
||||||
{error && (
|
|
||||||
<Alert
|
|
||||||
showIcon
|
|
||||||
type="error"
|
|
||||||
message={error}
|
|
||||||
style={{ margin: '1rem auto' }}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<Button type="primary" size="large" onClick={this.props.enableWeb3}>
|
|
||||||
Connect to Web3
|
|
||||||
</Button>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export default connect<StateProps, DispatchProps, OwnProps, AppState>(
|
|
||||||
state => ({
|
|
||||||
accounts: state.web3.accounts,
|
|
||||||
isEnablingWeb3: state.web3.isEnablingWeb3,
|
|
||||||
accountsLoading: state.web3.accountsLoading,
|
|
||||||
web3EnableError: state.web3.web3EnableError,
|
|
||||||
accountsError: state.web3.accountsError,
|
|
||||||
}),
|
|
||||||
{
|
|
||||||
enableWeb3,
|
|
||||||
},
|
|
||||||
)(Web3Provider);
|
|
|
@ -1,12 +1,10 @@
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { connect } from 'react-redux';
|
import { connect } from 'react-redux';
|
||||||
import { Spin } from 'antd';
|
|
||||||
import { Route, Redirect, RouteProps } from 'react-router-dom';
|
import { Route, Redirect, RouteProps } from 'react-router-dom';
|
||||||
import { AppState } from 'store/reducers';
|
import { AppState } from 'store/reducers';
|
||||||
|
|
||||||
interface StateProps {
|
interface StateProps {
|
||||||
user: AppState['auth']['user'];
|
user: AppState['auth']['user'];
|
||||||
isAuthingUser: AppState['auth']['isAuthingUser'];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface OwnProps {
|
interface OwnProps {
|
||||||
|
@ -17,11 +15,8 @@ type Props = RouteProps & StateProps & OwnProps;
|
||||||
|
|
||||||
class AuthRoute extends React.Component<Props> {
|
class AuthRoute extends React.Component<Props> {
|
||||||
public render() {
|
public render() {
|
||||||
const { user, isAuthingUser, onlyLoggedOut, ...routeProps } = this.props;
|
const { user, onlyLoggedOut, ...routeProps } = this.props;
|
||||||
|
if ((user && !onlyLoggedOut) || (!user && onlyLoggedOut)) {
|
||||||
if (isAuthingUser) {
|
|
||||||
return <Spin size="large" />;
|
|
||||||
} else if ((user && !onlyLoggedOut) || (!user && onlyLoggedOut)) {
|
|
||||||
return <Route {...routeProps} />;
|
return <Route {...routeProps} />;
|
||||||
} else {
|
} else {
|
||||||
// TODO: redirect to desired destination after auth
|
// TODO: redirect to desired destination after auth
|
||||||
|
@ -33,5 +28,4 @@ class AuthRoute extends React.Component<Props> {
|
||||||
|
|
||||||
export default connect((state: AppState) => ({
|
export default connect((state: AppState) => ({
|
||||||
user: state.auth.user,
|
user: state.auth.user,
|
||||||
isAuthingUser: state.auth.isAuthingUser,
|
|
||||||
}))(AuthRoute);
|
}))(AuthRoute);
|
||||||
|
|
|
@ -50,7 +50,7 @@ class Comment extends React.Component<Props> {
|
||||||
public render(): React.ReactNode {
|
public render(): React.ReactNode {
|
||||||
const { comment, isSignedIn, isPostCommentPending } = this.props;
|
const { comment, isSignedIn, isPostCommentPending } = this.props;
|
||||||
const { isReplying, reply } = this.state;
|
const { isReplying, reply } = this.state;
|
||||||
const authorPath = `/profile/${comment.author.accountAddress}`;
|
const authorPath = `/profile/${comment.author.userid}`;
|
||||||
return (
|
return (
|
||||||
<div className="Comment">
|
<div className="Comment">
|
||||||
<div className="Comment-info">
|
<div className="Comment-info">
|
||||||
|
|
|
@ -10,8 +10,6 @@ import './Final.less';
|
||||||
|
|
||||||
interface StateProps {
|
interface StateProps {
|
||||||
form: AppState['create']['form'];
|
form: AppState['create']['form'];
|
||||||
crowdFundError: AppState['web3']['crowdFundError'];
|
|
||||||
crowdFundCreatedAddress: AppState['web3']['crowdFundCreatedAddress'];
|
|
||||||
createdProposal: ProposalWithCrowdFund | null;
|
createdProposal: ProposalWithCrowdFund | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -27,19 +25,21 @@ class CreateFinal extends React.Component<Props> {
|
||||||
}
|
}
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
const { crowdFundError, crowdFundCreatedAddress, createdProposal } = this.props;
|
const { createdProposal } = this.props;
|
||||||
let content;
|
let content;
|
||||||
if (crowdFundError) {
|
// TODO - handle errors?
|
||||||
content = (
|
// if (crowdFundError) {
|
||||||
<div className="CreateFinal-message is-error">
|
// content = (
|
||||||
<Icon type="close-circle" />
|
// <div className="CreateFinal-message is-error">
|
||||||
<div className="CreateFinal-message-text">
|
// <Icon type="close-circle" />
|
||||||
Something went wrong during creation: "{crowdFundError}"{' '}
|
// <div className="CreateFinal-message-text">
|
||||||
<a onClick={this.create}>Click here</a> to try again.
|
// Something went wrong during creation: "{crowdFundError}"{' '}
|
||||||
</div>
|
// <a onClick={this.create}>Click here</a> to try again.
|
||||||
</div>
|
// </div>
|
||||||
);
|
// </div>
|
||||||
} else if (crowdFundCreatedAddress && createdProposal) {
|
// );
|
||||||
|
// } else
|
||||||
|
if (createdProposal) {
|
||||||
content = (
|
content = (
|
||||||
<div className="CreateFinal-message is-success">
|
<div className="CreateFinal-message is-success">
|
||||||
<Icon type="check-circle" />
|
<Icon type="check-circle" />
|
||||||
|
@ -72,12 +72,7 @@ class CreateFinal extends React.Component<Props> {
|
||||||
export default connect<StateProps, DispatchProps, {}, AppState>(
|
export default connect<StateProps, DispatchProps, {}, AppState>(
|
||||||
(state: AppState) => ({
|
(state: AppState) => ({
|
||||||
form: state.create.form,
|
form: state.create.form,
|
||||||
crowdFundError: state.web3.crowdFundError,
|
createdProposal: getProposalByAddress(state, 'notanaddress'),
|
||||||
crowdFundCreatedAddress: state.web3.crowdFundCreatedAddress,
|
|
||||||
createdProposal: getProposalByAddress(
|
|
||||||
state,
|
|
||||||
state.web3.crowdFundCreatedAddress || '',
|
|
||||||
),
|
|
||||||
}),
|
}),
|
||||||
{
|
{
|
||||||
createProposal: createActions.createProposal,
|
createProposal: createActions.createProposal,
|
||||||
|
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue