Merge pull request #50 from dternyak/contribution-validation

Blockchain validation into develop
This commit is contained in:
Daniel Ternyak 2019-01-10 13:51:19 -06:00 committed by GitHub
commit f6ba6e3dcb
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
46 changed files with 820 additions and 722 deletions

View File

@ -206,7 +206,7 @@ const app = store({
} }
app.proposalDetailApproving = false; app.proposalDetailApproving = false;
}, },
async getEmailExample(type: string) { async getEmailExample(type: string) {
try { try {
const example = await getEmailExample(type); const example = await getEmailExample(type);

View File

@ -22,6 +22,5 @@ TWITTER_CLIENT_SECRET=twitter-client-secret
LINKEDIN_CLIENT_ID=linkedin-client-id LINKEDIN_CLIENT_ID=linkedin-client-id
LINKEDIN_CLIENT_SECRET=linkedin-client-secret LINKEDIN_CLIENT_SECRET=linkedin-client-secret
BLOCKCHAIN_WS_API_URL="http://localhost:5050"
BLOCKCHAIN_REST_API_URL="http://localhost:5051" BLOCKCHAIN_REST_API_URL="http://localhost:5051"
BLOCKCHAIN_API_SECRET="ef0b48e41f78d3ae85b1379b386f1bca" BLOCKCHAIN_API_SECRET="ef0b48e41f78d3ae85b1379b386f1bca"

View File

@ -2,24 +2,26 @@ import requests
import json import json
from grant.settings import BLOCKCHAIN_REST_API_URL, BLOCKCHAIN_API_SECRET from grant.settings import BLOCKCHAIN_REST_API_URL, BLOCKCHAIN_API_SECRET
### REST API ###
def handle_res(res): def handle_res(res):
j = res.json() j = res.json()
if j.get('error'): if j.get('error'):
raise Exception('Blockchain API Error: {}'.format(j['error'])) raise Exception('Blockchain API Error: {}'.format(j['error']))
return j['data'] return j['data']
def blockchain_get(path, params = None): def blockchain_get(path, params = None):
res = requests.get( res = requests.get(
f'{BLOCKCHAIN_REST_API_URL}{path}', f'{BLOCKCHAIN_REST_API_URL}{path}',
headers={ 'authorization': BLOCKCHAIN_API_SECRET }, headers={ 'authorization': BLOCKCHAIN_API_SECRET },
params=params, params=params,
) )
return handle_res(res) return handle_res(res)
def blockchain_post(path, data = None): def blockchain_post(path, data = None):
res = requests.post( res = requests.post(
f'{BLOCKCHAIN_REST_API_URL}{path}', f'{BLOCKCHAIN_REST_API_URL}{path}',
headers={ 'authorization': BLOCKCHAIN_API_SECRET }, headers={ 'authorization': BLOCKCHAIN_API_SECRET },
data=json.dumps(data), data=json.dumps(data),
) )
return handle_res(res) return handle_res(res)

View File

@ -1,6 +1,7 @@
import datetime import datetime
from typing import List from typing import List
from sqlalchemy import func, or_ from sqlalchemy import func, or_
from functools import reduce
from grant.comment.models import Comment from grant.comment.models import Comment
from grant.extensions import ma, db from grant.extensions import ma, db
@ -95,6 +96,8 @@ class ProposalContribution(db.Model):
amount = db.Column(db.String(255), nullable=False) amount = db.Column(db.String(255), nullable=False)
tx_id = db.Column(db.String(255)) tx_id = db.Column(db.String(255))
user = db.relationship("User")
def __init__( def __init__(
self, self,
proposal_id: int, proposal_id: int,
@ -108,10 +111,21 @@ class ProposalContribution(db.Model):
self.status = PENDING self.status = PENDING
@staticmethod @staticmethod
def getExistingContribution(user_id: int, proposal_id: int, amount: str): def get_existing_contribution(user_id: int, proposal_id: int, amount: str):
return ProposalContribution.query.filter_by(
user_id=user_id,
proposal_id=proposal_id,
amount=amount,
status=PENDING,
).first()
@staticmethod
def get_by_userid(user_id):
return ProposalContribution.query \ return ProposalContribution.query \
.filter_by(user_id=user_id, proposal_id=proposal_id, amount=amount) \ .filter(ProposalContribution.user_id == user_id) \
.first() .filter(ProposalContribution.status != DELETED) \
.order_by(ProposalContribution.date_created.desc()) \
.all()
def confirm(self, tx_id: str, amount: str): def confirm(self, tx_id: str, amount: str):
self.status = CONFIRMED self.status = CONFIRMED
@ -300,7 +314,6 @@ class ProposalSchema(ma.Schema):
"content", "content",
"comments", "comments",
"updates", "updates",
"contributions",
"milestones", "milestones",
"category", "category",
"team", "team",
@ -317,7 +330,6 @@ class ProposalSchema(ma.Schema):
comments = ma.Nested("CommentSchema", many=True) comments = ma.Nested("CommentSchema", many=True)
updates = ma.Nested("ProposalUpdateSchema", many=True) updates = ma.Nested("ProposalUpdateSchema", many=True)
contributions = ma.Nested("ProposalContributionSchema", many=True, exclude=['proposal'])
team = ma.Nested("UserSchema", many=True) team = ma.Nested("UserSchema", many=True)
milestones = ma.Nested("MilestoneSchema", many=True) milestones = ma.Nested("MilestoneSchema", many=True)
invites = ma.Nested("ProposalTeamInviteSchema", many=True) invites = ma.Nested("ProposalTeamInviteSchema", many=True)
@ -335,13 +347,30 @@ class ProposalSchema(ma.Schema):
return dt_to_unix(obj.date_published) if obj.date_published else None return dt_to_unix(obj.date_published) if obj.date_published else None
def get_funded(self, obj): def get_funded(self, obj):
# TODO: Add up all contributions and return that contributions = ProposalContribution.query \
return "0" .filter_by(proposal_id=obj.id, status=CONFIRMED) \
.all()
funded = reduce(lambda prev, c: prev + float(c.amount), contributions, 0)
return str(funded)
proposal_schema = ProposalSchema() proposal_schema = ProposalSchema()
proposals_schema = ProposalSchema(many=True) proposals_schema = ProposalSchema(many=True)
user_fields = [
"proposal_id",
"status",
"title",
"brief",
"target",
"funded",
"date_created",
"date_approved",
"date_published",
"reject_reason",
"team",
]
user_proposal_schema = ProposalSchema(only=user_fields)
user_proposals_schema = ProposalSchema(many=True, only=user_fields)
class ProposalUpdateSchema(ma.Schema): class ProposalUpdateSchema(ma.Schema):
class Meta: class Meta:
@ -433,7 +462,7 @@ class ProposalContributionSchema(ma.Schema):
) )
proposal = ma.Nested("ProposalSchema") proposal = ma.Nested("ProposalSchema")
user = ma.Nested("UserSchema") user = ma.Nested("UserSchema", exclude=["email_address"])
date_created = ma.Method("get_date_created") date_created = ma.Method("get_date_created")
addresses = ma.Method("get_addresses") addresses = ma.Method("get_addresses")
@ -445,41 +474,9 @@ class ProposalContributionSchema(ma.Schema):
proposal_contribution_schema = ProposalContributionSchema() proposal_contribution_schema = ProposalContributionSchema()
proposals_contribution_schema = ProposalContributionSchema(many=True) proposal_contributions_schema = ProposalContributionSchema(many=True)
user_proposal_contribution_schema = ProposalContributionSchema(exclude=['user', 'addresses'])
user_proposal_contributions_schema = ProposalContributionSchema(many=True, exclude=['user', 'addresses'])
proposal_proposal_contribution_schema = ProposalContributionSchema(exclude=['proposal', 'addresses'])
proposal_proposal_contributions_schema = ProposalContributionSchema(many=True, exclude=['proposal', 'addresses'])
class UserProposalSchema(ma.Schema):
class Meta:
model = Proposal
# Fields to expose
fields = (
"proposal_id",
"status",
"title",
"brief",
"target",
"funded",
"date_created",
"date_approved",
"date_published",
"reject_reason",
"team",
)
date_created = ma.Method("get_date_created")
proposal_id = ma.Method("get_proposal_id")
funded = ma.Method("get_funded")
team = ma.Nested("UserSchema", many=True)
def get_proposal_id(self, obj):
return obj.id
def get_date_created(self, obj):
return dt_to_unix(obj.date_created) * 1000
def get_funded(self, obj):
# TODO: Add up all contributions and return that
return "0"
user_proposal_schema = UserProposalSchema()
user_proposals_schema = UserProposalSchema(many=True)

View File

@ -11,10 +11,10 @@ from grant.comment.models import Comment, comment_schema, comments_schema
from grant.milestone.models import Milestone from grant.milestone.models import Milestone
from grant.user.models import User, SocialMedia, Avatar from grant.user.models import User, SocialMedia, Avatar
from grant.email.send import send_email from grant.email.send import send_email
from grant.utils.auth import requires_auth, requires_team_member_auth, get_authed_user from grant.utils.auth import requires_auth, requires_team_member_auth, get_authed_user, internal_webhook
from grant.utils.exceptions import ValidationException from grant.utils.exceptions import ValidationException
from grant.utils.misc import is_email, make_url from grant.utils.misc import is_email, make_url, from_zat
from .models import( from .models import (
Proposal, Proposal,
proposals_schema, proposals_schema,
proposal_schema, proposal_schema,
@ -25,13 +25,15 @@ from .models import(
proposal_team, proposal_team,
ProposalTeamInvite, ProposalTeamInvite,
proposal_team_invite_schema, proposal_team_invite_schema,
proposal_proposal_contributions_schema,
db, db,
DRAFT, DRAFT,
PENDING, PENDING,
APPROVED, APPROVED,
REJECTED, REJECTED,
LIVE, LIVE,
DELETED DELETED,
CONFIRMED,
) )
import traceback import traceback
@ -328,11 +330,25 @@ def delete_proposal_team_invite(proposal_id, id_or_address):
@endpoint.api() @endpoint.api()
def get_proposal_contributions(proposal_id): def get_proposal_contributions(proposal_id):
proposal = Proposal.query.filter_by(id=proposal_id).first() proposal = Proposal.query.filter_by(id=proposal_id).first()
if proposal: if not proposal:
dumped_proposal = proposal_schema.dump(proposal)
return dumped_proposal["contributions"]
else:
return {"message": "No proposal matching id"}, 404 return {"message": "No proposal matching id"}, 404
top_contributions = ProposalContribution.query \
.filter_by(proposal_id=proposal_id, status=CONFIRMED) \
.order_by(ProposalContribution.amount.desc()) \
.limit(5) \
.all()
latest_contributions = ProposalContribution.query \
.filter_by(proposal_id=proposal_id, status=CONFIRMED) \
.order_by(ProposalContribution.date_created.desc()) \
.limit(5) \
.all()
return {
'top': proposal_proposal_contributions_schema.dump(top_contributions),
'latest': proposal_proposal_contributions_schema.dump(latest_contributions),
}
@blueprint.route("/<proposal_id>/contributions/<contribution_id>", methods=["GET"]) @blueprint.route("/<proposal_id>/contributions/<contribution_id>", methods=["GET"])
@ -361,7 +377,7 @@ def post_proposal_contribution(proposal_id, amount):
code = 200 code = 200
contribution = ProposalContribution \ contribution = ProposalContribution \
.getExistingContribution(g.current_user.id, proposal_id, amount) .get_existing_contribution(g.current_user.id, proposal_id, amount)
if not contribution: if not contribution:
code = 201 code = 201
@ -375,3 +391,49 @@ def post_proposal_contribution(proposal_id, amount):
dumped_contribution = proposal_contribution_schema.dump(contribution) dumped_contribution = proposal_contribution_schema.dump(contribution)
return dumped_contribution, code return dumped_contribution, code
# Can't use <proposal_id> since webhook doesn't know proposal id
@blueprint.route("/contribution/<contribution_id>/confirm", methods=["POST"])
@internal_webhook
@endpoint.api(
parameter('to', type=str, required=True),
parameter('amount', type=str, required=True),
parameter('txid', type=str, required=True),
)
def post_contribution_confirmation(contribution_id, to, amount, txid):
contribution = contribution = ProposalContribution.query.filter_by(
id=contribution_id).first()
if not contribution:
# TODO: Log in sentry
print(f'Unknown contribution {contribution_id} confirmed with txid {txid}')
return {"message": "No contribution matching id"}, 404
# Convert to whole zcash coins from zats
zec_amount = str(from_zat(int(amount)))
contribution.confirm(tx_id=txid, amount=zec_amount)
db.session.add(contribution)
db.session.commit()
return None, 200
@blueprint.route("/contribution/<contribution_id>", methods=["DELETE"])
@requires_auth
@endpoint.api()
def delete_proposal_contribution(contribution_id):
contribution = contribution = ProposalContribution.query.filter_by(
id=contribution_id).first()
if not contribution:
return {"message": "No contribution matching id"}, 404
if contribution.status == CONFIRMED:
return {"message": "Cannot delete confirmed contributions"}, 400
if contribution.user_id != g.current_user.id:
return {"message": "Must be the user of the contribution to delete it"}, 403
contribution.status = DELETED
db.session.add(contribution)
db.session.commit()
return None, 202

View File

@ -56,6 +56,5 @@ TWITTER_CLIENT_SECRET = env.str("TWITTER_CLIENT_SECRET")
LINKEDIN_CLIENT_ID = env.str("LINKEDIN_CLIENT_ID") LINKEDIN_CLIENT_ID = env.str("LINKEDIN_CLIENT_ID")
LINKEDIN_CLIENT_SECRET = env.str("LINKEDIN_CLIENT_SECRET") LINKEDIN_CLIENT_SECRET = env.str("LINKEDIN_CLIENT_SECRET")
BLOCKCHAIN_WS_API_URL = env.str("BLOCKCHAIN_WS_API_URL")
BLOCKCHAIN_REST_API_URL = env.str("BLOCKCHAIN_REST_API_URL") BLOCKCHAIN_REST_API_URL = env.str("BLOCKCHAIN_REST_API_URL")
BLOCKCHAIN_API_SECRET = env.str("BLOCKCHAIN_API_SECRET") BLOCKCHAIN_API_SECRET = env.str("BLOCKCHAIN_API_SECRET")

View File

@ -8,10 +8,13 @@ from grant.proposal.models import (
proposal_team, proposal_team,
ProposalTeamInvite, ProposalTeamInvite,
invites_with_proposal_schema, invites_with_proposal_schema,
ProposalContribution,
user_proposal_contributions_schema,
user_proposals_schema, user_proposals_schema,
PENDING, PENDING,
APPROVED, APPROVED,
REJECTED REJECTED,
CONFIRMED
) )
from grant.utils.auth import requires_auth, requires_same_user_auth, get_authed_user from grant.utils.auth import requires_auth, requires_same_user_auth, get_authed_user
from grant.utils.upload import remove_avatar, sign_avatar_upload, AvatarException from grant.utils.upload import remove_avatar, sign_avatar_upload, AvatarException
@ -62,19 +65,21 @@ def get_user(user_id, with_proposals, with_comments, with_funded, with_pending):
user = User.get_by_id(user_id) user = User.get_by_id(user_id)
if user: if user:
result = user_schema.dump(user) result = user_schema.dump(user)
authed_user = get_authed_user()
if with_proposals: if with_proposals:
proposals = Proposal.get_by_user(user) proposals = Proposal.get_by_user(user)
proposals_dump = user_proposals_schema.dump(proposals) proposals_dump = user_proposals_schema.dump(proposals)
result["createdProposals"] = proposals_dump result["proposals"] = proposals_dump
if with_funded: if with_funded:
contributions = Proposal.get_by_user_contribution(user) contributions = ProposalContribution.get_by_userid(user_id)
contributions_dump = user_proposals_schema.dump(contributions) if not authed_user or user.id != authed_user.id:
result["fundedProposals"] = contributions_dump contributions = [c for c in contributions if c.status == CONFIRMED]
contributions_dump = user_proposal_contributions_schema.dump(contributions)
result["contributions"] = contributions_dump
if with_comments: if with_comments:
comments = Comment.get_by_user(user) comments = Comment.get_by_user(user)
comments_dump = user_comments_schema.dump(comments) comments_dump = user_comments_schema.dump(comments)
result["comments"] = comments_dump result["comments"] = comments_dump
authed_user = get_authed_user()
if with_pending and authed_user and authed_user.id == user.id: if with_pending and authed_user and authed_user.id == user.id:
pending = Proposal.get_by_user(user, [PENDING, APPROVED, REJECTED]) pending = Proposal.get_by_user(user, [PENDING, APPROVED, REJECTED])
pending_dump = user_proposals_schema.dump(pending) pending_dump = user_proposals_schema.dump(pending)

View File

@ -7,7 +7,7 @@ from flask_security.core import current_user
from flask import request, g, jsonify from flask import request, g, jsonify
import sentry_sdk import sentry_sdk
from grant.settings import SECRET_KEY from grant.settings import SECRET_KEY, BLOCKCHAIN_API_SECRET
from ..proposal.models import Proposal from ..proposal.models import Proposal
from ..user.models import User from ..user.models import User
@ -67,3 +67,16 @@ def requires_team_member_auth(f):
return f(*args, **kwargs) return f(*args, **kwargs)
return requires_auth(decorated) return requires_auth(decorated)
def internal_webhook(f):
@wraps(f)
def decorated(*args, **kwargs):
secret = request.headers.get('authorization')
if not secret:
print('Internal webhook missing "Authorization" header')
return jsonify(message="Invalid 'Authorization' header"), 403
if BLOCKCHAIN_API_SECRET not in secret:
print(f'Internal webhook provided invalid "Authorization" header: {secret}')
return jsonify(message="Invalid 'Authorization' header"), 403
return f(*args, **kwargs)
return decorated

View File

@ -30,3 +30,9 @@ def make_url(path: str):
def is_email(email: str): def is_email(email: str):
return bool(re.match(r"[^@]+@[^@]+\.[^@]+", email)) return bool(re.match(r"[^@]+@[^@]+\.[^@]+", email))
def from_zat(zat: int):
return zat / 100000000
def to_zat(zec: float):
return zec * 100000000

View File

@ -2,8 +2,8 @@
API_SECRET_HASH="4747c3a7d043640cd8ed4e6b72bb9562d2585cee65681eb9c21ffec03c0bf560" API_SECRET_HASH="4747c3a7d043640cd8ed4e6b72bb9562d2585cee65681eb9c21ffec03c0bf560"
API_SECRET_KEY="ef0b48e41f78d3ae85b1379b386f1bca" API_SECRET_KEY="ef0b48e41f78d3ae85b1379b386f1bca"
# WebSocket Config # Webhooks Config
WS_PORT="5050" WEBHOOK_URL="http://localhost:5000/api/v1"
# REST Server Config # REST Server Config
REST_SERVER_PORT="5051" REST_SERVER_PORT="5051"

View File

@ -31,6 +31,7 @@
"@types/cors": "2.8.4", "@types/cors": "2.8.4",
"@types/dotenv": "^6.1.0", "@types/dotenv": "^6.1.0",
"@types/ws": "^6.0.1", "@types/ws": "^6.0.1",
"axios": "0.18.0",
"body-parser": "1.18.3", "body-parser": "1.18.3",
"cors": "2.8.5", "cors": "2.8.5",
"dotenv": "^6.1.0", "dotenv": "^6.1.0",

View File

@ -6,7 +6,7 @@ interface CustomEnvironment {
API_SECRET_HASH: string; API_SECRET_HASH: string;
API_SECRET_KEY: string; API_SECRET_KEY: string;
WS_PORT: string; WEBHOOK_URL: string;
REST_SERVER_PORT: string; REST_SERVER_PORT: string;
ZCASH_NODE_URL: string; ZCASH_NODE_URL: string;

View File

@ -1,18 +1,19 @@
import * as Websocket from "./websocket"; import * as Webhooks from "./webhooks";
import * as RestServer from "./server"; import * as RestServer from "./server";
import { initNode } from './node'; import { initNode } from './node';
async function start() { async function start() {
console.log("============== Starting services =============="); console.log("============== Starting services ==============");
await initNode(); await initNode();
await Websocket.start(); await Webhooks.start();
await RestServer.start(); await RestServer.start();
console.log("==============================================="); console.log("===============================================");
} }
process.on("SIGINT", () => { process.on("SIGINT", () => {
console.log('Shutting down services...'); console.log('Shutting down services...');
Websocket.exit(); Webhooks.exit();
RestServer.exit();
console.log('Exiting!'); console.log('Exiting!');
process.exit(); process.exit();
}); });

View File

@ -1,6 +1,7 @@
import { randomBytes, createHmac } from "crypto"; import { randomBytes, createHmac } from "crypto";
import { IncomingMessage } from "http"; import { IncomingMessage } from "http";
import { HDPublicKey, Address } from "zcash-bitcore-lib"; import { HDPublicKey, Address } from "zcash-bitcore-lib";
import { parse } from 'url';
import env from "./env"; import env from "./env";
function sha256(input: string) { function sha256(input: string) {
@ -14,22 +15,6 @@ export function generateApiKey() {
return { key, hash }; return { key, hash };
} }
export function getIpFromRequest(req: IncomingMessage) {
const xffHeader = req.headers["x-forwarded-for"];
if (xffHeader && typeof xffHeader === "string") {
return xffHeader.split(/\s*,\s*/)[0];
}
return req.connection.remoteAddress;
}
export function getAuthFromRequest(req: IncomingMessage) {
const swpHeader = req.headers["sec-websocket-protocol"];
if (swpHeader && typeof swpHeader === "string") {
return swpHeader;
}
return undefined;
}
export function authenticate(secret: string) { export function authenticate(secret: string) {
const hash = env.API_SECRET_HASH; const hash = env.API_SECRET_HASH;
if (!hash) { if (!hash) {
@ -38,14 +23,6 @@ export function authenticate(secret: string) {
return hash === sha256(secret); return hash === sha256(secret);
} }
export function authenticateRequest(req: IncomingMessage) {
const secret = getAuthFromRequest(req);
if (!secret) {
console.log(`Client must set 'sec-websocket-protocal' header with secret.`);
}
return secret ? authenticate(secret) : false;
}
// TODO: Not fully confident in compatibility with most bip32 wallets, // TODO: Not fully confident in compatibility with most bip32 wallets,
// do more work to ensure this is reliable. // do more work to ensure this is reliable.
export function deriveTransparentAddress(index: number, network: any) { export function deriveTransparentAddress(index: number, network: any) {

View File

@ -1,47 +1,25 @@
import WebSocket from "ws"; import axios from 'axios';
import { initializeNotifiers } from "./notifiers"; import { initializeNotifiers } from "./notifiers";
import { Notifier } from "./notifiers/notifier"; import { Notifier } from "./notifiers/notifier";
import { getIpFromRequest, authenticateRequest } from "../util"; import node from "../node";
import node, { rpcOptions } from "../node";
import env from "../env"; import env from "../env";
const log = console.log; const log = console.log;
export type Send = (message: Message) => void; export type Send = (route: string, method: string, payload: object) => void;
export type Receive = (message: Message) => void;
export interface Message {
type: string;
payload: any;
}
const parse = (data: WebSocket.Data) => {
try {
return JSON.parse(data.toString());
} catch (e) {
log(
`unable to parse message, it was probably not JSON, data: ${data}`
);
return null;
}
};
let wss: null | WebSocket.Server = null;
let notifiers = [] as Notifier[]; let notifiers = [] as Notifier[];
let consecutiveBlockFailures = 0; let consecutiveBlockFailures = 0;
const MAXIMUM_BLOCK_FAILURES = 5; const MAXIMUM_BLOCK_FAILURES = 5;
export async function start() { export async function start() {
await initNode(); await initNode();
initWebsocketServer();
initNotifiers(); initNotifiers();
} }
export function exit() { export function exit() {
notifiers.forEach(n => n.destroy && n.destroy()); notifiers.forEach(n => n.destroy && n.destroy());
wss && wss.close(); console.log('Webhook notifiers have exited');
wss = null;
console.log('WebSocket server has been closed');
} }
@ -82,47 +60,30 @@ async function initNode() {
}, 3000); }, 3000);
} }
function initWebsocketServer() {
if (wss) return;
wss = new WebSocket.Server({
port: parseInt(env.WS_PORT as string, 10)
});
log(`WebSocket Server started on port ${env.WS_PORT}`);
wss.on("connection", function connection(ws, req) {
log(`${getIpFromRequest(req)} connected`);
const isAuth = authenticateRequest(req);
if (!isAuth) {
log(`Connection ${getIpFromRequest(req)} rejected, unauthorized.`);
ws.send(JSON.stringify({ type: "auth", payload: "rejected" }));
ws.terminate();
return;
}
ws.on("message", message => {
const parsedMsg = parse(message);
if (parsedMsg) {
notifiers.forEach(n => n.receive && n.receive(parsedMsg));
}
});
ws.on("close", () => {
log(`${getIpFromRequest(req)} closed.`);
});
});
}
function initNotifiers() { function initNotifiers() {
const send: Send = message => const send: Send = (route, method, payload) => {
wss && console.log('About to send to', route);
wss.clients.forEach(ws => { axios.request({
try { method,
ws.send(JSON.stringify(message)); url: `${env.WEBHOOK_URL}${route}`,
} catch (e) { data: payload,
log(`Send error: ${e}`); headers: {
'Authorization': `Bearer ${env.API_SECRET_KEY}`,
'Content-Type': 'application/json',
},
})
.then((res) => {
if (res.status >= 400) {
console.error(`Webhook server responded to ${method} ${route} with status code ${res.status}`);
console.error(res.data);
} }
})
.catch((err) => {
console.error(err);
console.error('Webhook server request failed! See above for details.');
}); });
};
notifiers = initializeNotifiers(); notifiers = initializeNotifiers();
notifiers.forEach(n => n.registerSend(send)); notifiers.forEach(n => n.registerSend(send));

View File

@ -1,4 +1,4 @@
import { Send, Message } from "../../index"; import { Send } from "../../index";
import { Notifier } from "../notifier"; import { Notifier } from "../notifier";
import node, { BlockWithTransactions } from "../../../node"; import node, { BlockWithTransactions } from "../../../node";
import { import {
@ -43,7 +43,6 @@ export default class ContributionNotifier implements Notifier {
// generate one, so all of our addresses will only have addresses[0] // generate one, so all of our addresses will only have addresses[0]
const to = vout.scriptPubKey.addresses[0]; const to = vout.scriptPubKey.addresses[0];
if (tAddressIdMap[to]) { if (tAddressIdMap[to]) {
console.info(`Transaction found for contribution ${tAddressIdMap[to]}, +${vout.value} ZEC`);
this.sendContributionConfirmation({ this.sendContributionConfirmation({
to, to,
amount: vout.valueZat.toString(), amount: vout.valueZat.toString(),
@ -67,10 +66,11 @@ export default class ContributionNotifier implements Notifier {
const newReceived = received.filter(r => !this.confirmedTxIds.includes(r.txid)); const newReceived = received.filter(r => !this.confirmedTxIds.includes(r.txid));
newReceived.forEach(receipt => { newReceived.forEach(receipt => {
console.info(`Received new tx ${receipt.txid}`);
this.confirmedTxIds.push(receipt.txid); this.confirmedTxIds.push(receipt.txid);
const contributionId = getContributionIdFromMemo(receipt.memo); const contributionId = getContributionIdFromMemo(receipt.memo);
if (!contributionId) { if (!contributionId) {
console.warn('Sprout address received transaction without memo:\n', { console.warn(`Sprout address ${env.SPROUT_ADDRESS} received transaction with invalid memo:\n`, {
txid: receipt.txid, txid: receipt.txid,
decodedMemo: decodeHexMemo(receipt.memo) decodedMemo: decodeHexMemo(receipt.memo)
}); });
@ -134,10 +134,8 @@ export default class ContributionNotifier implements Notifier {
} }
}; };
private sendContributionConfirmation = (payload: ContributionConfirmationPayload) => { private sendContributionConfirmation = (p: ContributionConfirmationPayload) => {
this.send({ console.info(`Contribution confirmed for contribution ${p.contributionId}, +${p.amount} ZEC`);
payload, this.send(`/proposals/contribution/${p.contributionId}/confirm`, 'POST', p);
type: 'contribution:confirmation',
});
}; };
} }

View File

@ -1,9 +1,8 @@
import { Send, Message } from "../index"; import { Send } from "../index";
import { Block } from "../../node"; import { Block } from "../../node";
export interface Notifier { export interface Notifier {
registerSend(send: Send): void; registerSend(send: Send): void;
receive?(message: Message): void;
onNewBlock?(block: Block): void; onNewBlock?(block: Block): void;
destroy?(): void; destroy?(): void;
} }

File diff suppressed because it is too large Load Diff

View File

@ -33,6 +33,10 @@ export function getProposalUpdates(proposalId: number | string) {
return axios.get(`/api/v1/proposals/${proposalId}/updates`); return axios.get(`/api/v1/proposals/${proposalId}/updates`);
} }
export function getProposalContributions(proposalId: number | string) {
return axios.get(`/api/v1/proposals/${proposalId}/contributions`);
}
export function postProposal(payload: ProposalDraft) { export function postProposal(payload: ProposalDraft) {
return axios.post(`/api/v1/proposals/`, { return axios.post(`/api/v1/proposals/`, {
...payload, ...payload,
@ -216,6 +220,7 @@ export function postProposalContribution(
}); });
} }
export function postProposalComment(payload: { export function postProposalComment(payload: {
proposalId: number; proposalId: number;
parentCommentId?: number; parentCommentId?: number;
@ -224,3 +229,14 @@ export function postProposalComment(payload: {
const { proposalId, ...args } = payload; const { proposalId, ...args } = payload;
return axios.post(`/api/v1/proposals/${proposalId}/comments`, args); return axios.post(`/api/v1/proposals/${proposalId}/comments`, args);
} }
export function deleteProposalContribution(contributionId: string | number) {
return axios.delete(`/api/v1/proposals/contribution/${contributionId}`);
}
export function getProposalContribution(
proposalId: number,
contributionId: number,
): Promise<{ data: ContributionWithAddresses }> {
return axios.get(`/api/v1/proposals/${proposalId}/contributions/${contributionId}`);
}

View File

@ -1,25 +0,0 @@
import React from 'react';
import ShortAddress from 'components/ShortAddress';
import Identicon from 'components/Identicon';
import './style.less';
interface Props {
address: string;
secondary?: React.ReactNode;
}
const AddressRow = ({ address, secondary }: Props) => (
<div className="AddressRow">
<div className="AddressRow-avatar">
<Identicon address={address} />
</div>
<div className="AddressRow-info">
<div className="AddressRow-info-main">
<ShortAddress address={address} />
</div>
{secondary && <p className="AddressRow-info-secondary">{secondary}</p>}
</div>
</div>
);
export default AddressRow;

View File

@ -1,47 +0,0 @@
@height: 3rem;
.AddressRow {
position: relative;
display: flex;
height: @height;
margin-bottom: 1rem;
&:last-child {
margin-bottom: 0;
}
&-avatar {
display: block;
height: @height;
width: @height;
margin-right: 0.75rem;
img {
width: 100%;
border-radius: 4px;
}
}
&-info {
flex: 1;
min-width: 0;
&-main {
font-size: 1.1rem;
margin-bottom: 0.1rem;
min-width: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
&-secondary {
font-size: 0.9rem;
opacity: 0.7;
min-width: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
}
}

View File

@ -19,6 +19,7 @@
border-bottom: 1px solid rgba(#000, 0.06); border-bottom: 1px solid rgba(#000, 0.06);
&-qr { &-qr {
position: relative;
padding: 0.5rem; padding: 0.5rem;
margin-right: 1rem; margin-right: 1rem;
border-radius: 4px; border-radius: 4px;
@ -28,6 +29,16 @@
canvas { canvas {
display: block; display: block;
} }
&.is-loading canvas {
opacity: 0;
}
.ant-spin {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
}
} }
&-info { &-info {

View File

@ -67,8 +67,13 @@ export default class PaymentInfo extends React.Component<Props, State> {
</Radio.Group> </Radio.Group>
<div className="PaymentInfo-uri"> <div className="PaymentInfo-uri">
<div className="PaymentInfo-uri-qr"> <div className={
{uri ? <QRCode value={uri} /> : <Spin />} classnames('PaymentInfo-uri-qr', !uri && 'is-loading')
}>
<span style={{ opacity: uri ? 1 : 0 }}>
<QRCode value={uri || ''} />
</span>
{!uri && <Spin size="large" />}
</div> </div>
<div className="PaymentInfo-uri-info"> <div className="PaymentInfo-uri-info">
<CopyInput <CopyInput

View File

@ -1,14 +1,16 @@
import React from 'react'; import React from 'react';
import { Modal } from 'antd'; import { Modal } from 'antd';
import Result from 'ant-design-pro/lib/Result'; import Result from 'ant-design-pro/lib/Result';
import { postProposalContribution } from 'api/api'; import { postProposalContribution, getProposalContribution } from 'api/api';
import { ContributionWithAddresses } from 'types'; import { ContributionWithAddresses } from 'types';
import PaymentInfo from './PaymentInfo'; import PaymentInfo from './PaymentInfo';
interface OwnProps { interface OwnProps {
isVisible: boolean; isVisible: boolean;
proposalId: number; proposalId?: number;
contributionId?: number;
amount?: string; amount?: string;
hasNoButtons?: boolean;
handleClose(): void; handleClose(): void;
} }
@ -28,17 +30,21 @@ export default class ContributionModal extends React.Component<Props, State> {
}; };
componentWillUpdate(nextProps: Props) { componentWillUpdate(nextProps: Props) {
const { isVisible, proposalId } = nextProps const { isVisible, proposalId, contributionId } = nextProps;
if (isVisible && this.props.isVisible !== isVisible) { // When modal is opened and proposalId is provided or changed
this.fetchAddresses(proposalId); if (isVisible && proposalId) {
} if (
else if (proposalId !== this.props.proposalId) { this.props.isVisible !== isVisible ||
this.fetchAddresses(proposalId); proposalId !== this.props.proposalId
) {
this.fetchAddresses(proposalId, contributionId);
}
} }
} }
render() { render() {
const { isVisible, handleClose } = this.props; const { isVisible, handleClose, hasNoButtons } = this.props;
const { hasSent, contribution, error } = this.state; const { hasSent, contribution, error } = this.state;
let content; let content;
@ -68,11 +74,12 @@ export default class ContributionModal extends React.Component<Props, State> {
<Modal <Modal
title="Make your contribution" title="Make your contribution"
visible={isVisible} visible={isVisible}
closable={hasSent} closable={hasSent || hasNoButtons}
maskClosable={hasSent} maskClosable={hasSent || hasNoButtons}
okText={hasSent ? 'Done' : 'Ive sent it'} okText={hasSent ? 'Done' : 'Ive sent it'}
onOk={hasSent ? handleClose : this.confirmSend} onOk={hasSent ? handleClose : this.confirmSend}
onCancel={handleClose} onCancel={handleClose}
footer={hasNoButtons ? '' : undefined}
centered centered
> >
{content} {content}
@ -80,12 +87,20 @@ export default class ContributionModal extends React.Component<Props, State> {
); );
} }
private async fetchAddresses(proposalId: number) { private async fetchAddresses(
proposalId: number,
contributionId?: number,
) {
try { try {
const res = await postProposalContribution( let res;
proposalId, if (contributionId) {
this.props.amount || '0', res = await getProposalContribution(proposalId, contributionId);
); } else {
res = await postProposalContribution(
proposalId,
this.props.amount || '0',
);
}
this.setState({ contribution: res.data }); this.setState({ contribution: res.data });
} catch(err) { } catch(err) {
this.setState({ error: err.message }); this.setState({ error: err.message });

View File

@ -0,0 +1,80 @@
@small-query: ~'(max-width: 640px)';
.ProfileContribution {
display: flex;
align-items: center;
padding-bottom: 1.2rem;
border-bottom: 1px solid rgba(0, 0, 0, 0.05);
margin-bottom: 1rem;
&:last-child {
border-bottom: none;
padding-bottom: none;
}
@media @small-query {
flex-direction: column;
align-items: flex-start;
padding-bottom: 0.6rem;
}
&-info {
flex: 1;
&-title {
display: flex;
align-items: center;
font-size: 1.2rem;
font-weight: 600;
color: inherit;
margin-bottom: 0.5rem;
.ant-tag {
margin-left: 0.3rem;
}
}
}
&-state {
margin-left: 1.2rem;
min-width: 15rem;
text-align: right;
@media @small-query {
margin-left: 0;
margin-top: 0.6rem;
text-align: left;
}
&-amount {
margin-bottom: 0.25rem;
font-size: 1rem;
// UnitDisplay symbol
> span {
font-size: 1.1rem;
> span {
font-weight: normal;
font-size: 0.8rem;
}
}
}
&-actions {
font-size: 0.7rem;
> * {
&:after {
content: '·';
display: inline-block;
padding: 0 0.3rem;
}
&:last-child:after {
display: none;
}
}
}
}
}

View File

@ -0,0 +1,94 @@
import React from 'react';
import { connect } from 'react-redux';
import { Link } from 'react-router-dom';
import { Tag, Popconfirm } from 'antd';
import UnitDisplay from 'components/UnitDisplay';
import { ONE_DAY } from 'utils/time';
import { formatTxExplorerUrl } from 'utils/formatters';
import { deleteContribution } from 'modules/users/actions';
import { UserContribution } from 'types';
import './ProfileContribution.less';
interface OwnProps {
userId: number;
contribution: UserContribution;
showSendInstructions(contribution: UserContribution): void;
}
interface DispatchProps {
deleteContribution: typeof deleteContribution;
}
type Props = OwnProps & DispatchProps;
class ProfileContribution extends React.Component<Props> {
render() {
const { contribution } = this.props;
const { proposal } = contribution;
const isConfirmed = contribution.status === 'CONFIRMED';
const isExpired = !isConfirmed && contribution.dateCreated < Date.now() / 1000 - ONE_DAY;
let tag;
let actions: React.ReactNode;
if (isConfirmed) {
actions = (
<a
href={formatTxExplorerUrl(contribution.txId as string)}
target="_blank"
rel="noopener nofollow"
>
View transaction
</a>
);
} else if (isExpired) {
tag = <Tag color="red">Expired</Tag>;
// TODO: Link to support
actions = <>
<Popconfirm
title="Are you sure?"
onConfirm={this.deleteContribution}
>
<a>Delete</a>
</Popconfirm>
<Link to="/support">Contact support</Link>
</>;
} else {
tag = <Tag color="orange">Pending</Tag>;
actions = (
<a onClick={() => this.props.showSendInstructions(contribution)}>
View send instructions
</a>
);
}
return (
<div className="ProfileContribution">
<div className="ProfileContribution-info">
<Link
className="ProfileContribution-info-title"
to={`/proposals/${proposal.proposalId}`}
>
{proposal.title} {tag}
</Link>
<div className="ProfileContribution-info-brief">{proposal.brief}</div>
</div>
<div className="ProfileContribution-state">
<div className="ProfileContribution-state-amount">
+<UnitDisplay value={contribution.amount} symbol="ZEC" />
</div>
<div className="ProfileContribution-state-actions">
{actions}
</div>
</div>
</div>
);
}
private deleteContribution = () => {
this.props.deleteContribution(this.props.userId, this.props.contribution.id);
};
}
export default connect<{}, DispatchProps, OwnProps, {}>(undefined, {
deleteContribution,
})(ProfileContribution);

View File

@ -16,11 +16,14 @@ import ProfileUser from './ProfileUser';
import ProfileEdit from './ProfileEdit'; import ProfileEdit from './ProfileEdit';
import ProfilePendingList from './ProfilePendingList'; import ProfilePendingList from './ProfilePendingList';
import ProfileProposal from './ProfileProposal'; import ProfileProposal from './ProfileProposal';
import ProfileContribution from './ProfileContribution';
import ProfileComment from './ProfileComment'; import ProfileComment from './ProfileComment';
import ProfileInvite from './ProfileInvite'; import ProfileInvite from './ProfileInvite';
import Placeholder from 'components/Placeholder'; import Placeholder from 'components/Placeholder';
import Exception from 'pages/exception'; import Exception from 'pages/exception';
import ContributionModal from 'components/ContributionModal';
import './style.less'; import './style.less';
import { UserContribution } from 'types';
interface StateProps { interface StateProps {
usersMap: AppState['users']['map']; usersMap: AppState['users']['map'];
@ -34,7 +37,15 @@ interface DispatchProps {
type Props = RouteComponentProps<any> & StateProps & DispatchProps; type Props = RouteComponentProps<any> & StateProps & DispatchProps;
class Profile extends React.Component<Props> { interface State {
activeContribution: UserContribution | null;
}
class Profile extends React.Component<Props, State> {
state: State = {
activeContribution: null,
};
componentDidMount() { componentDidMount() {
this.fetchData(); this.fetchData();
} }
@ -49,6 +60,8 @@ class Profile extends React.Component<Props> {
render() { render() {
const userLookupParam = this.props.match.params.id; const userLookupParam = this.props.match.params.id;
const { authUser, match } = this.props; const { authUser, match } = this.props;
const { activeContribution } = this.state;
if (!userLookupParam) { if (!userLookupParam) {
if (authUser && authUser.userid) { if (authUser && authUser.userid) {
return <Redirect to={`/profile/${authUser.userid}`} />; return <Redirect to={`/profile/${authUser.userid}`} />;
@ -69,16 +82,10 @@ class Profile extends React.Component<Props> {
return <Exception code="404" />; return <Exception code="404" />;
} }
const { const { proposals, pendingProposals, contributions, comments, invites } = user;
pendingProposals,
createdProposals,
fundedProposals,
comments,
invites,
} = user;
const nonePending = pendingProposals.length === 0; const nonePending = pendingProposals.length === 0;
const noneCreated = createdProposals.length === 0; const noneCreated = proposals.length === 0;
const noneFunded = fundedProposals.length === 0; const noneFunded = contributions.length === 0;
const noneCommented = comments.length === 0; const noneCommented = comments.length === 0;
const noneInvites = user.hasFetchedInvites && invites.length === 0; const noneInvites = user.hasFetchedInvites && invites.length === 0;
@ -119,21 +126,26 @@ class Profile extends React.Component<Props> {
</div> </div>
</Tabs.TabPane> </Tabs.TabPane>
)} )}
<Tabs.TabPane tab={TabTitle('Created', createdProposals.length)} key="created"> <Tabs.TabPane tab={TabTitle('Created', proposals.length)} key="created">
<div> <div>
{noneCreated && ( {noneCreated && (
<Placeholder subtitle="Has not created any proposals yet" /> <Placeholder subtitle="Has not created any proposals yet" />
)} )}
{createdProposals.map(p => ( {proposals.map(p => (
<ProfileProposal key={p.proposalId} proposal={p} /> <ProfileProposal key={p.proposalId} proposal={p} />
))} ))}
</div> </div>
</Tabs.TabPane> </Tabs.TabPane>
<Tabs.TabPane tab={TabTitle('Funded', fundedProposals.length)} key="funded"> <Tabs.TabPane tab={TabTitle('Funded', contributions.length)} key="funded">
<div> <div>
{noneFunded && <Placeholder subtitle="Has not funded any proposals yet" />} {noneFunded && <Placeholder subtitle="Has not funded any proposals yet" />}
{fundedProposals.map(p => ( {contributions.map(c => (
<ProfileProposal key={p.proposalId} proposal={p} /> <ProfileContribution
key={c.id}
userId={user.userid}
contribution={c}
showSendInstructions={this.openContributionModal}
/>
))} ))}
</div> </div>
</Tabs.TabPane> </Tabs.TabPane>
@ -165,9 +177,20 @@ class Profile extends React.Component<Props> {
</Tabs.TabPane> </Tabs.TabPane>
)} )}
</Tabs> </Tabs>
<ContributionModal
isVisible={!!activeContribution}
proposalId={
activeContribution ? activeContribution.proposal.proposalId : undefined
}
contributionId={activeContribution ? activeContribution.id : undefined}
hasNoButtons
handleClose={this.closeContributionModal}
/>
</div> </div>
); );
} }
private fetchData() { private fetchData() {
const { match } = this.props; const { match } = this.props;
const userLookupId = match.params.id; const userLookupId = match.params.id;
@ -176,6 +199,9 @@ class Profile extends React.Component<Props> {
this.props.fetchUserInvites(userLookupId); this.props.fetchUserInvites(userLookupId);
} }
} }
private openContributionModal = (c: UserContribution) => this.setState({ activeContribution: c });
private closeContributionModal = () => this.setState({ activeContribution: null });
} }
const TabTitle = (disp: string, count: number) => ( const TabTitle = (disp: string, count: number) => (

View File

@ -0,0 +1,46 @@
@import '~styles/variables.less';
.ProposalContributors {
display: flex;
@media @mobile-query {
flex-direction: column;
}
&-block {
flex: 1;
padding: 1.5rem;
margin: 1rem;
background: #FFF;
border-radius: 4px;
border: 1px solid #ddd;
box-shadow: 0 2px 3px rgba(0, 0, 0, 0.1),
0 1px 1px rgba(0, 0, 0, 0.1);
&:first-child {
margin-left: 0;
}
&:last-child {
margin-right: 0;
}
@media @mobile-query {
margin-left: 0;
margin-right: 0;
}
&-title {
margin-top: -0.6rem;
margin-bottom: 1rem;
font-size: 1rem;
}
&-contributor {
margin-bottom: 1rem;
&:last-child {
margin-bottom: 0;
}
}
}
}

View File

@ -1,53 +1,106 @@
import React from 'react'; import React from 'react';
import { connect } from 'react-redux';
import { Spin } from 'antd'; import { Spin } from 'antd';
import AddressRow from 'components/AddressRow'; import UserRow from 'components/UserRow';
import Placeholder from 'components/Placeholder'; import Placeholder from 'components/Placeholder';
import UnitDisplay from 'components/UnitDisplay'; import UnitDisplay from 'components/UnitDisplay';
import { toZat } from 'utils/units';
import { fetchProposalContributions } from 'modules/proposals/actions';
import {
getProposalContributions,
getIsFetchingContributions,
getFetchContributionsError,
} from 'modules/proposals/selectors';
import { ContributionWithUser } from 'types';
import { AppState } from 'store/reducers';
import './index.less';
const ContributorsBlock = () => { interface OwnProps {
// TODO: Get contributors from proposal proposalId: number;
console.warn('TODO: Get contributors from proposal for Proposal/Contributors/index.tsx'); }
const proposal = { contributors: [] as any };
let content; interface StateProps {
if (proposal) { contributions: ReturnType<typeof getProposalContributions>;
if (proposal.contributors.length) { isFetchingContributions: ReturnType<typeof getIsFetchingContributions>;
content = proposal.contributors.map((contributor: any) => ( fetchContributionsError: ReturnType<typeof getFetchContributionsError>;
<AddressRow }
key={contributor.address}
address={contributor.address} interface DispatchProps {
secondary={ fetchProposalContributions: typeof fetchProposalContributions;
<UnitDisplay value={contributor.contributionAmount} symbol="ZEC" /> }
}
/> type Props = OwnProps & StateProps & DispatchProps;
));
} else { class ProposalContributors extends React.Component<Props> {
content = ( componentDidMount() {
<Placeholder if (this.props.proposalId) {
style={{ minHeight: '220px' }} this.props.fetchProposalContributions(this.props.proposalId);
title="No contributors found"
subtitle={`
No contributions have been made to this proposal.
Check back later once there's been at least one contribution.
`}
/>
);
} }
} else {
content = <Spin />;
} }
return ( componentWillReceiveProps(nextProps: Props) {
<div className="Proposal-top-side-block"> if (nextProps.proposalId && nextProps.proposalId !== this.props.proposalId) {
{proposal.contributors.length ? ( this.props.fetchProposalContributions(nextProps.proposalId);
<> }
<h1 className="Proposal-top-main-block-title">Contributors</h1> }
<div className="Proposal-top-main-block">{content}</div>
</> render() {
) : ( const { contributions, fetchContributionsError } = this.props;
content
)} let content;
</div> if (contributions) {
); if (contributions.top.length && contributions.latest.length) {
const makeContributionRow = (c: ContributionWithUser) => (
<div className="ProposalContributors-block-contributor" key={c.id}>
<UserRow
user={c.user}
extra={<>+<UnitDisplay value={toZat(c.amount)} symbol="ZEC" /></>}
/>
</div>
)
content = <>
<div className="ProposalContributors-block">
<h3 className="ProposalContributors-block-title">Latest contributors</h3>
{contributions.latest.map(makeContributionRow)}
</div>
<div className="ProposalContributors-block">
<h3 className="ProposalContributors-block-title">Top contributors</h3>
{contributions.top.map(makeContributionRow)}
</div>
</>;
} else {
content = (
<Placeholder
style={{ minHeight: '220px' }}
title="No contributors found"
subtitle={`
No contributions have been made to this proposal.
Check back later once there's been at least one contribution.
`}
/>
);
}
} else if (fetchContributionsError) {
content = <Placeholder title="Something went wrong" subtitle={fetchContributionsError} />;
} else {
content = <Spin />;
}
return (
<div className="ProposalContributors">
{content}
</div>
);
}
}; };
export default ContributorsBlock; export default connect(
(state: AppState, ownProps: OwnProps) => ({
contributions: getProposalContributions(state, ownProps.proposalId),
isFetchingContributions: getIsFetchingContributions(state),
fetchContributionsError: getFetchContributionsError(state),
}),
{
fetchProposalContributions,
},
)(ProposalContributors);

View File

@ -16,7 +16,6 @@ import Milestones from './Milestones';
import CommentsTab from './Comments'; import CommentsTab from './Comments';
import UpdatesTab from './Updates'; import UpdatesTab from './Updates';
import ContributorsTab from './Contributors'; import ContributorsTab from './Contributors';
// import CommunityTab from './Community';
import UpdateModal from './UpdateModal'; import UpdateModal from './UpdateModal';
import CancelModal from './CancelModal'; import CancelModal from './CancelModal';
import classnames from 'classnames'; import classnames from 'classnames';
@ -244,8 +243,8 @@ export class ProposalDetail extends React.Component<Props, State> {
<Tabs.TabPane tab="Updates" key="updates" disabled={!isLive}> <Tabs.TabPane tab="Updates" key="updates" disabled={!isLive}>
<UpdatesTab proposalId={proposal.proposalId} /> <UpdatesTab proposalId={proposal.proposalId} />
</Tabs.TabPane> </Tabs.TabPane>
<Tabs.TabPane tab="Contributors" key="contributors" disabled={!isLive}> <Tabs.TabPane tab="Contributors" key="contributors">
<ContributorsTab /> <ContributorsTab proposalId={proposal.proposalId} />
</Tabs.TabPane> </Tabs.TabPane>
</Tabs> </Tabs>

View File

@ -6,9 +6,10 @@ import './style.less';
interface Props { interface Props {
user: User; user: User;
extra?: React.ReactNode;
} }
const UserRow = ({ user }: Props) => ( const UserRow = ({ user, extra }: Props) => (
<Link to={`/profile/${user.userid}`} className="UserRow"> <Link to={`/profile/${user.userid}`} className="UserRow">
<div className="UserRow-avatar"> <div className="UserRow-avatar">
<UserAvatar user={user} className="UserRow-avatar-img" /> <UserAvatar user={user} className="UserRow-avatar-img" />
@ -17,6 +18,11 @@ const UserRow = ({ user }: Props) => (
<div className="UserRow-info-main">{user.displayName}</div> <div className="UserRow-info-main">{user.displayName}</div>
<p className="UserRow-info-secondary">{user.title}</p> <p className="UserRow-info-secondary">{user.title}</p>
</div> </div>
{extra && (
<div className="UserRow-extra">
{extra}
</div>
)}
</Link> </Link>
); );

View File

@ -3,6 +3,7 @@
.UserRow { .UserRow {
position: relative; position: relative;
display: flex; display: flex;
align-items: center;
height: @height; height: @height;
margin-bottom: 1rem; margin-bottom: 1rem;
color: inherit; color: inherit;
@ -40,9 +41,16 @@
font-size: 0.9rem; font-size: 0.9rem;
opacity: 0.7; opacity: 0.7;
min-width: 0; min-width: 0;
margin: 0;
white-space: nowrap; white-space: nowrap;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
} }
} }
&-extra {
text-align: right;
margin-left: 1rem;
font-weight: bold;
}
} }

View File

@ -4,6 +4,7 @@ import {
getProposal, getProposal,
getProposalComments, getProposalComments,
getProposalUpdates, getProposalUpdates,
getProposalContributions,
postProposalComment as apiPostProposalComment, postProposalComment as apiPostProposalComment,
} from 'api/api'; } from 'api/api';
import { Dispatch } from 'redux'; import { Dispatch } from 'redux';
@ -54,6 +55,18 @@ export function fetchProposalUpdates(proposalId: Proposal['proposalId']) {
}; };
} }
export function fetchProposalContributions(proposalId: Proposal['proposalId']) {
return (dispatch: Dispatch<any>) => {
dispatch({
type: types.PROPOSAL_CONTRIBUTIONS,
payload: getProposalContributions(proposalId).then(res => ({
proposalId,
...res.data,
})),
});
};
}
export function postProposalComment( export function postProposalComment(
proposalId: Proposal['proposalId'], proposalId: Proposal['proposalId'],
comment: string, comment: string,

View File

@ -1,6 +1,6 @@
import types from './types'; import types from './types';
import { findComment } from 'utils/helpers'; import { findComment } from 'utils/helpers';
import { Proposal, ProposalComments, ProposalUpdates, Comment } from 'types'; import { Proposal, ProposalComments, ProposalUpdates, Comment, ProposalContributions } from 'types';
export interface ProposalState { export interface ProposalState {
proposals: Proposal[]; proposals: Proposal[];
@ -15,8 +15,15 @@ export interface ProposalState {
updatesError: null | string; updatesError: null | string;
isFetchingUpdates: boolean; isFetchingUpdates: boolean;
proposalContributions: { [id: string]: ProposalContributions };
fetchContributionsError: null | string;
isFetchingContributions: boolean;
isPostCommentPending: boolean; isPostCommentPending: boolean;
postCommentError: null | string; postCommentError: null | string;
isDeletingContribution: boolean;
deleteContributionError: null | string;
} }
export const INITIAL_STATE: ProposalState = { export const INITIAL_STATE: ProposalState = {
@ -32,8 +39,15 @@ export const INITIAL_STATE: ProposalState = {
updatesError: null, updatesError: null,
isFetchingUpdates: false, isFetchingUpdates: false,
proposalContributions: {},
fetchContributionsError: null,
isFetchingContributions: false,
isPostCommentPending: false, isPostCommentPending: false,
postCommentError: null, postCommentError: null,
isDeletingContribution: false,
deleteContributionError: null,
}; };
function addProposal(state: ProposalState, payload: Proposal) { function addProposal(state: ProposalState, payload: Proposal) {
@ -89,6 +103,17 @@ function addUpdates(state: ProposalState, payload: ProposalUpdates) {
}; };
} }
function addContributions(state: ProposalState, payload: ProposalContributions) {
return {
...state,
proposalContributions: {
...state.proposalContributions,
[payload.proposalId]: payload,
},
isFetchingContributions: false,
};
}
interface PostCommentPayload { interface PostCommentPayload {
proposalId: Proposal['proposalId']; proposalId: Proposal['proposalId'];
comment: Comment; comment: Comment;
@ -184,6 +209,22 @@ export default (state = INITIAL_STATE, action: any) => {
isFetchingUpdates: false, isFetchingUpdates: false,
}; };
case types.PROPOSAL_CONTRIBUTIONS_PENDING:
return {
...state,
fetchContributionsError: null,
isFetchingContributions: true,
};
case types.PROPOSAL_CONTRIBUTIONS_FULFILLED:
return addContributions(state, payload);
case types.PROPOSAL_CONTRIBUTIONS_REJECTED:
return {
...state,
// TODO: Get action to send real error
fetchContributionsError: 'Failed to fetch updates',
isFetchingContributions: false,
};
case types.POST_PROPOSAL_COMMENT_PENDING: case types.POST_PROPOSAL_COMMENT_PENDING:
return { return {
...state, ...state,

View File

@ -1,5 +1,5 @@
import { AppState } from 'store/reducers'; import { AppState } from 'store/reducers';
import { Proposal, ProposalComments, ProposalUpdates } from 'types'; import { Proposal, ProposalComments, ProposalUpdates, ProposalContributions } from 'types';
export function getProposals(state: AppState) { export function getProposals(state: AppState) {
return state.proposal.proposals; return state.proposal.proposals;
@ -74,3 +74,19 @@ export function getIsFetchingUpdates(state: AppState) {
export function getUpdatesError(state: AppState) { export function getUpdatesError(state: AppState) {
return state.proposal.updatesError; return state.proposal.updatesError;
} }
export function getProposalContributions(
state: AppState,
proposalId: Proposal['proposalId'],
): Omit<ProposalContributions, 'proposalId'> | null {
const pc = state.proposal.proposalContributions[proposalId];
return pc ? { top: pc.top, latest: pc.latest } : null;
}
export function getIsFetchingContributions(state: AppState) {
return state.proposal.isFetchingContributions;
}
export function getFetchContributionsError(state: AppState) {
return state.proposal.fetchContributionsError;
}

View File

@ -19,6 +19,11 @@ enum proposalTypes {
PROPOSAL_UPDATES_REJECTED = 'PROPOSAL_UPDATES_REJECTED', PROPOSAL_UPDATES_REJECTED = 'PROPOSAL_UPDATES_REJECTED',
PROPOSAL_UPDATES_PENDING = 'PROPOSAL_UPDATES_PENDING', PROPOSAL_UPDATES_PENDING = 'PROPOSAL_UPDATES_PENDING',
PROPOSAL_CONTRIBUTIONS = 'PROPOSAL_CONTRIBUTIONS',
PROPOSAL_CONTRIBUTIONS_FULFILLED = 'PROPOSAL_CONTRIBUTIONS_FULFILLED',
PROPOSAL_CONTRIBUTIONS_REJECTED = 'PROPOSAL_CONTRIBUTIONS_REJECTED',
PROPOSAL_CONTRIBUTIONS_PENDING = 'PROPOSAL_CONTRIBUTIONS_PENDING',
POST_PROPOSAL_COMMENT = 'POST_PROPOSAL_COMMENT', POST_PROPOSAL_COMMENT = 'POST_PROPOSAL_COMMENT',
POST_PROPOSAL_COMMENT_FULFILLED = 'POST_PROPOSAL_COMMENT_FULFILLED', POST_PROPOSAL_COMMENT_FULFILLED = 'POST_PROPOSAL_COMMENT_FULFILLED',
POST_PROPOSAL_COMMENT_REJECTED = 'POST_PROPOSAL_COMMENT_REJECTED', POST_PROPOSAL_COMMENT_REJECTED = 'POST_PROPOSAL_COMMENT_REJECTED',

View File

@ -5,6 +5,7 @@ import {
updateUser as apiUpdateUser, updateUser as apiUpdateUser,
fetchUserInvites as apiFetchUserInvites, fetchUserInvites as apiFetchUserInvites,
putInviteResponse, putInviteResponse,
deleteProposalContribution,
deleteProposalDraft, deleteProposalDraft,
putProposalPublish, putProposalPublish,
} from 'api/api'; } from 'api/api';
@ -92,6 +93,18 @@ export function respondToInvite(
}; };
} }
export function deleteContribution(userId: string | number, contributionId: string | number) {
// Fire and forget
deleteProposalContribution(contributionId);
return {
type: types.DELETE_CONTRIBUTION,
payload: {
userId,
contributionId,
},
};
}
export function deletePendingProposal(userId: number, proposalId: number) { export function deletePendingProposal(userId: number, proposalId: number) {
return async (dispatch: Dispatch<any>) => { return async (dispatch: Dispatch<any>) => {
await dispatch({ await dispatch({

View File

@ -1,7 +1,6 @@
import lodash from 'lodash'; import lodash from 'lodash';
import { UserProposal, UserComment, TeamInviteWithProposal } from 'types'; import { User, UserProposal, UserComment, UserContribution, TeamInviteWithProposal } from 'types';
import types from './types'; import types from './types';
import { User } from 'types';
export interface TeamInviteWithResponse extends TeamInviteWithProposal { export interface TeamInviteWithResponse extends TeamInviteWithProposal {
isResponding: boolean; isResponding: boolean;
@ -15,8 +14,8 @@ export interface UserState extends User {
isUpdating: boolean; isUpdating: boolean;
updateError: string | null; updateError: string | null;
pendingProposals: UserProposal[]; pendingProposals: UserProposal[];
createdProposals: UserProposal[]; proposals: UserProposal[];
fundedProposals: UserProposal[]; contributions: UserContribution[];
comments: UserComment[]; comments: UserComment[];
isFetchingInvites: boolean; isFetchingInvites: boolean;
hasFetchedInvites: boolean; hasFetchedInvites: boolean;
@ -45,8 +44,8 @@ export const INITIAL_USER_STATE: UserState = {
isUpdating: false, isUpdating: false,
updateError: null, updateError: null,
pendingProposals: [], pendingProposals: [],
createdProposals: [], proposals: [],
fundedProposals: [], contributions: [],
comments: [], comments: [],
isFetchingInvites: false, isFetchingInvites: false,
hasFetchedInvites: false, hasFetchedInvites: false,
@ -150,6 +149,13 @@ export default (state = INITIAL_STATE, action: any) => {
isResponding: false, isResponding: false,
respondError: errorMessage, respondError: errorMessage,
}); });
// delete contribution
case types.DELETE_CONTRIBUTION:
return updateUserState(state, payload.userId, {
contributions: state.map[payload.userId].contributions.filter(
c => c.id !== payload.contributionId
),
});
// proposal delete // proposal delete
case types.USER_DELETE_PROPOSAL_FULFILLED: case types.USER_DELETE_PROPOSAL_FULFILLED:
return removePendingProposal(state, payload.userId, payload.proposalId); return removePendingProposal(state, payload.userId, payload.proposalId);
@ -198,7 +204,7 @@ function updatePublishedProposal(
) { ) {
const withoutPending = removePendingProposal(state, userId, proposal.proposalId); const withoutPending = removePendingProposal(state, userId, proposal.proposalId);
const userUpdates = { const userUpdates = {
createdProposals: [proposal, ...state.map[userId].createdProposals], proposals: [proposal, ...state.map[userId].proposals],
}; };
return updateUserState(withoutPending, userId, userUpdates); return updateUserState(withoutPending, userId, userUpdates);
} }

View File

@ -19,6 +19,8 @@ enum UsersActions {
RESPOND_TO_INVITE_FULFILLED = 'RESPOND_TO_INVITE_FULFILLED', RESPOND_TO_INVITE_FULFILLED = 'RESPOND_TO_INVITE_FULFILLED',
RESPOND_TO_INVITE_REJECTED = 'RESPOND_TO_INVITE_REJECTED', RESPOND_TO_INVITE_REJECTED = 'RESPOND_TO_INVITE_REJECTED',
DELETE_CONTRIBUTION = 'DELETE_CONTRIBUTION',
USER_DELETE_PROPOSAL = 'USER_DELETE_PROPOSAL', USER_DELETE_PROPOSAL = 'USER_DELETE_PROPOSAL',
USER_DELETE_PROPOSAL_FULFILLED = 'USER_DELETE_PROPOSAL_FULFILLED', USER_DELETE_PROPOSAL_FULFILLED = 'USER_DELETE_PROPOSAL_FULFILLED',

View File

@ -20,8 +20,11 @@ export function formatUserFromGet(user: UserState) {
if (user.pendingProposals) { if (user.pendingProposals) {
user.pendingProposals = user.pendingProposals.map(bnUserProp); user.pendingProposals = user.pendingProposals.map(bnUserProp);
} }
user.createdProposals = user.createdProposals.map(bnUserProp); user.proposals = user.proposals.map(bnUserProp);
user.fundedProposals = user.fundedProposals.map(bnUserProp); user.contributions = user.contributions.map(c => {
c.amount = toZat(c.amount as any as string);
return c;
});
return user; return user;
} }
@ -30,17 +33,21 @@ export function formatProposalFromGet(p: any): Proposal {
proposal.proposalUrlId = generateProposalUrl(proposal.proposalId, proposal.title); proposal.proposalUrlId = generateProposalUrl(proposal.proposalId, proposal.title);
proposal.target = toZat(p.target); proposal.target = toZat(p.target);
proposal.funded = toZat(p.funded); proposal.funded = toZat(p.funded);
proposal.percentFunded = proposal.funded.div(proposal.target.divn(100)).toNumber(); proposal.percentFunded = proposal.target.isZero()
proposal.milestones = proposal.milestones.map((m: any, index: number) => { ? 0
return { : proposal.funded.div(proposal.target.divn(100)).toNumber();
...m, if (proposal.milestones) {
index, proposal.milestones = proposal.milestones.map((m: any, index: number) => {
amount: proposal.target.mul(new BN(m.payoutPercent)).divn(100), return {
// TODO: Get data from backend ...m,
state: MILESTONE_STATE.WAITING, index,
isPaid: false, amount: proposal.target.mul(new BN(m.payoutPercent)).divn(100),
}; // TODO: Get data from backend
}); state: MILESTONE_STATE.WAITING,
isPaid: false,
};
});
}
return proposal; return proposal;
} }
@ -83,10 +90,14 @@ export function massageSerializedState(state: AppState) {
return p; return p;
}; };
Object.values(state.users.map).forEach(user => { Object.values(state.users.map).forEach(user => {
user.createdProposals.forEach(bnUserProp); user.proposals = user.proposals.map(bnUserProp);
user.fundedProposals.forEach(bnUserProp); user.contributions = user.contributions.map(c => {
user.comments.forEach(c => { c.amount = new BN(c.amount, 16);
return c;
});
user.comments = user.comments.map(c => {
c.proposal = bnUserProp(c.proposal); c.proposal = bnUserProp(c.proposal);
return c;
}); });
}); });

View File

@ -85,3 +85,7 @@ export function formatZcashCLI(address: string, amount?: string | number, memo?:
} }
return `zcash-cli z_sendmany YOUR_ADDRESS '${JSON.stringify([tx])}'`; return `zcash-cli z_sendmany YOUR_ADDRESS '${JSON.stringify([tx])}'`;
} }
export function formatTxExplorerUrl(txid: string) {
return `https://explorer.zcha.in/transactions/${txid}`;
}

View File

@ -2,7 +2,6 @@ import * as React from 'react';
import { storiesOf } from '@storybook/react'; import { storiesOf } from '@storybook/react';
import { ProposalCampaignBlock } from 'components/Proposal/CampaignBlock'; import { ProposalCampaignBlock } from 'components/Proposal/CampaignBlock';
import Contributors from 'components/Proposal/Contributors';
import 'styles/style.less'; import 'styles/style.less';
import 'components/Proposal/style.less'; import 'components/Proposal/style.less';
@ -53,9 +52,4 @@ storiesOf('Proposal', module)
<div style={{ display: 'flex', flexWrap: 'wrap' }}> <div style={{ display: 'flex', flexWrap: 'wrap' }}>
<CampaignBlocks style={{ margin: '0 12px' }} /> <CampaignBlocks style={{ margin: '0 12px' }} />
</div> </div>
))
.add('Contributors', () => (
<div style={{ display: 'flex', justifyContent: 'center' }}>
<Contributors />
</div>
)); ));

View File

@ -1,8 +1,12 @@
import { Zat } from 'utils/units';
import { Proposal, User } from 'types';
export interface Contribution { export interface Contribution {
id: string; id: number;
txId: string; txId: string;
amount: string; amount: string;
dateCreated: number; dateCreated: number;
status: 'PENDING' | 'CONFIRMED';
} }
export interface ContributionWithAddresses extends Contribution { export interface ContributionWithAddresses extends Contribution {
@ -12,3 +16,13 @@ export interface ContributionWithAddresses extends Contribution {
memo: string; memo: string;
}; };
} }
export interface ContributionWithUser extends Contribution {
user: User;
}
export interface UserContribution extends Omit<Contribution, 'amount' | 'txId'> {
amount: Zat;
txId?: string;
proposal: Proposal;
}

View File

@ -1,7 +1,6 @@
import BN from 'bn.js';
import { Zat } from 'utils/units'; import { Zat } from 'utils/units';
import { PROPOSAL_CATEGORY } from 'api/constants'; import { PROPOSAL_CATEGORY } from 'api/constants';
import { CreateMilestone, Update, User, Comment } from 'types'; import { CreateMilestone, Update, User, Comment, ContributionWithUser } from 'types';
import { ProposalMilestone } from './milestone'; import { ProposalMilestone } from './milestone';
export interface TeamInvite { export interface TeamInvite {
@ -40,8 +39,8 @@ export interface ProposalDraft {
export interface Proposal extends Omit<ProposalDraft, 'target' | 'invites'> { export interface Proposal extends Omit<ProposalDraft, 'target' | 'invites'> {
proposalAddress: string; proposalAddress: string;
proposalUrlId: string; proposalUrlId: string;
target: BN; target: Zat;
funded: BN; funded: Zat;
percentFunded: number; percentFunded: number;
milestones: ProposalMilestone[]; milestones: ProposalMilestone[];
datePublished: number; datePublished: number;
@ -62,13 +61,19 @@ export interface ProposalUpdates {
updates: Update[]; updates: Update[];
} }
export interface ProposalContributions {
proposalId: Proposal['proposalId'];
top: ContributionWithUser[];
latest: ContributionWithUser[];
}
export interface UserProposal { export interface UserProposal {
proposalId: number; proposalId: number;
status: STATUS; status: STATUS;
title: string; title: string;
brief: string; brief: string;
funded: BN; funded: Zat;
target: BN; target: Zat;
dateCreated: number; dateCreated: number;
dateApproved: number; dateApproved: number;
datePublished: number; datePublished: number;