/
pledges.js
515 lines (451 loc) · 16.3 KB
/
pledges.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
const ForeignGivethBridgeArtifact = require('giveth-bridge/build/ForeignGivethBridge.json');
const logger = require('winston');
const { toBN } = require('web3-utils');
const eventDecodersFromArtifact = require('./lib/eventDecodersFromArtifact');
const topicsFromArtifacts = require('./lib/topicsFromArtifacts');
const { getBlockTimestamp } = require('./lib/web3Helpers');
const { CampaignStatus } = require('../models/campaigns.model');
const { DonationStatus } = require('../models/donations.model');
const { MilestoneStatus } = require('../models/milestones.model');
const { AdminTypes } = require('../models/pledgeAdmins.model');
const toWrapper = require('../utils/to');
const reprocess = require('../utils/reprocess');
// only log necessary transferInfo
function logTransferInfo(transferInfo) {
const info = Object.assign({}, transferInfo, {
donations: transferInfo.donations.slice().map(d => {
// eslint-disable-next-line no-param-reassign
delete d.ownerEntity;
return d;
}),
fromPledgeAdmin: Object.assign({}, transferInfo.fromPledgeAdmin),
toPledgeAdmin: Object.assign({}, transferInfo.toPledgeAdmin),
});
delete info.fromPledgeAdmin.admin;
delete info.toPledgeAdmin.admin;
logger.error('missing from donation ->', JSON.stringify(info, null, 2));
}
function _retreiveTokenFromPledge(app, pledge) {
const tokenWhitelist = app.get('tokenWhitelist');
let token;
if (Array.isArray(tokenWhitelist))
token = tokenWhitelist.find(
t =>
typeof t.foreignAddress === 'string' &&
typeof pledge.token === 'string' &&
t.foreignAddress.toLowerCase() === pledge.token.toLowerCase(),
);
else {
throw new Error('Could not get tokenWhitelist or it is not defined');
}
if (!token)
throw new Error(
`Token address ${pledge.token} was not found in whitelist for pledge ${pledge}`,
);
return token;
}
// sort donations by pendingAmountRemaining (asc with undefined coming last)
function donationSort(a, b) {
const { pendingAmountRemaining: aVal } = a;
const { pendingAmountRemaining: bVal } = b;
if (aVal !== undefined) {
if (bVal === undefined) return -1;
// both are '0'
if (aVal === bVal) return 0;
if (aVal === '0') return -1;
if (bVal === '0') return 1;
// if both are defined, at least 1 value should be 0
logger.warn(
'donation sort detected 2 donations where pendingAmountRemaining was defined & > 0. Only 1 donation should have pendingAmountRemaining > 0',
);
} else if (bVal !== undefined) {
return 1;
}
return 0;
}
/**
* @param {object} transferInfo
*/
const isCommittedDelegation = ({ fromPledge, toPledge }) =>
Number(fromPledge.intendedProject) > 0 && fromPledge.intendedProject === toPledge.owner;
/**
* @param {object} transferInfo
*/
const isRejectedDelegation = ({ fromPledge, toPledge }) =>
Number(fromPledge.intendedProject) > 0 && fromPledge.intendedProject !== toPledge.owner;
/**
* @param {object} transferInfo
*/
const isDelegation = ({ intendedProject }) => !!intendedProject;
const getDonationStatus = transferInfo => {
const { toPledgeAdmin, delegate } = transferInfo;
const { pledgeState } = transferInfo.toPledge;
if (pledgeState === '1') return DonationStatus.PAYING;
if (pledgeState === '2') return DonationStatus.PAID;
if (isDelegation(transferInfo)) return DonationStatus.TO_APPROVE;
if (toPledgeAdmin.type === AdminTypes.GIVER || !!delegate) return DonationStatus.WAITING;
return DonationStatus.COMMITTED;
};
/**
* @param {number|string} commitTime liquidPledging `commitTime`
* @param {number} ts default commitTime
*/
const getCommitTime = (commitTime, ts) =>
// * 1000 is to convert evm ts to js ts
Number(commitTime) > 0 ? new Date(commitTime * 1000) : ts;
/**
* generate a mutation object used to create/update the `to` donation
*
* @param {object} transferInfo object containing information regarding the Transfer event
*/
function createToDonationMutation(app, transferInfo, isReturnTransfer) {
const {
toPledgeAdmin,
toPledge,
toPledgeId,
fromPledge,
delegate,
intendedProject,
donations,
amount,
ts,
txHash,
} = transferInfo;
// find token
const token = _retreiveTokenFromPledge(app, fromPledge);
const mutation = {
amount,
amountRemaining: amount,
giverAddress: donations[0].giverAddress, // all donations should have same giverAddress
ownerId: toPledge.owner,
ownerTypeId: toPledgeAdmin.typeId,
ownerType: toPledgeAdmin.type,
pledgeId: toPledgeId,
commitTime: getCommitTime(toPledge.commitTime, ts),
status: getDonationStatus(transferInfo),
createdAt: ts,
parentDonations: donations.map(d => d._id),
txHash,
mined: true,
token,
};
// lp keeps the delegation chain, but we want to ignore it
if (![DonationStatus.PAYING, DonationStatus.PAID].includes(mutation.status) && delegate) {
Object.assign(mutation, {
delegateId: delegate.id,
delegateTypeId: delegate.typeId,
delegateType: delegate.type,
});
}
if (intendedProject) {
Object.assign(mutation, {
intendedProjectId: intendedProject.id,
intendedProjectTypeId: intendedProject.typeId,
intendedProjectType: intendedProject.type,
});
}
if (isReturnTransfer || isRejectedDelegation(transferInfo)) {
mutation.isReturn = true;
}
return mutation;
}
/**
*
* @param {object} app feathers app instance
* @param {object} liquidPledging liquidPledging contract instance
*/
const pledges = (app, liquidPledging) => {
const web3 = app.getWeb3();
const donationService = app.service('donations');
const pledgeAdmins = app.service('pledgeAdmins');
/**
* Attempts to fetch the homeTxHash for an initial donation into lp.
*
* b/c we are using the bridge, we expect the ForeignGivethBridge Deposit event
* to occur in the same tx as the initial donation.
*
* @param {string} txHash txHash of the initialDonation to attempt to fetch a homeTxHash for
* @returns {string|undefined} homeTxHash if found
*/
async function getHomeTxHash(txHash) {
const decoders = eventDecodersFromArtifact(ForeignGivethBridgeArtifact);
const [err, receipt] = await toWrapper(web3.eth.getTransactionReceipt(txHash));
if (err || !receipt) {
logger.error('Error fetching transaction, or no tx receipt found ->', err, receipt);
return undefined;
}
const topics = topicsFromArtifacts([ForeignGivethBridgeArtifact], ['Deposit']);
// get logs we're interested in.
const logs = receipt.logs.filter(log => topics.some(t => t.hash === log.topics[0]));
if (logs.length === 0) return undefined;
const log = logs[0];
const topic = topics.find(t => t.hash === log.topics[0]);
const event = decoders[topic.name](log);
return event.returnValues.homeTx;
}
/**
* fetch donations for a pledge needed to fulfill the transfer amount
*
* lp will aggregate multiple donations by the same person to another entity
* into a single pledge. We keep them separated by donation. Here we fetch
* all donations needed to fulfill the amount being transferred from the pledge.
* Donations are spent in a FIFO order.
*
* @param {number|string} pledgeId lp pledgeId
* @param {number|string} amount amount that is being transferred
*/
async function getDonations(pledgeId, amount) {
const donations = await donationService.find({
paginate: false,
schema: 'includeTypeAndGiverDetails',
query: {
$sort: { createdAt: 1 },
pledgeId,
amountRemaining: { $ne: 0 },
},
});
donations.sort(donationSort);
let remaining = toBN(amount);
return donations.filter(d => {
if (remaining.gtn(0)) {
remaining = remaining.sub(toBN(d.amountRemaining));
return true;
}
return false;
});
}
function getPledgeAdmin(id) {
return pledgeAdmins.find({ paginate: false, query: { id } }).then(data => data[0]);
}
async function createDonation(mutation, isInitialTransfer = false, retry = false) {
const query = {
$limit: 1,
giverAddress: mutation.giverAddress,
amount: mutation.amount,
mined: false,
$or: [{ pledgeId: '0' }, { pledgeId: mutation.pledgeId }],
};
if (isInitialTransfer) {
// b/c new donations occur on a different network, we can't use the txHash here
// so attempt to find the 1st donation where all other params are the same
Object.assign(query, {
status: DonationStatus.PENDING,
ownerId: { $in: [0, mutation.ownerId] }, // w/ donateAndCreateGiver, ownerId === 0
delegateId: mutation.delegateId,
intendedProjectId: mutation.intendedProjectId,
txHash: undefined,
homeTxHash: { $exists: true },
$sort: {
createdAt: 1,
},
});
} else {
query.txHash = mutation.txHash;
}
const donations = await donationService.find({
paginate: false,
query,
});
if (donations.length === 0) {
// if this is the second attempt, then create a donation object
// otherwise, try and process the event later, giving time for
// the donation entity to be created via REST api first
// this is really only useful when instant mining. and re-syncing feathers w/ past events.
// Other then that, the donation should always be created before the tx was mined.
return retry
? donationService.create(mutation)
: reprocess(createDonation.bind(this, mutation, isInitialTransfer, true), 5000);
}
return donationService.patch(donations[0]._id, mutation);
}
async function newDonation(app, pledgeId, amount, ts, txHash) {
const pledge = await liquidPledging.getPledge(pledgeId);
const giver = await getPledgeAdmin(pledge.owner);
const token = _retreiveTokenFromPledge(app, pledge);
const mutation = {
giverAddress: giver.admin.address, // giver is a user type
amount,
amountRemaining: amount,
pledgeId,
ownerId: pledge.owner,
ownerTypeId: giver.typeId,
ownerType: giver.type,
status: DonationStatus.WAITING, // waiting for delegation by owner
mined: true,
createdAt: ts,
token,
intendedProjectId: pledge.intendedProject,
txHash,
};
return createDonation(mutation, !!txHash);
}
/**
* Determine if this transfer was a return of excess funds of an over-funded milestone
* @param {object} transferInfo
*/
async function isReturnTransfer(transferInfo) {
const { fromPledge, fromPledgeAdmin, toPledgeId, txHash, donations } = transferInfo;
// currently only milestones will can be over-funded
if (fromPledgeAdmin.type !== AdminTypes.MILESTONE) return false;
const from = donations[0].pledgeId; // all donations will have same pledgeId
const transferEventsInTx = await app
.service('events')
.find({ paginate: false, query: { transactionHash: txHash, event: 'Transfer' } });
// ex events in return case:
// Transfer(from: 1, to: 2, amount: 1000)
// Transfer(from: 2, to: 1, amount: < 1000)
return transferEventsInTx.some(
e =>
// it may go directly to fromPledge.oldPledge if this was delegated funds
// being returned b/c the intermediary pledge is the pledge w/ the intendedProject
[e.returnValues.from, fromPledge.oldPledge].includes(toPledgeId) &&
e.returnValues.to === from,
);
}
/**
* create a new donation for the `to` pledge
*
* @param {object} transferInfo
*/
async function createToDonation(transferInfo) {
const { txHash, donations } = transferInfo;
const isInitialTransfer = donations.length === 1 && donations[0].parentDonations.length === 0;
const mutation = createToDonationMutation(
app,
transferInfo,
await isReturnTransfer(transferInfo),
);
if (isInitialTransfer) {
// always set homeTx on mutation b/c ui checks if homeTxHash exists to check for initial donations
const homeTxHash = (await getHomeTxHash(txHash)) || 'unknown';
mutation.homeTxHash = homeTxHash;
}
return createDonation(mutation, isInitialTransfer);
}
/**
* patch existing donations we are transferring
*
* @param {object} transferInfo
*/
async function spendAndUpdateExistingDonations(transferInfo) {
const { donations, amount } = transferInfo;
let total = toBN(amount);
await Promise.all(
donations.map(async d => {
if (total.eqn(0)) {
logger.warn('too many donations fetched. total is already 0');
return;
}
// calculate remaining total & donation amountRemaining
let a = toBN(d.amountRemaining);
if (a.gte(total)) {
a = a.sub(total);
total = toBN(0);
} else {
total = total.sub(a);
a = toBN(0);
}
if (a.ltn(0)) {
throw new Error(`donation.amountRemaining is < 0: ${JSON.stringify(d)}`);
}
const mutation = {
amountRemaining: a.toString(),
pendingAmountRemaining: undefined,
};
if (isCommittedDelegation(transferInfo)) {
mutation.status = DonationStatus.COMMITTED;
}
if (isRejectedDelegation(transferInfo)) {
mutation.status = DonationStatus.REJECTED;
}
await donationService.patch(d._id, mutation);
}),
);
}
// fetches all necessary data to determine what happened for this Transfer event
async function transfer(from, to, amount, ts, txHash) {
try {
const [fromPledge, toPledge] = await Promise.all([
liquidPledging.getPledge(from),
liquidPledging.getPledge(to),
]);
const fromPledgeAdmin = await getPledgeAdmin(fromPledge.owner);
if (
(fromPledgeAdmin.type === AdminTypes.MILESTONE &&
fromPledgeAdmin.admin.status === MilestoneStatus.CANCELED) ||
(fromPledgeAdmin.type === AdminTypes.CAMPAIGN &&
fromPledgeAdmin.admin.status === CampaignStatus.CANCELED)
) {
// When a project is canceled in lp, the pledges are not "reverted" until they
// are normalized. This normalization function can be called, but it is also
// run on before every transfer. Thus we update the donations when handling
// the `CancelProject` event so the cache contains the appropriate info to
// normalize & transfer the pledge in a single call
return;
}
const promises = [getPledgeAdmin(toPledge.owner), getDonations(from, amount)];
// In lp any delegate in the chain can delegate, but currently we only allow last delegate
// to have that ability
if (toPledge.nDelegates > 0) {
promises.push(
liquidPledging
.getPledgeDelegate(to, toPledge.nDelegates)
.then(delegate => getPledgeAdmin(delegate.idDelegate)),
);
} else {
promises.push(undefined);
}
// fetch intendedProject pledgeAdmin
if (Number(toPledge.intendedProject) > 0) {
promises.push(getPledgeAdmin(toPledge.intendedProject));
} else {
promises.push(undefined);
}
const [toPledgeAdmin, donations, delegate, intendedProject] = await Promise.all(promises);
const transferInfo = {
fromPledgeAdmin,
toPledgeAdmin,
fromPledge,
toPledge,
toPledgeId: to,
delegate,
intendedProject,
donations,
amount,
ts,
txHash,
};
if (donations.length === 0) {
logTransferInfo(transferInfo);
// if from donation is missing, we can't do anything
return;
}
await spendAndUpdateExistingDonations(transferInfo);
await createToDonation(transferInfo);
} catch (err) {
logger.error(err);
}
}
return {
/**
* handle `Transfer` events
*
* @param {object} event Web3 event object
*/
async transfer(event) {
if (event.event !== 'Transfer') throw new Error('transfer only handles Transfer events');
const { from, to, amount } = event.returnValues;
const txHash = event.transactionHash;
const ts = await getBlockTimestamp(web3, event.blockNumber);
if (Number(from) === 0) {
const [err] = await toWrapper(newDonation(app, to, amount, ts, txHash));
if (err) {
logger.error('newDonation error ->', err);
}
} else {
await transfer(from, to, amount, ts, txHash);
}
},
};
};
module.exports = pledges;