User Auth Conversion (#19)

This commit is contained in:
AMStrix 2018-12-14 13:36:22 -06:00 committed by Daniel Ternyak
parent 163ff62433
commit 50cc377b48
170 changed files with 1657 additions and 11452 deletions

3
.gitignore vendored
View File

@ -1,2 +1,3 @@
.idea
contract/build
.DS_Store
.vscode

View File

@ -15,22 +15,6 @@ matrix:
before_install:
- cd backend/
- 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
script:
- 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

View File

@ -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).
[Grant.io](http://grant.io) is under heavy development, and is not considered stable. Use at your own risk!
This is a collection of the various services and components that make up the ZCash Grant System.
### Setup
__________________
##### Docker
To get setup quickly, simply use docker-compose to spin up the necessary services
TBD
---
##### Locally
Alternatively, run the backend and front-end services locally.
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.
### Testing
To run tests across all components simultaneously, use the following command
TBD
### Deployment
TBD

View File

@ -11,8 +11,6 @@ This is the admin component of [Grant.io](http://grant.io).
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:
```bash

View File

@ -97,7 +97,6 @@
"tslint-react": "^3.6.0",
"typescript": "3.0.3",
"url-loader": "^1.1.1",
"web3": "^1.0.0-beta.34",
"webpack": "^4.19.0",
"webpack-cli": "^3.1.0",
"webpack-dev-server": "^3.1.8",
@ -107,7 +106,6 @@
"devDependencies": {
"@types/bn.js": "4.11.1",
"@types/ethereumjs-util": "5.2.0",
"@types/query-string": "6.1.0",
"@types/web3": "1.0.3"
"@types/query-string": "6.1.0"
}
}

View File

@ -14,17 +14,8 @@ class Home extends React.Component {
<div className="Home">
<h1>Home</h1>
<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>proposal count: {proposalCount}</div>
</>
)}
<div>user count: {userCount}</div>
<div>proposal count: {proposalCount}</div>
</div>
);
}

View File

@ -18,16 +18,6 @@
left: 216px;
z-index: 5;
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 {
@ -114,83 +104,5 @@
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;
}
}
}
}

View File

@ -1,17 +1,11 @@
import React from 'react';
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 Showdown from 'showdown';
import moment from 'moment';
import store from 'src/store';
import {
Proposal,
Contract,
ContractMethod as TContractMethod,
ContractMilestone,
ContractContributor,
} from 'src/types';
import { Proposal } from 'src/types';
import './index.less';
import Field from 'components/Field';
import { Link } from 'react-router-dom';
@ -52,9 +46,6 @@ class ProposalsNaked extends React.Component<Props> {
icon="reload"
onClick={() => store.fetchProposals()}
/>
<div className="Proposals-controls-status">
{store.crowdFundGeneralStatus}
</div>
</div>
<ProposalItem key={singleProposal.proposalId} {...singleProposal} />
</div>
@ -68,7 +59,6 @@ class ProposalsNaked extends React.Component<Props> {
<div className="Proposals">
<div className="Proposals-controls">
<Button title="refresh" icon="reload" onClick={() => store.fetchProposals()} />
<div className="Proposals-controls-status">{store.crowdFundGeneralStatus}</div>
</div>
{proposals.length === 0 && <div>no proposals</div>}
{proposals.length > 0 &&
@ -191,34 +181,6 @@ class ProposalItemNaked extends React.Component<Proposal> {
</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>
);
@ -229,182 +191,5 @@ class ProposalItemNaked extends React.Component<Proposal> {
}
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));
export default Proposals;

View File

@ -1,13 +1,6 @@
import { cloneDeep } from 'lodash';
import Web3 from 'web3';
import { store } from 'react-easy-state';
import axios, { AxiosError } from 'axios';
import { User, Proposal, INITIAL_CONTRACT, Contract, ContractMethodInput } from './types';
import {
initializeWeb3,
populateProposalContract,
proposalContractSend,
} from './web3helper';
import { User, Proposal } from './types';
// API
const api = axios.create({
@ -50,7 +43,6 @@ async function deleteUser(id: string) {
async function fetchProposals() {
const { data } = await api.get('/admin/proposals');
data.forEach((p: Proposal) => (p.contract = cloneDeep(INITIAL_CONTRACT)));
return data;
}
@ -73,12 +65,6 @@ const app = store({
users: [] as User[],
proposalsFetched: false,
proposals: [] as Proposal[],
web3Type: '',
web3Enabled: false,
ethNetId: -1,
ethAccount: '',
crowdFundFactoryDefinitionStatus: '',
crowdFundGeneralStatus: 'idle',
removeGeneralError(i: number) {
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) {
try {
await deleteProposal(id);
@ -188,8 +156,5 @@ function handleApiError(e: AxiosError) {
app.checkLogin();
window.setInterval(app.checkLogin, 10000);
let web3: null | Web3 = null;
initializeWeb3(app).then(x => (web3 = x));
export type TApp = typeof app;
export default app;

View File

@ -23,7 +23,6 @@ export interface Proposal {
team: User[];
comments: Comment[];
contractStatus: string;
contract: Contract;
}
export interface Comment {
commentId: string;
@ -41,89 +40,3 @@ export interface User {
proposals: Proposal[];
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;

View File

@ -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;
}

View File

@ -20,7 +20,6 @@
"lib": ["dom", "es2017"],
"paths": {
"src/*": ["./*"],
"contracts/*": ["../../contract/build/*"],
"components/*": ["./components/*"],
"styles/*": ["./styles/*"]
}

View File

@ -113,7 +113,6 @@ module.exports = {
// tsconfig.compilerOptions.paths should sync with these
alias: {
src: path.resolve(__dirname, 'src'),
contracts: path.resolve(__dirname, '../contract/build'),
components: path.resolve(__dirname, 'src/components'),
styles: path.resolve(__dirname, 'src/styles'),
},

File diff suppressed because it is too large Load Diff

View File

@ -7,17 +7,11 @@ REDISTOGO_URL="redis://localhost:6379"
SECRET_KEY="not-so-secret"
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_RELEASE="optional, overrides git hash"
UPLOAD_DIRECTORY = "/tmp"
UPLOAD_URL = "http://localhost:5000" # for constructing download url
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

View File

@ -29,8 +29,8 @@ database tables and perform the initial migration
flask db migrate
flask db upgrade
## Running the App
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
@ -46,9 +46,8 @@ To deploy
export DATABASE_URL="<YOUR DATABASE URL>"
flask run # start the flask server
In your production environment, make sure the ``FLASK_DEBUG`` environment
variable is unset or is set to ``0``.
In your production environment, make sure the `FLASK_DEBUG` environment
variable is unset or is set to `0`.
## Shell
@ -56,8 +55,7 @@ To open the interactive shell, run
flask shell
By default, you will have access to the flask ``app``.
By default, you will have access to the flask `app`.
## Running Tests
@ -65,7 +63,6 @@ To run all tests, run
flask test
## Migrations
Whenever a database migration needs to be made. Run the following commands
@ -78,18 +75,65 @@ This will generate a new migration script. Then run
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
To create a proposal, run
flask create_proposal "FUNDING_REQUIRED" 1 123 "My Awesome Proposal" "### Hi! I have a great proposal"
## External Services
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:
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`

View File

@ -3,11 +3,12 @@
from flask import Flask
from flask_cors import CORS
from flask_sslify import SSLify
from flask_security import SQLAlchemyUserDatastore
from sentry_sdk.integrations.flask import FlaskIntegration
import sentry_sdk
from grant import commands, proposal, user, comment, milestone, admin, email, web3 as web3module
from grant.extensions import bcrypt, migrate, db, ma, mail, web3
from grant import commands, proposal, user, comment, milestone, admin, email
from grant.extensions import bcrypt, migrate, db, ma, mail, security
from grant.settings import SENTRY_RELEASE, ENV
@ -36,9 +37,11 @@ def register_extensions(app):
migrate.init_app(app, db)
ma.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)
return None
@ -51,9 +54,6 @@ def register_blueprints(app):
app.register_blueprint(milestone.views.blueprint)
app.register_blueprint(admin.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):

View File

@ -15,11 +15,19 @@ TEST_PATH = os.path.join(PROJECT_ROOT, "tests")
@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."""
import pytest
rv = pytest.main([TEST_PATH, "--verbose"])
if test:
rv = pytest.main([TEST_PATH, "--verbose", "-k", test])
else:
rv = pytest.main([TEST_PATH, "--verbose"])
exit(rv)

View File

@ -9,6 +9,7 @@ default_template_args = {
'unsubscribe_url': 'https://grant.io/unsubscribe',
}
def signup_info(email_args):
return {
'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.',
}
def team_invite_info(email_args):
return {
'subject': '{} has invited you to a project'.format(email_args['inviter'].display_name),
@ -23,6 +25,7 @@ def team_invite_info(email_args):
'preview': 'Youve been invited to the "{}" project team'.format(email_args['proposal'].title)
}
get_info_lookup = {
'signup': signup_info,
'team_invite': team_invite_info

View File

@ -5,11 +5,11 @@ from flask_marshmallow import Marshmallow
from flask_migrate import Migrate
from flask_sqlalchemy import SQLAlchemy
from flask_sendgrid import SendGrid
from flask_web3 import FlaskWeb3
from flask_security import Security
bcrypt = Bcrypt()
db = SQLAlchemy()
migrate = Migrate()
ma = Marshmallow()
mail = SendGrid()
web3 = FlaskWeb3()
security = Security()

View File

@ -53,7 +53,6 @@ class ProposalTeamInvite(db.Model):
def get_pending_for_user(user):
return ProposalTeamInvite.query.filter(
ProposalTeamInvite.accepted == None,
(func.lower(user.account_address) == func.lower(ProposalTeamInvite.address)) |
(func.lower(user.email_address) == func.lower(ProposalTeamInvite.address))
).all()

View File

@ -10,10 +10,9 @@ from grant.comment.models import Comment, comment_schema, comments_schema
from grant.milestone.models import Milestone
from grant.user.models import User, SocialMedia, Avatar
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.misc import is_email, make_url
from grant.web3.proposal import read_proposal
from grant.utils.misc import is_email
from .models import(
Proposal,
proposals_schema,
@ -53,7 +52,7 @@ def get_proposal_comments(proposal_id):
proposal = Proposal.query.filter_by(id=proposal_id).first()
if not proposal:
return {"message": "No proposal matching id"}, 404
# Only pull top comments, replies will be attached to them
comments = Comment.query.filter_by(proposal_id=proposal_id, parent_comment_id=None)
num_comments = Comment.query.filter_by(proposal_id=proposal_id).count()
@ -65,12 +64,10 @@ def get_proposal_comments(proposal_id):
@blueprint.route("/<proposal_id>/comments", methods=["POST"])
@requires_sm
@requires_auth
@endpoint.api(
parameter('comment', type=str, required=True),
parameter('parentCommentId', type=int, required=False),
parameter('signedMessage', type=str, required=True),
parameter('rawTypedData', type=str, required=True)
parameter('parentCommentId', type=int, required=False)
)
def post_proposal_comments(proposal_id, comment, parent_comment_id, signed_message, raw_typed_data):
# Make sure proposal exists
@ -84,24 +81,6 @@ def post_proposal_comments(proposal_id, comment, parent_comment_id, signed_messa
if not parent:
return {"message": "Parent comment doesnt exist"}, 400
# Make sure comment content matches
typed_data = ast.literal_eval(raw_typed_data)
if comment != typed_data['message']['comment']:
return {"message": "Comment doesnt 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
comment = Comment(
proposal_id=proposal_id,
@ -123,27 +102,21 @@ def get_proposals(stage):
if stage:
proposals = (
Proposal.query.filter_by(status="LIVE", stage=stage)
.order_by(Proposal.date_created.desc())
.all()
.order_by(Proposal.date_created.desc())
.all()
)
else:
proposals = Proposal.query.order_by(Proposal.date_created.desc()).all()
dumped_proposals = proposals_schema.dump(proposals)
try:
for p in dumped_proposals:
proposal_contract = read_proposal(p['proposal_address'])
p['crowd_fund'] = proposal_contract
filtered_proposals = list(filter(lambda p: p['crowd_fund'] is not None, dumped_proposals))
return filtered_proposals
except Exception as e:
print(e)
print(traceback.format_exc())
return {"message": "Oops! Something went wrong."}, 500
return dumped_proposals
# except Exception as e:
# print(e)
# print(traceback.format_exc())
# return {"message": "Oops! Something went wrong."}, 500
@blueprint.route("/drafts", methods=["POST"])
@requires_sm
@requires_auth
@endpoint.api()
def make_proposal_draft():
proposal = Proposal.create(status="DRAFT")
@ -154,7 +127,7 @@ def make_proposal_draft():
@blueprint.route("/drafts", methods=["GET"])
@requires_sm
@requires_auth
@endpoint.api()
def get_proposal_drafts():
proposals = (
@ -167,6 +140,7 @@ def get_proposal_drafts():
)
return proposals_schema.dump(proposals), 200
@blueprint.route("/<proposal_id>", methods=["PUT"])
@requires_team_member_auth
@endpoint.api(
@ -202,7 +176,7 @@ def update_proposal(milestones, proposal_id, **kwargs):
proposal_id=g.current_proposal.id
)
db.session.add(m)
# Commit
db.session.commit()
return proposal_schema.dump(g.current_proposal), 200
@ -278,6 +252,7 @@ def post_proposal_update(proposal_id, title, content):
dumped_update = proposal_update_schema.dump(update)
return dumped_update, 201
@blueprint.route("/<proposal_id>/invite", methods=["POST"])
@requires_team_member_auth
@endpoint.api(
@ -294,7 +269,7 @@ def post_proposal_team_invite(proposal_id, address):
# Send email
# TODO: Move this to some background task / after request action
email = address
user = User.get_by_identifier(email_address=address, account_address=address)
user = User.get_by_email(email_address=address)
if user:
email = user.email_address
if is_email(email):
@ -352,7 +327,7 @@ def get_proposal_contribution(proposal_id, contribution_id):
@blueprint.route("/<proposal_id>/contributions", methods=["POST"])
@requires_sm
@requires_auth
@endpoint.api(
parameter('txId', type=str, required=True),
parameter('fromAddress', type=str, required=True),

View File

@ -9,21 +9,20 @@ environment variables.
import subprocess
from environs import Env
def git_revision_short_hash():
try:
return subprocess.check_output(['git', 'rev-parse', '--short', 'HEAD'])
except subprocess.CalledProcessError:
return 0
env = Env()
env.read_env()
ENV = env.str("FLASK_ENV", default="production")
DEBUG = ENV == "development"
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")
QUEUES = ["default"]
SECRET_KEY = env.str("SECRET_KEY")
@ -34,10 +33,13 @@ CACHE_TYPE = "simple" # Can be "memcached", "redis", etc.
SQLALCHEMY_TRACK_MODIFICATIONS = False
SENDGRID_API_KEY = env.str("SENDGRID_API_KEY", default="")
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_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)
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

View File

@ -1,16 +1,16 @@
<p style="margin: 0;">
Youve been invited by <strong>{{ args.inviter.display_name }}</strong>
to join the team for
<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
site below.
Youve been invited by <strong>{{ args.inviter.display_name }}</strong> to
join the team for
<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 site below.
</p>
{% if not args.user %}
<p style="margin: 20px 0 0;">
It looks like you don't yet have a Grant.io account, so you'll need to
sign up first before you can join the team.
</p>
<p style="margin: 20px 0 0;">
It looks like you don't yet have a Grant.io account, so you'll need to sign up
first before you can join the team.
</p>
{% endif %}
<table width="100%" border="0" cellspacing="0" cellpadding="0">
@ -19,16 +19,17 @@
<table border="0" cellspacing="0" cellpadding="0">
<tr>
<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;">
{% if args.user %}
See invitation
{% else %}
Get started
{% endif %}
<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;"
>
{% if args.user %} See invitation {% else %} Get started {% endif
%}
</a>
</td>
</tr>
</table>
</td>
</tr>
</table>
</table>

View File

@ -11,12 +11,9 @@ def delete_user(identity):
print(identity)
user = None
if str.isdigit(identity):
user = User.query.filter(id=identity).first()
user = User.get_by_id(identity)
else:
user = User.query.filter(
(User.account_address == identity) |
(User.email_address == identity)
).first()
user = User.get_by_email(identity)
if user:
db.session.delete(user)

View File

@ -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.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.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):
@ -24,50 +48,66 @@ class Avatar(db.Model):
__tablename__ = "avatar"
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 = 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):
self.image_url = image_url
self.user_id = user_id
class User(db.Model):
class User(db.Model, UserMixin):
__tablename__ = "user"
id = db.Column(db.Integer(), primary_key=True)
email_address = db.Column(db.String(255), unique=True, nullable=True)
account_address = db.Column(db.String(255), unique=True, nullable=True)
email_address = db.Column(db.String(255), unique=True, nullable=False)
password = db.Column(db.String(255), unique=False, nullable=False)
display_name = 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")
comments = db.relationship(Comment, backref="user", lazy=True)
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
def __init__(self, email_address=None, account_address=None, display_name=None, title=None):
if not email_address and not account_address:
raise ValueError("Either email_address or account_address is required to create a user")
def __init__(
self,
email_address,
password,
active,
roles,
display_name=None,
title=None,
):
self.email_address = email_address
self.account_address = account_address
self.display_name = display_name
self.title = title
self.password = password
@staticmethod
def create(email_address=None, account_address=None, display_name=None, title=None, _send_email=True):
user = User(
account_address=account_address,
def create(email_address=None, password=None, display_name=None, title=None, _send_email=True):
user = security.datastore.create_user(
email_address=email_address,
password=hash_password(password),
display_name=display_name,
title=title
)
db.session.add(user)
db.session.flush()
security.datastore.commit()
# Setup & send email verification
ev = EmailVerification(user_id=user.id)
@ -83,21 +123,33 @@ class User(db.Model):
return user
@staticmethod
def get_by_identifier(email_address: str = None, account_address: str = None):
if not email_address and not account_address:
raise ValueError("Either email_address or account_address is required to get a user")
def get_by_id(user_id: int):
return security.datastore.get_user(user_id)
@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 Meta:
model = User
# Fields to expose
fields = (
"account_address",
"title",
"email_address",
"social_medias",
@ -113,9 +165,11 @@ class UserSchema(ma.Schema):
def get_userid(self, obj):
return obj.id
user_schema = UserSchema()
users_schema = UserSchema(many=True)
class SocialMediaSchema(ma.Schema):
class Meta:
model = SocialMedia
@ -125,7 +179,7 @@ class SocialMediaSchema(ma.Schema):
"service",
"username",
)
url = ma.Method("get_url")
service = ma.Method("get_service")
username = ma.Method("get_username")

View File

@ -10,27 +10,14 @@ from grant.proposal.models import (
invites_with_proposal_schema,
user_proposals_schema
)
from grant.utils.auth import requires_sm, requires_same_user_auth, verify_signed_auth, BadSignatureException
from grant.utils.upload import save_avatar, send_upload, remove_avatar
from grant.web3.proposal import read_user_proposal
from grant.settings import UPLOAD_URL
from grant.utils.auth import requires_auth, requires_same_user_auth
from grant.utils.upload import remove_avatar, sign_avatar_upload, AvatarException
from .models import User, SocialMedia, Avatar, users_schema, user_schema, db
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"])
@endpoint.api(
parameter('proposalId', type=str, required=False)
@ -52,136 +39,121 @@ def get_users(proposal_id):
@blueprint.route("/me", methods=["GET"])
@requires_sm
@requires_auth
@endpoint.api()
def get_me():
dumped_user = user_schema.dump(g.current_user)
return dumped_user
@blueprint.route("/<user_identity>", methods=["GET"])
@blueprint.route("/<user_id>", methods=["GET"])
@endpoint.api(
parameter("withProposals", type=bool, required=False),
parameter("withComments", type=bool, required=False),
parameter("withFunded", type=bool, required=False)
)
def get_user(user_identity, with_proposals, with_comments, with_funded):
user = User.get_by_identifier(email_address=user_identity, account_address=user_identity)
def get_user(user_id, with_proposals, with_comments, with_funded):
user = User.get_by_id(user_id)
if user:
result = user_schema.dump(user)
if with_proposals:
proposals = Proposal.get_by_user(user)
proposals_dump = user_proposals_schema.dump(proposals)
result["createdProposals"] = populate_user_proposals_cfs(proposals_dump)
result["createdProposals"] = proposals_dump
if with_funded:
contributions = Proposal.get_by_user_contribution(user)
contributions_dump = user_proposals_schema.dump(contributions)
result["fundedProposals"] = populate_user_proposals_cfs(contributions_dump)
result["fundedProposals"] = contributions_dump
if with_comments:
comments = Comment.get_by_user(user)
comments_dump = user_comments_schema.dump(comments)
result["comments"] = comments_dump
return result
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
@blueprint.route("/", methods=["POST"])
@endpoint.api(
parameter('accountAddress', type=str, required=True),
parameter('emailAddress', type=str, required=True),
parameter('password', type=str, required=True),
parameter('displayName', type=str, required=True),
parameter('title', type=str, required=True),
parameter('signedMessage', type=str, required=True),
parameter('rawTypedData', type=str, required=True)
parameter('title', type=str, required=True)
)
def create_user(
account_address,
email_address,
password,
display_name,
title,
signed_message,
raw_typed_data
title
):
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:
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(
account_address=account_address,
email_address=email_address,
password=password,
display_name=display_name,
title=title
)
user.login()
result = user_schema.dump(user)
return result, 201
@blueprint.route("/auth", methods=["POST"])
@endpoint.api(
parameter('accountAddress', type=str, required=True),
parameter('signedMessage', type=str, required=True),
parameter('rawTypedData', type=str, required=True)
parameter('email', type=str, required=True),
parameter('password', type=str, required=True)
)
def auth_user(account_address, signed_message, raw_typed_data):
existing_user = User.get_by_identifier(account_address=account_address)
def auth_user(email, password):
existing_user = User.get_by_email(email)
if not existing_user:
return {"message": "No user exists with that address"}, 400
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
return {"message": "No user exists with that email"}, 400
if not existing_user.check_password(password):
return {"message": "Invalid password"}, 403
existing_user.login()
return user_schema.dump(existing_user)
@blueprint.route("/avatar", methods=["POST"])
@requires_sm
@blueprint.route("/password", methods=["PUT"])
@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()
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
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:
filename = save_avatar(file, user.id)
return {"url": "{0}/api/v1/users/avatar/{1}".format(UPLOAD_URL, filename)}
except Exception as e:
signed_post = sign_avatar_upload(mimetype, user.id)
return signed_post
except AvatarException as e:
return {"message": str(e)}, 400
@blueprint.route("/avatar/<filename>", methods=["GET"])
def get_avatar(filename):
return send_upload(filename)
@blueprint.route("/avatar", methods=["DELETE"])
@requires_sm
@requires_auth
@endpoint.api(
parameter('url', type=str, required=True)
)
@ -190,8 +162,8 @@ def delete_avatar(url):
remove_avatar(url, user.id)
@blueprint.route("/<user_identity>", methods=["PUT"])
@requires_sm
@blueprint.route("/<user_id>", methods=["PUT"])
@requires_auth
@requires_same_user_auth
@endpoint.api(
parameter('displayName', type=str, required=True),
@ -199,7 +171,7 @@ def delete_avatar(url):
parameter('socialMedias', type=list, 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
if display_name is not None:
@ -223,29 +195,29 @@ def update_user(user_identity, display_name, title, social_medias, avatar):
new_avatar = Avatar(image_url=avatar, user_id=user.id)
db.session.add(new_avatar)
old_avatar_url = db_avatar and db_avatar.image_url
if old_avatar_url and old_avatar_url != new_avatar.image_url:
remove_avatar(old_avatar_url, user.id)
old_avatar_url = db_avatar and db_avatar.image_url
if old_avatar_url and old_avatar_url != avatar:
remove_avatar(old_avatar_url, user.id)
db.session.commit()
result = user_schema.dump(user)
return result
@blueprint.route("/<user_identity>/invites", methods=["GET"])
@blueprint.route("/<user_id>/invites", methods=["GET"])
@requires_same_user_auth
@endpoint.api()
def get_user_invites(user_identity):
def get_user_invites(user_id):
invites = ProposalTeamInvite.get_pending_for_user(g.current_user)
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
@endpoint.api(
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()
if not invite:
return {"message": "No invite found with id {}".format(invite_id)}, 404

View File

@ -3,107 +3,48 @@ import json
from functools import wraps
import requests
from flask_security.core import current_user
from flask import request, g, jsonify
from itsdangerous import SignatureExpired, BadSignature
from itsdangerous import TimedJSONWebSignatureSerializer as Serializer
import sentry_sdk
from grant.settings import SECRET_KEY, AUTH_URL
from grant.settings import SECRET_KEY
from ..proposal.models import Proposal
from ..user.models import User
TWO_WEEKS = 1209600
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):
def requires_auth(f):
@wraps(f)
def decorated(*args, **kwargs):
signature = request.headers.get('MsgSignature', None)
typed_data = request.headers.get('RawTypedData', None)
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:
scope.user = {
"id": user.id,
}
return f(*args, **kwargs)
return jsonify(message="Authentication is required to access this resource"), 401
if not current_user.is_authenticated:
return jsonify(message="Authentication is required to access this resource"), 401
g.current_user = current_user
with sentry_sdk.configure_scope() as scope:
scope.user = {
"id": current_user.id,
}
return f(*args, **kwargs)
return decorated
# Decorator that requires you to be the user you're interacting with
def requires_same_user_auth(f):
@wraps(f)
def decorated(*args, **kwargs):
user_identity = kwargs["user_identity"]
if not user_identity:
return jsonify(message="Decorator requires_same_user_auth requires path variable <user_identity>"), 500
user_id = kwargs["user_id"]
if not user_id:
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:
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:
return jsonify(message="You are not authorized to modify this user"), 403
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):
@wraps(f)
def decorated(*args, **kwargs):
@ -121,4 +62,4 @@ def requires_team_member_auth(f):
g.current_proposal = proposal
return f(*args, **kwargs)
return requires_sm(decorated)
return requires_auth(decorated)

View File

@ -1,54 +1,63 @@
import os
import re
from hashlib import md5
from werkzeug.utils import secure_filename
from flask import send_from_directory
from grant.settings import UPLOAD_DIRECTORY
import uuid
import boto3
from flask import current_app
IMAGE_MIME_TYPES = set(['image/png', 'image/jpg', 'image/gif'])
AVATAR_MAX_SIZE = 2 * 1024 * 1024 # 2MB
class FileValidationException(Exception):
class AvatarException(Exception):
pass
def allowed_avatar_file(file):
if file.mimetype not in IMAGE_MIME_TYPES:
raise FileValidationException("Unacceptable file type: {0}".format(file.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)
)
def allowed_avatar_type(mimetype):
if mimetype not in IMAGE_MIME_TYPES:
raise AvatarException("Unacceptable file type: {0}".format(mimetype))
return True
def hash_file(file):
hasher = md5()
buf = file.read()
hasher.update(buf)
file.seek(0)
return hasher.hexdigest()
def extract_avatar_filename(url):
match = re.search(r'avatars/(\d+\.\w+\.\w+)$', url)
if match:
return match.group(1)
else:
raise AvatarException("Unable to extract avatar filename from %s" % url)
def save_avatar(file, user_id):
if file and allowed_avatar_file(file):
ext = file.mimetype.replace('image/', '')
filename = "{0}.{1}.{2}".format(user_id, hash_file(file), ext)
file.save(os.path.join(UPLOAD_DIRECTORY, filename))
return filename
def construct_avatar_url(filename):
S3_BUCKET = current_app.config['S3_BUCKET']
return "https://%s.s3.amazonaws.com/avatars/%s" % (S3_BUCKET, filename)
def remove_avatar(url, user_id):
match = re.search(r'/api/v1/users/avatar/(\d+.\w+.\w+)', url)
if match:
filename = match.group(1)
if filename.startswith(str(user_id) + '.'):
os.remove(os.path.join(UPLOAD_DIRECTORY, filename))
S3_BUCKET = current_app.config['S3_BUCKET']
filename = extract_avatar_filename(url)
user_match = re.search(r'^(\d+)\.\w+\.\w+$', filename)
if user_match and user_match.group(1) == str(user_id):
s3 = boto3.resource('s3')
s3.Object(S3_BUCKET, 'avatars/' + filename).delete()
def send_upload(filename):
return send_from_directory(UPLOAD_DIRECTORY, secure_filename(filename))
def sign_avatar_upload(mimetype, user_id):
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)
}

View File

@ -1 +0,0 @@
from . import dev_contracts

View File

@ -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)

View File

@ -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

View File

@ -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

View File

@ -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 ###

View File

@ -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 ###

View File

@ -57,11 +57,14 @@ sendgrid==5.3.0
# input validation
flask-yolo2API==0.2.6
#web3
flask-web3==0.1.1
web3==4.8.1
#sentry
sentry-sdk[flask]==0.5.5
#boto3 (AWS sdk)
boto3==1.9.52
# force SSL
Flask-SSLify==0.1.5
# sessions
Flask-Security==3.0.0

View File

@ -1,3 +1,4 @@
import json
from flask_testing import TestCase
from grant.app import create_app
@ -21,7 +22,7 @@ class BaseTestConfig(TestCase):
def tearDown(self):
db.session.remove()
db.drop_all()
def assertStatus(self, response, status_code, message=None):
"""
Overrides TestCase's default to print out response JSON.
@ -33,6 +34,7 @@ class BaseTestConfig(TestCase):
assert_status = assertStatus
class BaseUserConfig(BaseTestConfig):
headers = {
"MsgSignature": message["sig"],
@ -42,8 +44,8 @@ class BaseUserConfig(BaseTestConfig):
def setUp(self):
super(BaseUserConfig, self).setUp()
self.user = User.create(
account_address=test_user["accountAddress"],
email_address=test_user["emailAddress"],
password=test_user["password"],
display_name=test_user["displayName"],
title=test_user["title"],
)
@ -52,19 +54,32 @@ class BaseUserConfig(BaseTestConfig):
avatar = Avatar(image_url=test_user["avatar"]["link"], user_id=self.user.id)
db.session.add(avatar)
self.user_password = test_user["password"]
self.other_user = User.create(
account_address=test_other_user["accountAddress"],
email_address=test_other_user["emailAddress"],
password=test_other_user["password"],
display_name=test_other_user["displayName"],
title=test_other_user["title"]
)
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):
User.query.filter_by(id=self.user.id).delete()
db.session.commit()
class BaseProposalCreatorConfig(BaseUserConfig):
def setUp(self):
super().setUp()

View File

@ -7,19 +7,10 @@ from ..test_data import test_proposal, test_user
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):
invite_res = self.app.post(
"/api/v1/proposals/{}/invite".format(self.proposal.id),
data=json.dumps({ "address": "test@test.test" }),
data=json.dumps({"address": "test@test.test"}),
headers=self.headers,
content_type='application/json'
)
@ -29,7 +20,7 @@ class TestAPI(BaseProposalCreatorConfig):
def test_no_auth_create_invite_fails(self):
invite_res = self.app.post(
"/api/v1/proposals/{}/invite".format(self.proposal.id),
data=json.dumps({ "address": "0x8B0B72F8bDE212991135668922fD5acE557DE6aB" }),
data=json.dumps({"address": "0x8B0B72F8bDE212991135668922fD5acE557DE6aB"}),
content_type='application/json'
)
self.assertStatus(invite_res, 401)
@ -38,7 +29,7 @@ class TestAPI(BaseProposalCreatorConfig):
def test_invalid_proposal_create_invite_fails(self):
invite_res = self.app.post(
"/api/v1/proposals/12345/invite",
data=json.dumps({ "address": "0x8B0B72F8bDE212991135668922fD5acE557DE6aB" }),
data=json.dumps({"address": "0x8B0B72F8bDE212991135668922fD5acE557DE6aB"}),
headers=self.headers,
content_type='application/json'
)
@ -58,7 +49,7 @@ class TestAPI(BaseProposalCreatorConfig):
headers=self.headers
)
self.assertStatus(delete_res, 202)
# Rejects if unknown proposal
def test_invalid_invite_delete_invite(self):
delete_res = self.app.delete(
@ -66,14 +57,14 @@ class TestAPI(BaseProposalCreatorConfig):
headers=self.headers
)
self.assertStatus(delete_res, 404)
# Rejects if not authorized
def test_no_auth_delete_invite_fails(self):
delete_res = self.app.delete(
"/api/v1/proposals/{}/invite/12345".format(self.proposal)
)
self.assertStatus(delete_res, 401)
# Rejects if the invite was already accepted
def test_accepted_invite_delete_invite(self):
address = "0x8B0B72F8bDE212991135668922fD5acE557DE6aB"

View File

@ -8,5 +8,8 @@ DEBUG_TB_ENABLED = False
CACHE_TYPE = 'simple' # Can be "memcached", "redis", etc.
SQLALCHEMY_TRACK_MODIFICATIONS = False
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"

View File

@ -26,32 +26,32 @@ message = {
{
"name": "name",
"type": "string"
},
{
"name": "version",
"type": "string"
},
{
"name": "chainId",
"type": "uint256"
}
]
},
"message": {
"message": "I am proving the identity of 0x6bEeA1Cef016c23e292381b6FcaeC092960e41aa on Grant.io",
"time": "Tue, 27 Nov 2018 19:02:04 GMT"
},
"primaryType": "authorization"
}
},
{
"name": "version",
"type": "string"
},
{
"name": "chainId",
"type": "uint256"
}
]
},
"message": {
"message": "I am proving the identity of 0x6bEeA1Cef016c23e292381b6FcaeC092960e41aa on Grant.io",
"time": "Tue, 27 Nov 2018 19:02:04 GMT"
},
"primaryType": "authorization"
}
}
test_user = {
"accountAddress": '0x6bEeA1Cef016c23e292381b6FcaeC092960e41aa',
"displayName": 'Groot',
"emailAddress": 'iam@groot.com',
"password": "p4ssw0rd",
"title": 'I am Groot!',
"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": [
{
@ -65,11 +65,10 @@ test_user = {
test_team = [test_user]
test_other_user = {
"accountAddress": "0xA65AD9c6006fe8948E75EC0861A1BAbaD8168DE0",
"displayName": 'Faketoshi',
"emailAddress": 'fake@toshi.com',
"title": 'The Real Fake Satoshi'
# TODO make signed messages for this for more tests
"title": 'The Real Fake Satoshi',
"password": 'n4k0m0t0'
}
milestones = [
@ -91,8 +90,8 @@ test_proposal = {
"milestones": milestones,
"category": random.choice(CATEGORIES),
"target": "123.456",
"payoutAddress": test_team[0]["accountAddress"],
"trustees": [test_team[0]["accountAddress"]],
"payoutAddress": "123",
"trustees": ["123"],
"deadlineDuration": 100,
"voteDuration": 100
}

View File

@ -8,22 +8,6 @@ from ..test_data import test_proposal, test_user
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):
invite = ProposalTeamInvite(
proposal_id=self.proposal.id,
@ -51,65 +35,65 @@ class TestAPI(BaseProposalCreatorConfig):
proposal_id = self.other_proposal.id
invite = ProposalTeamInvite(
proposal_id=proposal_id,
address=self.user.account_address
address=self.user.email_address
)
db.session.add(invite)
db.session.commit()
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,
data=json.dumps({ "response": True }),
data=json.dumps({"response": True}),
content_type='application/json'
)
self.assertStatus(invites_res, 200)
# Make sure we made the team, coach
proposal = Proposal.query.filter_by(id=proposal_id).first()
self.assertTrue(len(proposal.team) == 2) #TODO: More thorough check than length
self.assertTrue(len(proposal.team) == 2) # TODO: More thorough check than length
def test_put_user_invite_response_reject(self):
proposal_id = self.other_proposal.id
invite = ProposalTeamInvite(
proposal_id=proposal_id,
address=self.user.account_address
address=self.user.email_address
)
db.session.add(invite)
db.session.commit()
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,
data=json.dumps({ "response": False }),
data=json.dumps({"response": False}),
content_type='application/json'
)
self.assertStatus(invites_res, 200)
# Make sure we made the team, coach
proposal = Proposal.query.filter_by(id=proposal_id).first()
self.assertTrue(len(proposal.team) == 1) #TODO: More thorough check than length
self.assertTrue(len(proposal.team) == 1) # TODO: More thorough check than length
def test_no_auth_put_user_invite_response(self):
proposal_id = self.other_proposal.id
invite = ProposalTeamInvite(
proposal_id=proposal_id,
address=self.user.account_address
address=self.user.email_address
)
db.session.add(invite)
db.session.commit()
invites_res = self.app.put(
"/api/v1/users/{}/invites/{}/respond".format(self.user.account_address, invite.id),
data=json.dumps({ "response": True }),
"/api/v1/users/{}/invites/{}/respond".format(self.user.id, invite.id),
data=json.dumps({"response": True}),
content_type='application/json'
)
self.assertStatus(invites_res, 401)
def test_invalid_invite_put_user_invite_response(self):
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,
data=json.dumps({ "response": True }),
data=json.dumps({"response": True}),
content_type='application/json'
)
self.assertStatus(invites_res, 404)

View File

@ -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"])

View File

@ -26,10 +26,10 @@ class TestAPI(BaseUserConfig):
self.assertStatus(response, 201)
# 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.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):
users_get_resp = self.app.get(
@ -39,9 +39,9 @@ class TestAPI(BaseUserConfig):
users_json = users_get_resp.json
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(
"/api/v1/users/{}".format(self.user.email_address)
"/api/v1/users/{}".format(self.user.id)
)
users_json = users_get_resp.json
@ -50,18 +50,79 @@ class TestAPI(BaseUserConfig):
self.assertEqual(users_json["socialMedias"][0]["username"], 'groot')
self.assertEqual(users_json["socialMedias"][0]["url"], self.user.social_medias[0].social_media_link)
self.assertEqual(users_json["displayName"], self.user.display_name)
def test_get_single_user_by_account_address(self):
users_get_resp = self.app.get(
"/api/v1/users/{}".format(self.user.account_address)
)
users_json = users_get_resp.json
self.assertEqual(users_json["avatar"]["imageUrl"], self.user.avatar.image_url)
self.assertEqual(users_json["socialMedias"][0]["service"], 'GITHUB')
self.assertEqual(users_json["socialMedias"][0]["username"], 'groot')
self.assertEqual(users_json["socialMedias"][0]["url"], self.user.social_medias[0].social_media_link)
self.assertEqual(users_json["displayName"], self.user.display_name)
def test_user_auth_success(self):
user_auth_resp = self.app.post(
"/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)
def test_user_auth_required(self):
login_resp = self.app.post(
"/api/v1/users/auth",
data=json.dumps({
"email": self.user.email_address,
"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):
# self.user is identical to test_user, should throw
@ -73,14 +134,16 @@ class TestAPI(BaseUserConfig):
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["displayName"] = 'new display name'
updated_user["avatar"] = {}
updated_user["socialMedias"] = []
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),
headers=self.headers,
content_type='application/json'
@ -92,13 +155,15 @@ class TestAPI(BaseUserConfig):
self.assertFalse(len(user_json["socialMedias"]))
self.assertEqual(user_json["displayName"], updated_user["displayName"])
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):
self.login_default_user()
updated_user = animalify(copy.deepcopy(user_schema.dump(self.user)))
updated_user["displayName"] = 'new display name'
del updated_user["avatar"]
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),
headers=self.headers,
content_type='application/json'

View File

@ -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)

View File

@ -1,2 +0,0 @@
INFURA_KEY=key
MNEMONIC=mnemonic

5
contract/.gitignore vendored
View File

@ -1,5 +0,0 @@
node_modules
.idea/
yarn-error.log
.env
build

View File

@ -1 +0,0 @@
8.13.0

View File

@ -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

View File

@ -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));
}
);
});
});

View File

@ -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,
});

View File

@ -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");
_;
}
}

View File

@ -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;
}
}

View File

@ -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);
}
}

View File

@ -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);
}
}

View File

@ -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");
_;
}
}

View File

@ -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;
}
}

View File

@ -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';

View File

@ -1,5 +0,0 @@
var Migrations = artifacts.require("./Migrations.sol");
module.exports = function(deployer) {
deployer.deploy(Migrations);
};

View File

@ -1,5 +0,0 @@
const CrowdFundFactory = artifacts.require("./CrowdFundFactory.sol");
module.exports = function(deployer) {
deployer.deploy(CrowdFundFactory);
};

View File

@ -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"
}
}

View File

@ -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));
});
});

View File

@ -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)
});
});

View File

@ -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));
});
});

View File

@ -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
});

View File

@ -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
};

View File

@ -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"
// }
}
}
};

File diff suppressed because it is too large Load Diff

View File

@ -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)
NO_DEV_TS_CHECK=true
@ -14,9 +11,3 @@ BACKEND_URL=http://localhost:5000
# sentry
SENTRY_DSN=https://PUBLICKEY@sentry.io/PROJECTID
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

1
frontend/.gitignore vendored
View File

@ -8,5 +8,4 @@ dist
*.log
.env
*.pid
client/lib/contracts
.vscode

View File

@ -1,37 +1,23 @@
# 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
1. Install local project dependencies, and also install Truffle & Ganache globally:
```bash
# Local dependencies
yarn
# Global dependencies
yarn global add truffle ganache-cli
```
1. Make sure the `backend` component is running.
2. (In a separate terminal) Run the ganache development blockchain:
```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:
1. Run the webpack dev-server:
```bash
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
@ -40,14 +26,3 @@ This is the front-end component of [Grant.io](http://grant.io).
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
```

View File

@ -9,7 +9,6 @@ const webpackDevMiddleware = require('webpack-dev-middleware');
const webpackHotMiddleware = require('webpack-hot-middleware');
const express = require('express');
const paths = require('../config/paths');
const truffleUtil = require('./truffle-util');
const { logMessage } = require('./utils');
const app = express();
@ -22,8 +21,6 @@ const start = async () => {
rimraf.sync(paths.clientBuild);
rimraf.sync(paths.serverBuild);
await truffleUtil.ethereumCheck();
const [clientConfig, serverConfig] = webpackConfig;
clientConfig.entry.bundle = [
`webpack-hot-middleware/client?path=http://localhost:${WEBPACK_PORT}/__webpack_hmr`,

View File

@ -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();
};

View File

@ -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'));

View File

@ -34,7 +34,7 @@ import 'styles/style.less';
interface RouteConfig extends RouteProps {
route: RouteProps;
template: TemplateProps;
requiresWeb3?: boolean;
requiresAuth?: boolean;
onlyLoggedIn?: boolean;
onlyLoggedOut?: boolean;
}
@ -61,7 +61,7 @@ const routeConfigs: RouteConfig[] = [
},
template: {
title: 'Create a Proposal',
requiresWeb3: true,
requiresAuth: true,
},
onlyLoggedIn: true,
},
@ -74,7 +74,7 @@ const routeConfigs: RouteConfig[] = [
},
template: {
title: 'Browse proposals',
requiresWeb3: false,
requiresAuth: false,
},
},
{
@ -87,7 +87,7 @@ const routeConfigs: RouteConfig[] = [
title: 'Edit proposal',
isFullScreen: true,
hideFooter: true,
requiresWeb3: true,
requiresAuth: true,
},
onlyLoggedIn: true,
},
@ -99,7 +99,7 @@ const routeConfigs: RouteConfig[] = [
},
template: {
title: 'Proposal',
requiresWeb3: false,
requiresAuth: false,
},
},
{

View File

@ -55,27 +55,42 @@ export function getUser(address: string): Promise<{ data: User }> {
});
}
export function createUser(payload: {
accountAddress: string;
emailAddress: string;
displayName: string;
export function createUser(user: {
email: string;
password: string;
name: string;
title: string;
signedMessage: string;
rawTypedData: string;
}): Promise<{ data: User }> {
const payload = {
emailAddress: user.email,
password: user.password,
displayName: user.name,
title: user.title,
};
return axios.post('/api/v1/users', payload);
}
export function authUser(payload: {
accountAddress: string;
signedMessage: string;
rawTypedData: string;
email: string;
password: string;
}): Promise<{ data: User }> {
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 }> {
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> {

View File

@ -3,6 +3,8 @@ import axios from 'axios';
const instance = axios.create({
baseURL: process.env.BACKEND_URL,
headers: {},
// for session cookies
withCredentials: true,
});
instance.interceptors.response.use(

View File

@ -3,7 +3,7 @@ import classnames from 'classnames';
import { Form, Input } from 'antd';
import { InputProps } from 'antd/lib/input';
import { FormItemProps } from 'antd/lib/form';
import { isValidEthAddress } from 'utils/validators';
import { isValidAddress } from 'utils/validators';
import Identicon from 'components/Identicon';
import { DONATION } from 'utils/constants';
import './AddressInput.less';
@ -22,7 +22,7 @@ export default class AddressInput extends React.Component<Props> {
const { value, onChange, className, showIdenticon } = this.props;
const passedFormItemProps = this.props.formItemProps || {};
const passedInputProps = this.props.inputProps || {};
const isInvalid = value && !isValidEthAddress(value);
const isInvalid = value && !isValidAddress(value);
const formItemProps = {
validateStatus: (isInvalid

View File

@ -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;
}
}

View File

@ -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>
);
};

View File

@ -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);
}
}
}

View File

@ -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>
);
}
}

View File

@ -7,36 +7,10 @@
margin: 0 auto;
padding: 1rem;
box-shadow: 0 1px 2px rgba(#000, 0.2);
}
&-identity {
display: flex;
align-items: center;
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;
}
}
& button,
& input {
margin-bottom: 0.5rem;
}
}
@ -51,4 +25,4 @@
max-width: @max-width;
margin: 1rem auto 0;
}
}
}

View File

@ -1,12 +1,8 @@
import React from 'react';
import { connect } from 'react-redux';
import { Button, Alert } from 'antd';
import { Button, Alert, Input } from 'antd';
import { authActions } from 'modules/auth';
import { User } from 'types';
import { AppState } from 'store/reducers';
import { AUTH_PROVIDER } from 'utils/auth';
import Identicon from 'components/Identicon';
import ShortAddress from 'components/ShortAddress';
import './SignIn.less';
interface StateProps {
@ -18,65 +14,77 @@ interface DispatchProps {
authUser: typeof authActions['authUser'];
}
interface OwnProps {
// TODO: Use common use User type instead
user: User;
provider: AUTH_PROVIDER;
reset(): void;
}
type Props = StateProps & DispatchProps;
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() {
const { user, authUserError } = this.props;
const { authUserError, isAuthingUser } = this.props;
const { email, password, isAttemptedAuth } = this.state;
return (
<div className="SignIn">
<div className="SignIn-container">
<div className="SignIn-identity">
<Identicon
address={user.accountAddress}
className="SignIn-identity-identicon"
<form onSubmit={this.handleLogin}>
<Input
value={email}
placeholder="email"
onChange={e => this.setState({ email: e.currentTarget.value })}
size="large"
autoComplete="email"
required={true}
/>
<div className="SignIn-identity-info">
<div className="SignIn-identity-info-name">{user.displayName}</div>
<code className="SignIn-identity-info-address">
<ShortAddress address={user.accountAddress} />
</code>
</div>
</div>
<Button type="primary" size="large" block onClick={this.authUser}>
Prove identity
</Button>
<Input
value={password}
placeholder="password"
type="password"
onChange={e => this.setState({ password: e.currentTarget.value })}
size="large"
autoComplete="current-password"
required={true}
/>
<Button
type="primary"
size="large"
loading={isAuthingUser}
htmlType="submit"
block
>
Sign in
</Button>
</form>
</div>
{authUserError && (
<Alert
className="SignIn-error"
type="error"
message="Failed to sign in"
description={authUserError}
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>
*/}
{isAttemptedAuth &&
authUserError && (
<Alert
className="SignIn-error"
type="error"
message="Failed to sign in"
description={authUserError}
showIcon
/>
)}
</div>
);
}
private authUser = () => {
this.props.authUser(this.props.user.accountAddress);
private handleLogin = (ev: React.FormEvent<HTMLFormElement>) => {
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 => ({
isAuthingUser: state.auth.isAuthingUser,
authUserError: state.auth.authUserError,

View File

@ -7,41 +7,17 @@
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 {
&-item {
margin-bottom: 0.4rem;
margin-bottom: 0.5rem;
}
.ant-form-item-label {
padding-bottom: 0.2rem;
}
&-controls {
margin-top: 0.5rem;
}
&-alert {
margin-top: 1rem;
}
}
&-back {
margin-top: 2rem;
opacity: 0.7;
font-size: 0.8rem;
text-align: center;
}
}
}

View File

@ -1,11 +1,10 @@
import React from 'react';
import { connect } from 'react-redux';
import { Form, Input, Button, Alert } from 'antd';
import Identicon from 'components/Identicon';
import ShortAddress from 'components/ShortAddress';
import { AUTH_PROVIDER } from 'utils/auth';
import { FormComponentProps } from 'antd/lib/form';
import { authActions } from 'modules/auth';
import { AppState } from 'store/reducers';
import PasswordFormItems from 'components/PasswordFormItems';
import './SignUp.less';
interface StateProps {
@ -17,114 +16,98 @@ interface DispatchProps {
createUser: typeof authActions['createUser'];
}
interface OwnProps {
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: '',
};
type Props = StateProps & DispatchProps & FormComponentProps;
class SignUp extends React.Component<Props> {
render() {
const { address, isCreatingUser, createUserError } = this.props;
const { name, title, email } = this.state;
const { isCreatingUser, createUserError } = this.props;
const { getFieldDecorator } = this.props.form;
return (
<div className="SignUp">
<div className="SignUp-container">
<div className="SignUp-identity">
<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 className="SignUp-form" onSubmit={this.handleSubmit}>
<Form.Item className="SignUp-form-item" label="Display name">
<Input
name="name"
value={name}
onChange={this.handleChange}
placeholder="Non-unique name that others will see you as"
size="large"
/>
{getFieldDecorator('name', {
rules: [{ required: true, message: 'Please add a display name' }],
})(
<Input
name="name"
placeholder="Non-unique name that others will see you as"
autoComplete="name"
/>,
)}
</Form.Item>
<Form.Item className="SignUp-form-item" label="Title">
<Input
name="title"
value={title}
onChange={this.handleChange}
placeholder="A short description about you, e.g. Core Ethereum Developer"
/>
{getFieldDecorator('title', {
rules: [{ required: true, message: 'Please add your title' }],
})(
<Input
name="title"
placeholder="A short description about you, e.g. Core Ethereum Developer"
/>,
)}
</Form.Item>
<Form.Item className="SignUp-form-item" label="Email address">
<Input
name="email"
value={email}
onChange={this.handleChange}
placeholder="We promise not to spam you or share your email"
/>
{getFieldDecorator('email', {
rules: [
{ type: 'email', message: 'Invalid email' },
{ required: true, message: 'Please enter your email' },
],
})(
<Input
name="email"
placeholder="We promise not to spam you or share your email"
autoComplete="username"
/>,
)}
</Form.Item>
<Button
type="primary"
htmlType="submit"
size="large"
block
loading={isCreatingUser}
>
Claim Identity
</Button>
<PasswordFormItems form={this.props.form} />
<div className="SignUp-form-controls">
<Button
type="primary"
htmlType="submit"
size="large"
block
loading={isCreatingUser}
>
Create account
</Button>
</div>
{createUserError && (
<Alert
type="error"
message={createUserError}
showIcon
closable
style={{ marginTop: '1rem' }}
className="SignUp-form-alert"
/>
)}
</Form>
</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>
);
}
private handleChange = (ev: React.ChangeEvent<HTMLInputElement>) => {
const { name, value } = ev.currentTarget;
this.setState({ [name]: value } as any);
};
private handleSubmit = (ev: React.FormEvent<HTMLFormElement>) => {
ev.preventDefault();
const { address, createUser } = this.props;
const { name, title, email } = this.state;
createUser({ address, name, title, email });
const { createUser } = this.props;
this.props.form.validateFieldsAndScroll((err: any, values: any) => {
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 => ({
isCreatingUser: state.auth.isCreatingUser,
createUserError: state.auth.createUserError,
@ -132,4 +115,4 @@ export default connect<StateProps, DispatchProps, OwnProps, AppState>(
{
createUser: authActions.createUser,
},
)(SignUp);
)(FormWrappedSignUp);

View File

@ -4,11 +4,17 @@
margin: 0 auto 0.25rem;
text-align: center;
}
&-subtitle {
font-size: 1.2rem;
margin-bottom: 2rem;
opacity: 0.7;
text-align: center;
}
}
&-switch {
font-size: 1.2rem;
margin-top: 2rem;
text-align: center;
}
}

View File

@ -2,163 +2,71 @@ import React from 'react';
import { connect } from 'react-redux';
import { Spin } from 'antd';
import { AppState } from 'store/reducers';
import { AUTH_PROVIDER } from 'utils/auth';
import { authActions } from 'modules/auth';
import SignIn from './SignIn';
import SignUp from './SignUp';
import SelectProvider from './SelectProvider';
import ProvideIdentity from './ProvideIdentity';
import './index.less';
interface StateProps {
web3Accounts: AppState['web3']['accounts'];
checkedUsers: AppState['auth']['checkedUsers'];
authUser: AppState['auth']['user'];
isCheckingUser: AppState['auth']['isCheckingUser'];
}
interface DispatchProps {
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,
};
type Props = StateProps;
class AuthFlow extends React.Component<Props> {
state: State = { ...DEFAULT_STATE };
state: { page: 'SIGN_IN' | 'SIGN_UP' } = { page: 'SIGN_IN' };
private pages = {
SIGN_IN: {
title: () => 'Prove your Identity',
subtitle: () => 'Log into your Grant.io account by proving your identity',
title: 'Sign in',
subtitle: '',
render: () => {
const { address, provider } = this.state;
const user = address && this.props.checkedUsers[address];
return (
user &&
provider && <SignIn provider={provider} user={user} reset={this.resetState} />
);
return <SignIn />;
},
renderSwitch: () => (
<>
No account?{' '}
<a onClick={() => this.setState({ page: 'SIGN_UP' })}>Create a new account</a>.
</>
),
},
SIGN_UP: {
title: () => 'Claim your Identity',
subtitle: () => 'Create a Grant.io account by claiming your identity',
title: 'Create your Account',
subtitle: 'Please enter your details below',
render: () => {
const { address, provider } = this.state;
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}
/>
)
);
return <SignUp />;
},
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() {
const { checkedUsers, isCheckingUser } = this.props;
const { provider, address } = this.state;
const checkedUser = address && checkedUsers[address];
let page;
const { isCheckingUser } = this.props;
const page = this.pages[this.state.page];
if (provider) {
if (address) {
// TODO: If address results in user, show SIGN_IN.
if (isCheckingUser) {
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;
if (isCheckingUser) {
return <Spin size="large" />;
}
return (
<div className="AuthFlow">
<h1 className="AuthFlow-title">{page.title()}</h1>
<p className="AuthFlow-subtitle">{page.subtitle()}</p>
{page.title && <h1 className="AuthFlow-title">{page.title}</h1>}
{page.subtitle && <p className="AuthFlow-subtitle">{page.subtitle}</p>}
<div className="AuthFlow-content">{page.render()}</div>
<div className="AuthFlow-switch">{page.renderSwitch()}</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 => ({
web3Accounts: state.web3.accounts,
checkedUsers: state.auth.checkedUsers,
authUser: state.auth.user,
isCheckingUser: state.auth.isCheckingUser,
}),
{

View File

@ -1,9 +0,0 @@
.AddressProvider {
width: 100%;
max-width: 360px;
margin: -0.5rem auto 0;
&-address {
margin-bottom: 0.5rem;
}
}

View File

@ -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);
};
}

View File

@ -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%;
}
}
}

View File

@ -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>
);

View File

@ -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;
}
}

View File

@ -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,
});
};
}

View File

@ -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;
};
}

View File

@ -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;
}
}

View File

@ -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);

View File

@ -1,12 +1,10 @@
import React from 'react';
import { connect } from 'react-redux';
import { Spin } from 'antd';
import { Route, Redirect, RouteProps } from 'react-router-dom';
import { AppState } from 'store/reducers';
interface StateProps {
user: AppState['auth']['user'];
isAuthingUser: AppState['auth']['isAuthingUser'];
}
interface OwnProps {
@ -17,11 +15,8 @@ type Props = RouteProps & StateProps & OwnProps;
class AuthRoute extends React.Component<Props> {
public render() {
const { user, isAuthingUser, onlyLoggedOut, ...routeProps } = this.props;
if (isAuthingUser) {
return <Spin size="large" />;
} else if ((user && !onlyLoggedOut) || (!user && onlyLoggedOut)) {
const { user, onlyLoggedOut, ...routeProps } = this.props;
if ((user && !onlyLoggedOut) || (!user && onlyLoggedOut)) {
return <Route {...routeProps} />;
} else {
// TODO: redirect to desired destination after auth
@ -33,5 +28,4 @@ class AuthRoute extends React.Component<Props> {
export default connect((state: AppState) => ({
user: state.auth.user,
isAuthingUser: state.auth.isAuthingUser,
}))(AuthRoute);

View File

@ -50,7 +50,7 @@ class Comment extends React.Component<Props> {
public render(): React.ReactNode {
const { comment, isSignedIn, isPostCommentPending } = this.props;
const { isReplying, reply } = this.state;
const authorPath = `/profile/${comment.author.accountAddress}`;
const authorPath = `/profile/${comment.author.userid}`;
return (
<div className="Comment">
<div className="Comment-info">

View File

@ -10,8 +10,6 @@ import './Final.less';
interface StateProps {
form: AppState['create']['form'];
crowdFundError: AppState['web3']['crowdFundError'];
crowdFundCreatedAddress: AppState['web3']['crowdFundCreatedAddress'];
createdProposal: ProposalWithCrowdFund | null;
}
@ -27,19 +25,21 @@ class CreateFinal extends React.Component<Props> {
}
render() {
const { crowdFundError, crowdFundCreatedAddress, createdProposal } = this.props;
const { createdProposal } = this.props;
let content;
if (crowdFundError) {
content = (
<div className="CreateFinal-message is-error">
<Icon type="close-circle" />
<div className="CreateFinal-message-text">
Something went wrong during creation: "{crowdFundError}"{' '}
<a onClick={this.create}>Click here</a> to try again.
</div>
</div>
);
} else if (crowdFundCreatedAddress && createdProposal) {
// TODO - handle errors?
// if (crowdFundError) {
// content = (
// <div className="CreateFinal-message is-error">
// <Icon type="close-circle" />
// <div className="CreateFinal-message-text">
// Something went wrong during creation: "{crowdFundError}"{' '}
// <a onClick={this.create}>Click here</a> to try again.
// </div>
// </div>
// );
// } else
if (createdProposal) {
content = (
<div className="CreateFinal-message is-success">
<Icon type="check-circle" />
@ -72,12 +72,7 @@ class CreateFinal extends React.Component<Props> {
export default connect<StateProps, DispatchProps, {}, AppState>(
(state: AppState) => ({
form: state.create.form,
crowdFundError: state.web3.crowdFundError,
crowdFundCreatedAddress: state.web3.crowdFundCreatedAddress,
createdProposal: getProposalByAddress(
state,
state.web3.crowdFundCreatedAddress || '',
),
createdProposal: getProposalByAddress(state, 'notanaddress'),
}),
{
createProposal: createActions.createProposal,

Some files were not shown because too many files have changed in this diff Show More