forked from MergeFi/backend
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathescrow.service.ts
More file actions
333 lines (298 loc) · 10.3 KB
/
Copy pathescrow.service.ts
File metadata and controls
333 lines (298 loc) · 10.3 KB
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
import {
BadRequestException,
Injectable,
Logger,
NotFoundException,
} from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { Escrow, Payment } from '../common/entities';
import { AssetType, EscrowStatus, PaymentStatus } from '../common/enums';
import {
amountToStroops,
isSupportedEscrowAsset,
isValidMoneyAmount,
} from '../common/validators/money.validator';
import { SorobanClientService } from './soroban-client.service';
export interface FundEscrowInput {
amount: string;
asset: AssetType;
funderAddress: string;
bountyId?: string;
milestoneId?: string;
maintenancePoolId?: string;
/**
* Denormalized sponsor identity, stored directly on the Escrow row rather
* than only reachable via a join to bounty/milestone. This is what lets
* sponsor-dashboard aggregates stay correct even after the parent
* bounty/milestone is deleted (#27) — omit for maintenance-pool escrows,
* which aren't sponsor-attributed.
*/
sponsorId?: string | null;
}
export interface SplitRecipient {
recipientAddress: string;
recipientId?: string;
percentage: number;
}
@Injectable()
export class EscrowService {
private readonly logger = new Logger(EscrowService.name);
constructor(
@InjectRepository(Escrow) private readonly escrowRepo: Repository<Escrow>,
@InjectRepository(Payment)
private readonly paymentRepo: Repository<Payment>,
private readonly soroban: SorobanClientService,
) {}
/** Locks funds for a bounty/milestone/pool by calling the escrow contract's `fund`. */
async fund(input: FundEscrowInput): Promise<Escrow> {
this.assertValidFundInput(input);
const escrow = this.escrowRepo.create({
amount: input.amount,
asset: input.asset,
status: EscrowStatus.PENDING,
fundedByAddress: input.funderAddress,
bountyId: input.bountyId ?? null,
milestoneId: input.milestoneId ?? null,
maintenancePoolId: input.maintenancePoolId ?? null,
sponsorId: input.sponsorId ?? null,
});
await this.escrowRepo.save(escrow);
try {
const referenceId =
input.bountyId ??
input.milestoneId ??
input.maintenancePoolId ??
escrow.id;
const result = await this.soroban.invoke('fund', [
input.funderAddress,
referenceId,
this.toStroops(input.amount),
]);
escrow.status = EscrowStatus.LOCKED;
escrow.fundTxHash = result.txHash;
escrow.lockedAt = new Date();
escrow.metadata = { fund: result };
return this.escrowRepo.save(escrow);
} catch (err) {
escrow.status = EscrowStatus.FAILED;
escrow.metadata = { error: (err as Error).message };
await this.escrowRepo.save(escrow);
throw err;
}
}
/** Releases the full escrowed amount to a single recipient (standard bounty payout). */
async release(
escrowId: string,
recipientAddress: string,
recipientId?: string,
): Promise<Escrow> {
const escrow = await this.getOrThrow(escrowId);
this.assertLocked(escrow);
const result = await this.soroban.invoke('release', [
escrow.bountyId ??
escrow.milestoneId ??
escrow.maintenancePoolId ??
escrow.id,
recipientAddress,
]);
escrow.status = EscrowStatus.RELEASED;
escrow.releaseTxHash = result.txHash;
escrow.releasedAt = new Date();
await this.escrowRepo.save(escrow);
const payment = this.paymentRepo.create({
escrowId: escrow.id,
recipientId: recipientId ?? null,
recipientAddress,
amount: escrow.amount,
asset: escrow.asset,
status: PaymentStatus.CONFIRMED,
txHash: result.txHash,
});
await this.paymentRepo.save(payment);
return escrow;
}
/**
* Splits the escrowed amount across multiple recipients by percentage
* (team bounties). Percentages must sum to exactly 100.
*/
async splitRelease(
escrowId: string,
recipients: SplitRecipient[],
): Promise<Payment[]> {
const escrow = await this.getOrThrow(escrowId);
this.assertLocked(escrow);
this.assertValidSplits(recipients);
const result = await this.soroban.invoke('split_release', [
escrow.bountyId ?? escrow.milestoneId ?? escrow.id,
recipients.map((r) => r.recipientAddress),
recipients.map((r) => Math.round(r.percentage * 100)), // basis points-ish, 2dp -> integer
]);
escrow.status = EscrowStatus.RELEASED;
escrow.releaseTxHash = result.txHash;
escrow.releasedAt = new Date();
await this.escrowRepo.save(escrow);
const totalAmount = Number(escrow.amount);
const payments: Payment[] = [];
for (const recipient of recipients) {
const share = this.roundAmount(
(totalAmount * recipient.percentage) / 100,
);
const payment = this.paymentRepo.create({
escrowId: escrow.id,
recipientId: recipient.recipientId ?? null,
recipientAddress: recipient.recipientAddress,
amount: share.toFixed(7),
asset: escrow.asset,
splitPercentage: recipient.percentage.toFixed(2),
status: PaymentStatus.CONFIRMED,
txHash: result.txHash,
});
payments.push(await this.paymentRepo.save(payment));
}
return payments;
}
/**
* Releases a portion of a LOCKED escrow to a single recipient without
* closing it out — used by milestone funding, where the total budget is
* distributed incrementally as individual issues resolve. The escrow
* moves to RELEASED once the cumulative released amount reaches the
* total locked amount.
*/
async releasePartial(
escrowId: string,
amount: string,
recipientAddress: string,
recipientId?: string,
): Promise<Payment> {
const escrow = await this.getOrThrow(escrowId);
this.assertLocked(escrow);
this.assertValidAmount(amount);
const existingPayments = await this.paymentRepo.find({
where: { escrowId: escrow.id },
});
const releasedSoFar = existingPayments.reduce(
(sum, p) => sum + Number(p.amount),
0,
);
const requested = Number(amount);
if (releasedSoFar + requested > Number(escrow.amount) + 1e-7) {
throw new BadRequestException(
`Partial release of ${amount} would exceed remaining escrow balance`,
);
}
const result = await this.soroban.invoke('release', [
escrow.milestoneId ?? escrow.bountyId ?? escrow.id,
recipientAddress,
this.toStroops(amount),
]);
const payment = await this.paymentRepo.save(
this.paymentRepo.create({
escrowId: escrow.id,
recipientId: recipientId ?? null,
recipientAddress,
amount,
asset: escrow.asset,
status: PaymentStatus.CONFIRMED,
txHash: result.txHash,
}),
);
if (releasedSoFar + requested >= Number(escrow.amount) - 1e-7) {
escrow.status = EscrowStatus.RELEASED;
escrow.releaseTxHash = result.txHash;
escrow.releasedAt = new Date();
await this.escrowRepo.save(escrow);
}
return payment;
}
/** Refunds the full escrowed amount back to the original funder. */
async refund(escrowId: string): Promise<Escrow> {
const escrow = await this.getOrThrow(escrowId);
this.assertLocked(escrow);
const result = await this.soroban.invoke('refund', [
escrow.bountyId ??
escrow.milestoneId ??
escrow.maintenancePoolId ??
escrow.id,
]);
escrow.status = EscrowStatus.REFUNDED;
escrow.refundTxHash = result.txHash;
escrow.refundedAt = new Date();
return this.escrowRepo.save(escrow);
}
async findOne(id: string): Promise<Escrow> {
return this.getOrThrow(id);
}
private async getOrThrow(id: string): Promise<Escrow> {
const escrow = await this.escrowRepo.findOne({ where: { id } });
if (!escrow) throw new NotFoundException(`Escrow ${id} not found`);
return escrow;
}
private assertLocked(escrow: Escrow) {
if (escrow.status !== EscrowStatus.LOCKED) {
throw new BadRequestException(
`Escrow ${escrow.id} is not in LOCKED state (current: ${escrow.status})`,
);
}
}
/** Validates that split percentages sum to 100.00, within floating point tolerance. */
assertValidSplits(recipients: SplitRecipient[]): void {
if (recipients.length === 0) {
throw new BadRequestException(
'At least one recipient is required for a split release',
);
}
const total = recipients.reduce((sum, r) => sum + r.percentage, 0);
if (Math.abs(total - 100) > 0.01) {
throw new BadRequestException(
`Split percentages must sum to 100, got ${total.toFixed(2)}`,
);
}
if (recipients.some((r) => r.percentage <= 0)) {
throw new BadRequestException('Split percentages must be positive');
}
}
private roundAmount(value: number): number {
return Math.round(value * 1e7) / 1e7;
}
private assertValidFundInput(input: FundEscrowInput): void {
this.assertValidAmount(input.amount);
if (!isSupportedEscrowAsset(input.asset)) {
throw new BadRequestException(
`Unsupported escrow asset: ${String(input.asset)}`,
);
}
this.assertExactlyOneParent(input);
}
/**
* A newly-created escrow must belong to exactly one of
* bounty/milestone/maintenancePool. This is deliberately an
* application-level check rather than a DB CHECK constraint: the
* database only enforces "at most one" (CHK_escrow_at_most_one_parent),
* because ON DELETE SET NULL legitimately drives an existing escrow's
* parent count to zero when its parent is deleted, and a stricter
* "exactly one" constraint would make that very SET NULL fail (#27).
*/
private assertExactlyOneParent(input: FundEscrowInput): void {
const parentCount = [
input.bountyId,
input.milestoneId,
input.maintenancePoolId,
].filter((id) => id != null).length;
if (parentCount !== 1) {
throw new BadRequestException(
'Exactly one of bountyId, milestoneId, or maintenancePoolId is required',
);
}
}
private assertValidAmount(amount: string): void {
if (!isValidMoneyAmount(amount)) {
throw new BadRequestException(
'Amount must be a positive decimal string with at most 7 fractional digits and no more than 100000000',
);
}
}
private toStroops(amount: string): bigint {
return amountToStroops(amount);
}
}