Track proposal contributions (#219)

* BE proposal contribution tracking

* FE proposal contribution tracking

* validate contributions

* make sure we catch errors in the 'confirmation' listener

* remove console.log

* lowercase from address compare

* remove validate_contribution_tx from post_proposal_contribution
This commit is contained in:
AMStrix 2018-11-21 21:18:22 -06:00 committed by Daniel Ternyak
parent a95a8ff080
commit d367e6e474
11 changed files with 349 additions and 18 deletions

View File

@ -45,6 +45,33 @@ class ProposalUpdate(db.Model):
self.date_created = datetime.datetime.now()
class ProposalContribution(db.Model):
__tablename__ = "proposal_contribution"
tx_id = db.Column(db.String(255), primary_key=True)
date_created = db.Column(db.DateTime, nullable=False)
proposal_id = db.Column(db.Integer, db.ForeignKey("proposal.id"), nullable=False)
user_id = db.Column(db.Integer, db.ForeignKey("user.id"), nullable=True)
from_address = db.Column(db.String(255), nullable=False)
amount = db.Column(db.String(255), nullable=False) # in eth
def __init__(
self,
tx_id: str,
proposal_id: int,
user_id: int,
from_address: str,
amount: str
):
self.tx_id = tx_id
self.proposal_id = proposal_id
self.user_id = user_id
self.from_address = from_address
self.amount = amount
self.date_created = datetime.datetime.now()
class Proposal(db.Model):
__tablename__ = "proposal"
@ -60,6 +87,7 @@ class Proposal(db.Model):
team = db.relationship("User", secondary=proposal_team)
comments = db.relationship(Comment, backref="proposal", lazy=True)
updates = db.relationship(ProposalUpdate, backref="proposal", lazy=True)
contributions = db.relationship(ProposalContribution, backref="proposal", lazy=True)
milestones = db.relationship("Milestone", backref="proposal", lazy=True)
def __init__(
@ -110,6 +138,7 @@ class ProposalSchema(ma.Schema):
"body",
"comments",
"updates",
"contributions",
"milestones",
"category",
"team"
@ -121,6 +150,7 @@ class ProposalSchema(ma.Schema):
comments = ma.Nested("CommentSchema", many=True)
updates = ma.Nested("ProposalUpdateSchema", many=True)
contributions = ma.Nested("ProposalContributionSchema", many=True)
team = ma.Nested("UserSchema", many=True)
milestones = ma.Nested("MilestoneSchema", many=True)
@ -166,3 +196,30 @@ class ProposalUpdateSchema(ma.Schema):
proposal_update_schema = ProposalUpdateSchema()
proposals_update_schema = ProposalUpdateSchema(many=True)
class ProposalContributionSchema(ma.Schema):
class Meta:
model = ProposalContribution
# Fields to expose
fields = (
"id",
"tx_id",
"proposal_id",
"user_id",
"from_address",
"amount",
"date_created",
)
id = ma.Method("get_id")
date_created = ma.Method("get_date_created")
def get_id(self, obj):
return obj.tx_id
def get_date_created(self, obj):
return dt_to_unix(obj.date_created)
proposal_contribution_schema = ProposalContributionSchema()
proposals_contribution_schema = ProposalContributionSchema(many=True)

View File

@ -8,8 +8,17 @@ from grant.comment.models import Comment, comment_schema
from grant.milestone.models import Milestone
from grant.user.models import User, SocialMedia, Avatar
from grant.utils.auth import requires_sm, requires_team_member_auth
from grant.web3.proposal import read_proposal
from .models import Proposal, proposals_schema, proposal_schema, ProposalUpdate, proposal_update_schema, db
from grant.web3.proposal import read_proposal, validate_contribution_tx
from .models import(
Proposal,
proposals_schema,
proposal_schema,
ProposalUpdate,
proposal_update_schema,
ProposalContribution,
proposal_contribution_schema,
db
)
blueprint = Blueprint("proposal", __name__, url_prefix="/api/v1/proposals")
@ -209,3 +218,52 @@ def post_proposal_update(proposal_id, title, content):
dumped_update = proposal_update_schema.dump(update)
return dumped_update, 201
@blueprint.route("/<proposal_id>/contributions", methods=["GET"])
@endpoint.api()
def get_proposal_contributions(proposal_id):
proposal = Proposal.query.filter_by(id=proposal_id).first()
if proposal:
dumped_proposal = proposal_schema.dump(proposal)
return dumped_proposal["contributions"]
else:
return {"message": "No proposal matching id"}, 404
@blueprint.route("/<proposal_id>/contributions/<contribution_id>", methods=["GET"])
@endpoint.api()
def get_proposal_contribution(proposal_id, contribution_id):
proposal = Proposal.query.filter_by(id=proposal_id).first()
if proposal:
contribution = ProposalContribution.query.filter_by(tx_id=contribution_id).first()
if contribution:
return proposal_contribution_schema.dump(contribution)
else:
return {"message": "No contribution matching id"}
else:
return {"message": "No proposal matching id"}, 404
@blueprint.route("/<proposal_id>/contributions", methods=["POST"])
@requires_sm
@endpoint.api(
parameter('txId', type=str, required=True),
parameter('fromAddress', type=str, required=True),
parameter('amount', type=str, required=True)
)
def post_proposal_contribution(proposal_id, tx_id, from_address, amount):
proposal = Proposal.query.filter_by(id=proposal_id).first()
if proposal:
contribution = ProposalContribution(
tx_id=tx_id,
proposal_id=proposal_id,
user_id=g.current_user.id,
from_address=from_address,
amount=amount
)
db.session.add(contribution)
db.session.commit()
dumped_contribution = proposal_contribution_schema.dump(contribution)
return dumped_contribution, 201
return {"message": "No proposal matching id"}, 404

View File

@ -136,3 +136,14 @@ def read_proposal(address):
crowd_fund[k] = str(crowd_fund[k])
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

@ -0,0 +1,38 @@
"""empty message
Revision ID: 1d06a5e43324
Revises: 312db8611967
Create Date: 2018-11-17 11:07:40.413141
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '1d06a5e43324'
down_revision = '312db8611967'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('proposal_contribution',
sa.Column('tx_id', sa.String(length=255), nullable=False),
sa.Column('date_created', sa.DateTime(), nullable=False),
sa.Column('proposal_id', sa.Integer(), nullable=False),
sa.Column('user_id', sa.Integer(), nullable=True),
sa.Column('from_address', sa.String(length=255), nullable=False),
sa.Column('amount', sa.String(length=255), nullable=False),
sa.ForeignKeyConstraint(['proposal_id'], ['proposal.id'], ),
sa.ForeignKeyConstraint(['user_id'], ['user.id'], ),
sa.PrimaryKeyConstraint('tx_id')
)
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.drop_table('proposal_contribution')
# ### end Alembic commands ###

View File

@ -1,4 +1,5 @@
import json
from mock import patch
from grant.proposal.models import Proposal
from grant.user.models import SocialMedia, Avatar
@ -71,3 +72,110 @@ class TestAPI(BaseUserConfig):
)
self.assertEqual(proposal_res2.status_code, 409)
@patch('grant.proposal.views.validate_contribution_tx', return_value=True)
def test_create_proposal_contribution(self, mock_validate_contribution_tx):
proposal_res = self.app.post(
"/api/v1/proposals/",
data=json.dumps(test_proposal),
headers=self.headers,
content_type='application/json'
)
proposal_json = proposal_res.json
proposal_id = proposal_json["proposalId"]
contribution = {
"txId": "0x12345",
"fromAddress": "0x23456",
"amount": "1.2345"
}
contribution_res = self.app.post(
"/api/v1/proposals/{}/contributions".format(proposal_id),
data=json.dumps(contribution),
headers=self.headers,
content_type='application/json'
)
res = contribution_res.json
exp = contribution
def eq(k):
self.assertEqual(exp[k], res[k])
eq("txId")
eq("fromAddress")
eq("amount")
self.assertEqual(proposal_id, res["proposalId"])
@patch('grant.proposal.views.validate_contribution_tx', return_value=True)
def test_get_proposal_contribution(self, mock_validate_contribution_tx):
proposal_res = self.app.post(
"/api/v1/proposals/",
data=json.dumps(test_proposal),
headers=self.headers,
content_type='application/json'
)
proposal_json = proposal_res.json
proposal_id = proposal_json["proposalId"]
contribution = {
"txId": "0x12345",
"fromAddress": "0x23456",
"amount": "1.2345"
}
self.app.post(
"/api/v1/proposals/{}/contributions".format(proposal_id),
data=json.dumps(contribution),
headers=self.headers,
content_type='application/json'
)
contribution_res = self.app.get(
"/api/v1/proposals/{0}/contributions/{1}".format(proposal_id, contribution["txId"])
)
res = contribution_res.json
exp = contribution
def eq(k):
self.assertEqual(exp[k], res[k])
eq("txId")
eq("fromAddress")
eq("amount")
self.assertEqual(proposal_id, res["proposalId"])
@patch('grant.proposal.views.validate_contribution_tx', return_value=True)
def test_get_proposal_contributions(self, mock_validate_contribution_tx):
proposal_res = self.app.post(
"/api/v1/proposals/",
data=json.dumps(test_proposal),
headers=self.headers,
content_type='application/json'
)
proposal_json = proposal_res.json
proposal_id = proposal_json["proposalId"]
contribution = {
"txId": "0x12345",
"fromAddress": "0x23456",
"amount": "1.2345"
}
self.app.post(
"/api/v1/proposals/{}/contributions".format(proposal_id),
data=json.dumps(contribution),
headers=self.headers,
content_type='application/json'
)
contributions_res = self.app.get(
"/api/v1/proposals/{0}/contributions".format(proposal_id)
)
res = contributions_res.json[0]
exp = contribution
def eq(k):
self.assertEqual(exp[k], res[k])
eq("txId")
eq("fromAddress")
eq("amount")
self.assertEqual(proposal_id, res["proposalId"])

View File

@ -1,5 +1,5 @@
import axios from './axios';
import { Proposal, TeamMember, Update } from 'types';
import { Proposal, TeamMember, Update, Contribution } from 'types';
import {
formatProposalFromGet,
formatTeamMemberForPost,
@ -111,3 +111,16 @@ export function postProposalUpdate(
content,
});
}
export function postProposalContribution(
proposalId: number,
txId: string,
fromAddress: string,
amount: string,
): Promise<{ data: Contribution }> {
return axios.post(`/api/v1/proposals/${proposalId}/contributions`, {
txId,
fromAddress,
amount,
});
}

View File

@ -4,6 +4,7 @@ import {
getProposal,
getProposalComments,
getProposalUpdates,
postProposalContribution as apiPostProposalContribution,
} from 'api/api';
import { Dispatch } from 'redux';
import { ProposalWithCrowdFund, Comment } from 'types';
@ -108,3 +109,17 @@ export function postProposalComment(
}
};
}
export function postProposalContribution(
proposalId: number,
txId: string,
account: string,
amount: string,
) {
return async (dispatch: Dispatch<any>) => {
await dispatch({
type: types.POST_PROPOSAL_CONTRIBUTION,
payload: apiPostProposalContribution(proposalId, txId, account, amount),
});
};
}

View File

@ -1,4 +1,4 @@
enum clockTypes {
enum proposalTypes {
PROPOSALS_DATA = 'PROPOSALS_DATA',
PROPOSALS_DATA_FULFILLED = 'PROPOSALS_DATA_FULFILLED',
PROPOSALS_DATA_REJECTED = 'PROPOSALS_DATA_REJECTED',
@ -23,6 +23,8 @@ enum clockTypes {
POST_PROPOSAL_COMMENT_FULFILLED = 'POST_PROPOSAL_COMMENT_FULFILLED',
POST_PROPOSAL_COMMENT_REJECTED = 'POST_PROPOSAL_COMMENT_REJECTED',
POST_PROPOSAL_COMMENT_PENDING = 'POST_PROPOSAL_COMMENT_PENDING',
POST_PROPOSAL_CONTRIBUTION = 'POST_PROPOSAL_CONTRIBUTION',
}
export default clockTypes;
export default proposalTypes;

View File

@ -5,7 +5,11 @@ import { postProposal } from 'api/api';
import getContract, { WrongNetworkError } from 'lib/getContract';
import { sleep } from 'utils/helpers';
import { web3ErrorToString } from 'utils/web3';
import { fetchProposal, fetchProposals } from 'modules/proposals/actions';
import {
fetchProposal,
fetchProposals,
postProposalContribution,
} from 'modules/proposals/actions';
import { PROPOSAL_CATEGORY } from 'api/constants';
import { AppState } from 'store/reducers';
import { Wei } from 'utils/units';
@ -278,6 +282,14 @@ export function fundCrowdFund(proposal: ProposalWithCrowdFund, value: number | s
const { proposalAddress, proposalId } = proposal;
const crowdFundContract = await getCrowdFundContract(web3, proposalAddress);
const handleErr = (err: Error) => {
dispatch({
type: types.SEND_REJECTED,
payload: err.message || err.toString(),
error: true,
});
};
try {
if (!web3) {
throw new Error('No web3 instance available');
@ -285,20 +297,27 @@ export function fundCrowdFund(proposal: ProposalWithCrowdFund, value: number | s
await crowdFundContract.methods
.contribute()
.send({ from: account, value: web3.utils.toWei(String(value), 'ether') })
.once('confirmation', async () => {
await sleep(5000);
await dispatch(fetchProposal(proposalId));
dispatch({
type: types.SEND_FULFILLED,
});
.once('confirmation', async (_: number, receipt: any) => {
try {
await sleep(5000);
await dispatch(
postProposalContribution(
proposalId,
receipt.transactionHash,
account,
String(value),
),
);
await dispatch(fetchProposal(proposalId));
dispatch({
type: types.SEND_FULFILLED,
});
} catch (err) {
handleErr(err);
}
});
} catch (err) {
console.log(err);
dispatch({
type: types.SEND_REJECTED,
payload: err.message || err.toString(),
error: true,
});
handleErr(err);
}
};
}

View File

@ -0,0 +1,9 @@
export interface Contribution {
id: string;
txId: string;
proposalId: number;
userId: number;
fromAddress: string;
amount: string;
dateCreated: number;
}

View File

@ -2,6 +2,7 @@ export * from './user';
export * from './social';
export * from './create';
export * from './comment';
export * from './contribution';
export * from './milestone';
export * from './update';
export * from './proposal';