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:
parent
a95a8ff080
commit
d367e6e474
|
@ -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)
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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 ###
|
|
@ -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"])
|
||||
|
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
|
|
|
@ -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),
|
||||
});
|
||||
};
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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 () => {
|
||||
.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);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
|
|
@ -0,0 +1,9 @@
|
|||
export interface Contribution {
|
||||
id: string;
|
||||
txId: string;
|
||||
proposalId: number;
|
||||
userId: number;
|
||||
fromAddress: string;
|
||||
amount: string;
|
||||
dateCreated: number;
|
||||
}
|
|
@ -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';
|
||||
|
|
Loading…
Reference in New Issue