Rework Contribution Privacy (#456)

* rework contribution privacy
- add ProposalContribution.privacy
- remove ProposalContribution.no_refund
- anonymous contribution only if not logged in
- logged in contributions can be optionally attributed
- refunds can happen if a user has  a refund address

* admin: upgrade react-easy-state (had memory leak)

* be: filter users private contributions for others

* Adjust copy to be more accurate.

* Add copy for setting refund addresses (#458)
This commit is contained in:
AMStrix 2019-06-11 21:49:14 -05:00 committed by Daniel Ternyak
parent ec8dd4bfa6
commit 8819ee3035
16 changed files with 183 additions and 106 deletions

View File

@ -89,7 +89,7 @@
"react-copy-to-clipboard": "^5.0.1",
"react-dev-utils": "^5.0.2",
"react-dom": "16.5.2",
"react-easy-state": "^6.0.4",
"react-easy-state": "^6.1.3",
"react-hot-loader": "^4.3.8",
"react-router": "^4.3.1",
"react-router-dom": "^4.3.1",

View File

@ -36,23 +36,27 @@ class ContributionDetail extends React.Component<Props, State> {
const renderDeetItem = (label: string, val: React.ReactNode) => (
<div className="ContributionDetail-deet">
<div className="ContributionDetail-deet-value">
{val}
</div>
<div className="ContributionDetail-deet-label">
{label}
</div>
<div className="ContributionDetail-deet-value">{val}</div>
<div className="ContributionDetail-deet-label">{label}</div>
</div>
);
const renderSendRefund = () => {
if (c.staking || !c.refundAddress || c.refundTxId || !c.proposal.isFailed || !c.user) {
if (
c.staking ||
!c.refundAddress ||
c.refundTxId ||
!c.proposal.isFailed ||
!c.user
) {
return;
}
const percent = c.proposal.milestones.reduce((prev, m) => {
return m.datePaid ? prev - parseFloat(m.payoutPercent) : prev;
}, 100);
const amount = toZat(c.amount).muln(percent).divn(100);
const amount = toZat(c.amount)
.muln(percent)
.divn(100);
return (
<Alert
className="ContributionDetail-alert"
@ -62,15 +66,15 @@ class ContributionDetail extends React.Component<Props, State> {
description={
<div>
<p>
The proposal this contribution was made towards has failed, and
is ready to be refunded to <strong>{c.user.displayName}</strong>
{' '}for <strong>{percent}%</strong> of the contribution amount.
Please Make a payment of <strong>{fromZat(amount)} ZEC</strong> to:
The proposal this contribution was made towards has failed, and is ready
to be refunded to <strong>{c.user.displayName}</strong> for{' '}
<strong>{percent}%</strong> of the contribution amount. Please Make a
payment of <strong>{fromZat(amount)} ZEC</strong> to:
</p>
<pre>{c.refundAddress}</pre>
<p>
They will be sent an email notifying them of the refund when you
enter the txid below.
They will be sent an email notifying them of the refund when you enter the
txid below.
</p>
<Input.Search
placeholder="Enter payment txid"
@ -98,10 +102,9 @@ class ContributionDetail extends React.Component<Props, State> {
<pre>{JSON.stringify(c.addresses, null, 4)}</pre>
</Collapse.Panel>
<Collapse.Panel key="user" header="user">
{c.user ? <UserItem {...c.user} /> : <em>Anonymous contribution</em>}
</Collapse.Panel>
<Collapse.Panel key="user" header="user">
{c.user ? <UserItem {...c.user} /> : <em>Anonymous contribution</em>}
</Collapse.Panel>
<Collapse.Panel key="proposal" header="proposal">
<ProposalItem {...c.proposal} />
@ -118,7 +121,9 @@ class ContributionDetail extends React.Component<Props, State> {
{/* ACTIONS */}
<Card size="small" className="ContributionDetail-controls">
<Link to={`/contributions/${id}/edit`}>
<Button type="primary" block>Edit</Button>
<Button type="primary" block>
Edit
</Button>
</Link>
</Card>
@ -128,16 +133,20 @@ class ContributionDetail extends React.Component<Props, State> {
{renderDeetItem('created', formatDateSeconds(c.dateCreated))}
{renderDeetItem('status', c.status)}
{renderDeetItem('amount', c.amount)}
{renderDeetItem('txid', c.txId
? <Input size="small" value={c.txId} readOnly />
: <em>N/A</em>
{renderDeetItem(
'txid',
c.txId ? <Input size="small" value={c.txId} readOnly /> : <em>N/A</em>,
)}
{renderDeetItem('refund txid', c.refundTxId
? <Input size="small" value={c.refundTxId} readOnly />
: <em>N/A</em>
{renderDeetItem(
'refund txid',
c.refundTxId ? (
<Input size="small" value={c.refundTxId} readOnly />
) : (
<em>N/A</em>
),
)}
{renderDeetItem('staking tx', JSON.stringify(c.staking))}
{renderDeetItem('no refund', JSON.stringify(c.noRefund))}
{renderDeetItem('private', JSON.stringify(c.private))}
</Card>
</Col>
</Row>

View File

@ -152,7 +152,7 @@ export interface Contribution {
memo: string;
};
staking: boolean;
noRefund: boolean;
private: boolean;
refundAddress?: string;
refundTxId?: string;
}

View File

@ -792,9 +792,10 @@
reflect-metadata "^0.1.12"
tslib "^1.8.1"
"@nx-js/observer-util@^4.1.1":
version "4.2.0"
resolved "https://registry.yarnpkg.com/@nx-js/observer-util/-/observer-util-4.2.0.tgz#ec9e2f903dda94cc3d8ac077617bc369f2ad2d6e"
"@nx-js/observer-util@^4.2.2":
version "4.2.2"
resolved "https://registry.yarnpkg.com/@nx-js/observer-util/-/observer-util-4.2.2.tgz#faea1bc61936653486d1b5dec7485220991e628d"
integrity sha512-9OayX1xkdGjdnsDiO2YdaYJ6aMyCF7/NY4QWVgIgjSAZJ4OX2fD766Ts79hEzBscenQy2DCaSoY8VkguIMB1ZA==
"@samverschueren/stream-to-observable@^0.3.0":
version "0.3.0"
@ -6616,11 +6617,12 @@ react-dom@16.5.2:
prop-types "^15.6.2"
schedule "^0.5.0"
react-easy-state@^6.0.4:
version "6.0.4"
resolved "https://registry.yarnpkg.com/react-easy-state/-/react-easy-state-6.0.4.tgz#94a124fe69723abcb922c15059dd444b2e3499f3"
react-easy-state@^6.1.3:
version "6.1.3"
resolved "https://registry.yarnpkg.com/react-easy-state/-/react-easy-state-6.1.3.tgz#f9db4e8d842b5acfb73b6899aaf49a26900f7d26"
integrity sha512-uWQ7ittvJylwn/Xgz7Ub1jjsbpthQ9Ad1KDLxXfbXCb2OPnov4porVdnOJU2PKeRezcam3ZgfPUtf9L9rjtyWg==
dependencies:
"@nx-js/observer-util" "^4.1.1"
"@nx-js/observer-util" "^4.2.2"
react-error-overlay@^4.0.1:
version "4.0.1"

View File

@ -156,7 +156,6 @@ def stats():
contribution_refundable_count = db.session.query(func.count(ProposalContribution.id)) \
.filter(ProposalContribution.refund_tx_id == None) \
.filter(ProposalContribution.staking == False) \
.filter(ProposalContribution.no_refund == False) \
.filter(ProposalContribution.status == ContributionStatus.CONFIRMED) \
.join(Proposal) \
.filter(or_(
@ -609,7 +608,7 @@ def get_contribution(contribution_id):
@body({
"proposalId": fields.Int(required=False, missing=None),
"userId": fields.Int(required=False, missing=None),
"status": fields.Str(required=True, validate=validate.OneOf(choices=ContributionStatus.list())),
"status": fields.Str(required=False, missing=None, validate=validate.OneOf(choices=ContributionStatus.list())),
"amount": fields.Str(required=False, missing=None),
"txId": fields.Str(required=False, missing=None),
"refundTxId": fields.Str(required=False, allow_none=True, missing=None),
@ -723,6 +722,8 @@ def financials():
SELECT SUM(TO_NUMBER(amount, '{nfmt}'))
FROM proposal_contribution as pc
INNER JOIN proposal as p ON pc.proposal_id = p.id
LEFT OUTER JOIN "user" as u ON pc.user_id = u.id
LEFT OUTER JOIN user_settings as us ON u.id = us.user_id
WHERE {where}
'''
@ -743,14 +744,34 @@ def financials():
'staking': str(ex(sql_pc("status = 'CONFIRMED' AND staking = TRUE"))),
'funding': str(ex(sql_pc_p("pc.status = 'CONFIRMED' AND pc.staking = FALSE AND p.stage = 'FUNDING_REQUIRED'"))),
'funded': str(ex(sql_pc_p("pc.status = 'CONFIRMED' AND pc.staking = FALSE AND p.stage in ('WIP', 'COMPLETED')"))),
# should have a refund_address
'refunding': str(ex(sql_pc_p(
"pc.status = 'CONFIRMED' AND pc.staking = FALSE AND pc.no_refund = FALSE AND pc.refund_tx_id IS NULL AND p.stage IN ('CANCELED', 'FAILED')"
'''
pc.status = 'CONFIRMED' AND
pc.staking = FALSE AND
pc.refund_tx_id IS NULL AND
p.stage IN ('CANCELED', 'FAILED') AND
us.refund_address IS NOT NULL
'''
))),
# here we don't care about current refund_address of user, just that there has been a refund_tx_id
'refunded': str(ex(sql_pc_p(
"pc.status = 'CONFIRMED' AND pc.staking = FALSE AND pc.no_refund = FALSE AND pc.refund_tx_id IS NOT NULL AND p.stage IN ('CANCELED', 'FAILED')"
'''
pc.status = 'CONFIRMED' AND
pc.staking = FALSE AND
pc.refund_tx_id IS NOT NULL AND
p.stage IN ('CANCELED', 'FAILED')
'''
))),
# if there is no user, or the user hasn't any refund_address
'donations': str(ex(sql_pc_p(
"(pc.status = 'CONFIRMED' AND pc.staking = FALSE AND pc.refund_tx_id IS NULL) AND (pc.no_refund = TRUE OR pc.user_id IS NULL) AND p.stage IN ('CANCELED', 'FAILED')"
'''
pc.status = 'CONFIRMED' AND
pc.staking = FALSE AND
pc.refund_tx_id IS NULL AND
(pc.user_id IS NULL OR us.refund_address IS NULL) AND
p.stage IN ('CANCELED', 'FAILED')
'''
))),
'gross': str(ex(sql_pc_p("pc.status = 'CONFIRMED' AND pc.refund_tx_id IS NULL"))),
}

View File

@ -88,7 +88,7 @@ class ProposalContribution(db.Model):
tx_id = db.Column(db.String(255), nullable=True)
refund_tx_id = db.Column(db.String(255), nullable=True)
staking = db.Column(db.Boolean, nullable=False)
no_refund = db.Column(db.Boolean, nullable=False)
private = db.Column(db.Boolean, nullable=False, default=False, server_default='true')
user = db.relationship("User")
@ -98,23 +98,23 @@ class ProposalContribution(db.Model):
amount: str,
user_id: int = None,
staking: bool = False,
no_refund: bool = False,
private: bool = True,
):
self.proposal_id = proposal_id
self.amount = amount
self.user_id = user_id
self.staking = staking
self.no_refund = no_refund
self.private = private
self.date_created = datetime.datetime.now()
self.status = ContributionStatus.PENDING
@staticmethod
def get_existing_contribution(user_id: int, proposal_id: int, amount: str, no_refund: bool = False):
def get_existing_contribution(user_id: int, proposal_id: int, amount: str, private: bool = False):
return ProposalContribution.query.filter_by(
user_id=user_id,
proposal_id=proposal_id,
amount=amount,
no_refund=no_refund,
private=private,
status=ContributionStatus.PENDING,
).first()
@ -425,14 +425,14 @@ class Proposal(db.Model):
amount,
user_id: int = None,
staking: bool = False,
no_refund: bool = False,
private: bool = True,
):
contribution = ProposalContribution(
proposal_id=self.id,
amount=amount,
user_id=user_id,
staking=staking,
no_refund=no_refund,
private=private
)
db.session.add(contribution)
db.session.flush()
@ -841,6 +841,7 @@ class ProposalContributionSchema(ma.Schema):
"date_created",
"addresses",
"is_anonymous",
"private"
)
proposal = ma.Nested("ProposalSchema")
@ -861,11 +862,11 @@ class ProposalContributionSchema(ma.Schema):
}
def get_is_anonymous(self, obj):
return not obj.user_id
return not obj.user_id or obj.private
@post_dump
def stub_anonymous_user(self, data):
if 'user' in data and data['user'] is None:
if 'user' in data and data['user'] is None or data['private']:
data['user'] = anonymous_user
return data
@ -894,7 +895,7 @@ class AdminProposalContributionSchema(ma.Schema):
"refund_address",
"refund_tx_id",
"staking",
"no_refund",
"private",
)
proposal = ma.Nested("ProposalSchema")

View File

@ -478,30 +478,26 @@ def get_proposal_contribution(proposal_id, contribution_id):
@limiter.limit("30/day;10/hour;2/minute")
@body({
"amount": fields.Str(required=True, validate=lambda p: 0.0001 <= float(p) <= 1000000),
"anonymous": fields.Bool(required=False, missing=None),
"noRefund": fields.Bool(required=False, missing=False),
"private": fields.Bool(required=False, missing=True)
})
def post_proposal_contribution(proposal_id, amount, anonymous, no_refund):
def post_proposal_contribution(proposal_id, amount, private):
proposal = Proposal.query.filter_by(id=proposal_id).first()
if not proposal:
return {"message": "No proposal matching id"}, 404
code = 200
user = None
user = get_authed_user()
contribution = None
if not anonymous:
user = get_authed_user()
if user:
contribution = ProposalContribution \
.get_existing_contribution(user.id, proposal_id, amount, no_refund)
.get_existing_contribution(user.id, proposal_id, amount, private)
if not contribution:
code = 201
contribution = proposal.create_contribution(
amount=amount,
no_refund=no_refund,
private=private,
user_id=user.id if user else None,
)

View File

@ -66,6 +66,7 @@ def get_user(user_id, with_proposals, with_comments, with_funded, with_pending,
contributions = ProposalContribution.get_by_userid(user_id)
if not authed_user or user.id != authed_user.id:
contributions = [c for c in contributions if c.status == ContributionStatus.CONFIRMED]
contributions = [c for c in contributions if not c.private]
contributions = [c for c in contributions if c.proposal.status == ProposalStatus.LIVE]
contributions_dump = user_proposal_contributions_schema.dump(contributions)
result["contributions"] = contributions_dump

View File

@ -159,7 +159,6 @@ class ContributionPagination(Pagination):
if 'REFUNDABLE' in filters:
query = query.filter(ProposalContribution.refund_tx_id == None) \
.filter(ProposalContribution.staking == False) \
.filter(ProposalContribution.no_refund == False) \
.filter(ProposalContribution.status == ContributionStatus.CONFIRMED) \
.join(Proposal) \
.filter(or_(
@ -173,15 +172,14 @@ class ContributionPagination(Pagination):
if 'DONATION' in filters:
query = query.filter(ProposalContribution.refund_tx_id == None) \
.filter(ProposalContribution.status == ContributionStatus.CONFIRMED) \
.filter(or_(
ProposalContribution.no_refund == True,
ProposalContribution.user_id == None,
)) \
.join(Proposal) \
.filter(or_(
Proposal.stage == ProposalStage.FAILED,
Proposal.stage == ProposalStage.CANCELED,
))
)) \
.join(ProposalContribution.user, isouter=True) \
.join(UserSettings, isouter=True) \
.filter(UserSettings.refund_address == None)
# SORT (see self.SORT_MAP)
if sort:

View File

@ -0,0 +1,33 @@
"""proposal_contribution: add private, remove no_refund
Revision ID: 4505f00c4ebd
Revises: 0f08974b4118
Create Date: 2019-06-07 10:31:47.120185
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '4505f00c4ebd'
down_revision = '0f08974b4118'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.add_column('proposal_contribution', sa.Column('private', sa.Boolean(), server_default='true', nullable=False))
op.drop_column('proposal_contribution', 'no_refund')
# ### end Alembic commands ###
# existing contributions with user ids are public
op.execute("UPDATE proposal_contribution SET private = FALSE WHERE user_id IS NOT NULL")
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.add_column('proposal_contribution', sa.Column('no_refund', sa.BOOLEAN(), autoincrement=False, nullable=False))
op.drop_column('proposal_contribution', 'private')
# ### end Alembic commands ###

View File

@ -316,13 +316,11 @@ export function putInviteResponse(
export function postProposalContribution(
proposalId: number,
amount: string,
anonymous: boolean = false,
noRefund: boolean = false,
isPrivate: boolean = true,
): Promise<{ data: ContributionWithAddressesAndUser }> {
return axios.post(`/api/v1/proposals/${proposalId}/contributions`, {
amount,
anonymous,
noRefund,
private: isPrivate,
});
}

View File

@ -20,6 +20,7 @@ interface OwnProps {
contributionId?: number;
amount?: string;
isAnonymous?: boolean;
isPublic?: boolean;
hasNoButtons?: boolean;
text?: React.ReactNode;
handleClose(): void;
@ -127,17 +128,13 @@ class ContributionModal extends React.Component<Props, State> {
message="This contribution will not be attributed"
description={
<>
Your contribution will show up without attribution. Even if you're logged
in, the contribution will not appear anywhere on your account after you
close this modal.
ZF Grants is unable to offer refunds for contributions made without
accounts with a set refund address. If refunds for this campaign are issued, your contribution will be
treated as a donation to the Zcash Foundation.
<br /> <br />
ZF Grants is unable to offer refunds for non-attributed contributions. If
refunds for this campaign are issued, your contribution will be treated as a
donation to the Zcash Foundation.
<br /> <br />
If you would like to have your contribution attached to an account and
remain eligible for refunds, you can close this modal, make sure you're
logged in, and don't check the "Contribute without attribution" checkbox.
If you would like your contribution to be eligible for refund, you can close
this modal, make sure you're logged in, set a refund address, and attempt to contribute again. You
can still choose to contribute without public attribution while logged in.
<br /> <br />
NOTE: The Zcash Foundation is unable to accept donations of more than $5,000
USD worth of ZEC from anonymous users.
@ -221,7 +218,7 @@ class ContributionModal extends React.Component<Props, State> {
) {
this.setState({ isFetchingContribution: true });
try {
const { amount, isAnonymous, authUser } = this.props;
const { amount, isAnonymous, authUser, isPublic } = this.props;
// Ensure auth'd users have a refund address unless they've confirmed
if (!isAnonymous && !noRefund) {
@ -240,12 +237,7 @@ class ContributionModal extends React.Component<Props, State> {
if (contributionId) {
res = await getProposalContribution(proposalId, contributionId);
} else {
res = await postProposalContribution(
proposalId,
amount || '0',
isAnonymous,
noRefund,
);
res = await postProposalContribution(proposalId, amount || '0', !isPublic);
}
this.setState({ contribution: res.data });
} catch (err) {

View File

@ -1,7 +1,7 @@
import React from 'react';
import { connect } from 'react-redux';
import { Link } from 'react-router-dom';
import { Tag, Popconfirm } from 'antd';
import { Tag, Popconfirm, Tooltip } from 'antd';
import UnitDisplay from 'components/UnitDisplay';
import { ONE_DAY } from 'utils/time';
import { formatTxExplorerUrl } from 'utils/formatters';
@ -25,7 +25,7 @@ type Props = OwnProps & DispatchProps;
class ProfileContribution extends React.Component<Props> {
render() {
const { contribution } = this.props;
const { proposal } = contribution;
const { proposal, private: isPrivate } = contribution;
const isConfirmed = contribution.status === 'CONFIRMED';
const isExpired =
(!isConfirmed && contribution.dateCreated < Date.now() / 1000 - ONE_DAY) ||
@ -63,6 +63,18 @@ class ProfileContribution extends React.Component<Props> {
);
}
const privateTag = isPrivate ? (
<Tooltip
title={
<>
Other users will <b>not</b> be able to see that you made this contribution.
</>
}
>
<Tag>Private</Tag>
</Tooltip>
) : null;
return (
<div className="ProfileContribution">
<div className="ProfileContribution-info">
@ -70,7 +82,8 @@ class ProfileContribution extends React.Component<Props> {
className="ProfileContribution-info-title"
to={`/proposals/${proposal.proposalId}`}
>
{proposal.title} {tag}
{proposal.title} {privateTag}
{tag}
</Link>
<div className="ProfileContribution-info-brief">{proposal.brief}</div>
</div>

View File

@ -1,7 +1,7 @@
import React from 'react';
import moment from 'moment';
import { Form, Input, Checkbox, Button, Icon, Popover, Tooltip } from 'antd';
import { CheckboxChangeEvent } from 'antd/lib/checkbox';
import { Form, Input, Button, Icon, Popover, Tooltip, Radio } from 'antd';
import { RadioChangeEvent } from 'antd/lib/radio';
import { Proposal, STATUS } from 'types';
import classnames from 'classnames';
import { fromZat } from 'utils/units';
@ -30,7 +30,7 @@ type Props = OwnProps & StateProps;
interface State {
amountToRaise: string;
amountError: string | null;
isAnonymous: boolean;
isPrivate: boolean;
isContributing: boolean;
}
@ -40,14 +40,14 @@ export class ProposalCampaignBlock extends React.Component<Props, State> {
this.state = {
amountToRaise: '',
amountError: null,
isAnonymous: false,
isPrivate: true,
isContributing: false,
};
}
render() {
const { proposal, isPreview, authUser } = this.props;
const { amountToRaise, amountError, isAnonymous, isContributing } = this.state;
const { amountToRaise, amountError, isPrivate, isContributing } = this.state;
const amountFloat = parseFloat(amountToRaise) || 0;
let content;
if (proposal) {
@ -190,12 +190,23 @@ export class ProposalCampaignBlock extends React.Component<Props, State> {
</Form.Item>
{amountToRaise &&
!!authUser && (
<Checkbox checked={isAnonymous} onChange={this.handleChangeAnonymity}>
Contribute without attribution
<Tooltip title="Your contribution will not be linked to your account. ZF Grants cannot refund non-attributed contributions.">
<Icon type="question-circle" />
</Tooltip>
</Checkbox>
<Radio.Group
onChange={this.handleChangePrivate}
value={isPrivate ? 'isPrivate' : 'isNotPrivate'}
>
<Radio value={'isPrivate'}>
Contribute without attribution
<Tooltip title="Other users will not see who made this contribution.">
<Icon type="question-circle" />
</Tooltip>
</Radio>
<Radio value={'isNotPrivate'}>
Attribute contribution publicly
<Tooltip title="Other users will be able to see that you made this contribution.">
<Icon type="question-circle" />
</Tooltip>
</Radio>
</Radio.Group>
)}
<Button
onClick={this.openContributionModal}
@ -214,7 +225,8 @@ export class ProposalCampaignBlock extends React.Component<Props, State> {
isVisible={isContributing}
proposalId={proposal.proposalId}
amount={amountToRaise}
isAnonymous={isAnonymous || !authUser}
isAnonymous={!authUser}
isPublic={!isPrivate}
handleClose={this.closeContributionModal}
/>
</React.Fragment>
@ -255,8 +267,9 @@ export class ProposalCampaignBlock extends React.Component<Props, State> {
this.setState({ amountToRaise: value, amountError });
};
private handleChangeAnonymity = (ev: CheckboxChangeEvent) => {
this.setState({ isAnonymous: ev.target.checked });
private handleChangePrivate = (ev: RadioChangeEvent) => {
const isPrivate = ev.target.value === 'isPrivate';
this.setState({ isPrivate });
};
private openContributionModal = () => this.setState({ isContributing: true });

View File

@ -37,7 +37,7 @@
margin: 0.5rem -1.5rem;
padding: 0.75rem 1rem;
text-align: center;
color: #FFF;
color: #fff;
font-size: 1rem;
.anticon {
@ -57,7 +57,6 @@
margin-top: 0;
}
&-popover {
&-overlay {
max-width: 400px;
@ -91,7 +90,7 @@
width: 100%;
}
.ant-checkbox-wrapper {
.ant-radio-wrapper {
margin-bottom: 0.5rem;
opacity: 0.7;
font-size: 0.8rem;

View File

@ -30,4 +30,5 @@ export interface UserContribution extends Omit<Contribution, 'amount' | 'txId'>
amount: Zat;
txId?: string;
proposal: Proposal;
private: boolean;
}