Repository navigation
Expand file tree
/
Copy pathstellarService.js
More file actions
1168 lines (1063 loc) · 41.1 KB
/
Copy pathstellarService.js
File metadata and controls
1168 lines (1063 loc) · 41.1 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
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
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
"use strict";
const { Operation } = require("@stellar/stellar-sdk");
const {
server,
isAcceptedAsset,
CONFIRMATION_THRESHOLD,
FINALIZATION_THRESHOLD,
} = require("../config/stellarConfig");
const mongoose = require("mongoose");
const Payment = require("../models/paymentModel");
const Student = require("../models/studentModel");
const PaymentIntent = require("../models/paymentIntentModel");
const { validatePaymentAmount } = require("../utils/paymentLimits");
const { toStroops, stroopsToNumber, compareAmounts } = require("../utils/stellarAmount");
const {
SUPPORTED_MEMO_TYPES,
normalizeMemoType,
decodeMemoToCanonical,
} = require("../utils/stellarMemo");
const { withStellarRetry } = require("../utils/withStellarRetry");
const { savePayment } = require("./transactionService");
const { deriveCorrelationId } = require("../utils/correlationId");
const lock = require("./distributedLock");
const {
CONFIRMATION_STATES,
computeTargetState,
resolveNextState,
deriveLegacyConfirmationStatus,
isConfirmedOrAbove,
} = require("./paymentConfirmationStateMachine");
const logger = require("../utils/logger").child("StellarService");
const { getEngineForSchool } = require("./feeAdjustmentEngine");
// TTL for the per-student balance lock (#1026), shared with paymentController's
// verifyPayment. Guards the read-aggregate-then-write sequence that computes
// cumulativeTotal and writes it as an absolute Student.$set — unsafe to run
// concurrently for the same student, whether the other writer is verifyPayment
// or another syncPaymentsForSchool run.
const STUDENT_BALANCE_LOCK_TTL_MS = parseInt(process.env.STUDENT_BALANCE_LOCK_TTL_MS || "15000", 10);
function detectAsset(payOp) {
const assetType = payOp.asset_type;
const assetCode = assetType === "native" ? "XLM" : payOp.asset_code;
const assetIssuer = assetType === "native" ? null : payOp.asset_issuer;
// Pass the issuer so credit assets (USDC) are validated against the pinned
// canonical issuer for the active network (#841). A fake "USDC" from any
// other issuer is rejected here.
const { accepted } = isAcceptedAsset(assetCode, assetType, assetIssuer);
if (!accepted) return null;
return { assetCode, assetType, assetIssuer };
}
/**
* Normalize a Horizon API amount string to a JS number using the Stellar SDK
* as the canonical source of truth for 7-decimal-place precision.
*
* Horizon returns amounts as decimal strings (e.g. '100.0000000'). Both XLM
* and USDC use 7 decimal places on Stellar, so the same conversion applies to
* all accepted assets. We round-trip through stroops via Operation._fromXDRAmount
* to guarantee the SDK's precision rules are applied consistently.
*
* @param {string} rawAmount - Decimal amount string from Horizon API
* @returns {number}
*/
function normalizeAmount(rawAmount) {
// Convert Horizon decimal string → stroop integer → SDK-normalized decimal
const stroops = String(Math.round(parseFloat(rawAmount) * 1e7));
return parseFloat(Operation._fromXDRAmount(stroops));
}
/**
* Extract and validate the payment operation from a transaction.
* Handles fee-bump transactions by unwrapping to the inner transaction.
* walletAddress is passed explicitly — supports per-school wallets.
* Returns { payOp, memo, asset, memoType } or null if the transaction is invalid.
*
* Memo type handling:
* - MEMO_TEXT: Valid for student ID matching
* - MEMO_NONE: No memo provided (MISSING_MEMO)
* - MEMO_ID, MEMO_HASH, MEMO_RETURN: Unsupported types (UNSUPPORTED_MEMO_TYPE)
*/
async function extractValidPayment(tx, walletAddress) {
// #840 — A transaction can be included in a ledger yet have
// `successful === false` (e.g. a failed operation). Such transactions must
// NEVER be credited. Require an explicit success flag rather than merely
// truthy, so a failed-but-included tx (or one missing the flag) is rejected.
if (tx.successful !== true) return null;
// #1556 — Fee-bump transactions: Horizon exposes memo_type, memo and the
// operations() link on the TOP-LEVEL transaction record regardless of whether
// it is a fee-bump or a regular transaction. The inner_transaction sub-object
// only carries { hash, signatures, max_fee } — it does NOT have memo_type,
// memo, or operations(). Reading those from inner_transaction always yields
// undefined / TypeError, so fee-bumped payments were silently dropped.
// Fix: always use the top-level `tx` for memo/operations. The inner hash is
// preserved separately for traceability when it differs.
const isFeeBomp = Boolean(tx.inner_transaction);
const innerHash = isFeeBomp ? tx.inner_transaction.hash : null;
// Check memo type and handle accordingly
const memoType = tx.memo_type || 'none';
if (memoType === 'none') {
// No memo provided
return null;
}
if (!normalizeMemoType(memoType)) {
// MEMO_RETURN — no canonical encoding, so it cannot identify a payment.
logger.warn('Transaction has unsupported memo type', {
txHash: tx.hash,
innerHash,
memoType,
memo: tx.memo,
});
return null;
}
// MEMO_ID / MEMO_HASH decode back to the canonical intent memo (#1118).
const memo = decodeMemoToCanonical(tx.memo, memoType);
if (!memo) {
logger.warn('Transaction memo could not be decoded to a payment reference', {
txHash: tx.hash,
innerHash,
memoType,
});
return null;
}
const ops = await withStellarRetry(() => tx.operations(), {
label: "extractValidPayment.operations",
context: `Transaction ${tx.hash} operations`,
});
// #840 — only a successful `payment` operation landing on the school wallet
// is creditable. detectAsset then enforces the asset/issuer (#841): a
// wrong-asset or fake-issuer operation yields null and is rejected here.
const payOp = ops.records.find(
(op) => op.type === "payment" && op.to === walletAddress,
);
if (!payOp) return null;
const asset = detectAsset(payOp);
if (!asset) return null;
return { payOp, memo, asset, memoType, innerHash };
}
function validatePaymentAgainstFee(paymentAmount, expectedFee) {
// Compare in exact integer stroop space (#842). Float comparison of the paid
// amount vs the fee can mis-judge an exact-match payment as short/over by a
// rounding epsilon; stroop comparison is exact.
const paidStroops = toStroops(paymentAmount);
const feeStroops = toStroops(expectedFee);
if (paidStroops < feeStroops) {
return {
status: "underpaid",
excessAmount: 0,
message: `Payment of ${paymentAmount} is less than the required fee of ${expectedFee}`,
};
}
if (paidStroops > feeStroops) {
const excess = stroopsToNumber(paidStroops - feeStroops);
return {
status: "overpaid",
excessAmount: excess,
message: `Payment of ${paymentAmount} exceeds the required fee of ${expectedFee} by ${excess}`,
};
}
return {
status: "valid",
excessAmount: 0,
message: "Payment matches the required fee",
};
}
/**
* Calculate adjusted fee using the DynamicFeeAdjustmentEngine and validate payment.
* Issue #1474: Wires the fee adjustment engine into the live payment validation path.
*
* @param {string} schoolId - School ID for tenant isolation
* @param {number} paymentAmount - The amount the student paid
* @param {number} baseFee - The base fee amount
* @param {Object} context - Fee calculation context (promoCode, isEarly, etc.)
* @returns {Object} Validation result with status, finalFee, and adjustments
*/
function validatePaymentWithDynamicFee(schoolId, paymentAmount, baseFee, context = {}) {
try {
const engine = getEngineForSchool(schoolId);
const feeCalculation = engine.calculateFee({
baseAmount: baseFee,
schoolId,
...context,
});
const finalFee = feeCalculation.finalFee;
const validation = validatePaymentAgainstFee(paymentAmount, finalFee);
return {
...validation,
baseFee,
finalFee,
adjustments: feeCalculation.adjustments,
effectiveRate: feeCalculation.effectiveRate,
};
} catch (err) {
logger.error('Fee adjustment engine error', {
schoolId,
paymentAmount,
baseFee,
error: err.message,
});
// Fallback to non-adjusted fee validation on engine error
return {
...validatePaymentAgainstFee(paymentAmount, baseFee),
baseFee,
finalFee: baseFee,
adjustments: [],
fallbackToBaseFee: true,
};
}
}
async function getLatestLedgerSequence(label) {
const latestLedger = await withStellarRetry(
() => server.ledgers().order("desc").limit(1).call(),
{ label },
);
return latestLedger.records[0].sequence;
}
async function checkConfirmationStatus(txLedger) {
const latestSequence = await getLatestLedgerSequence(
"checkConfirmationStatus",
);
return latestSequence - txLedger >= CONFIRMATION_THRESHOLD;
}
/**
* Determine the next confirmation state for a payment per the finality
* policy (issue #747). Fetches the latest Horizon ledger sequence, computes
* the state the ledger depth + suspicion flag implies, then resolves it
* against the payment's current state via the idempotent/monotonic state
* machine — re-running this with the same inputs (e.g. a re-poll of the same
* ledger range) always yields the same result and never regresses a payment.
*
* @param {number|null} txLedger - ledger sequence the tx was included in
* @param {string} [currentState] - payment's current confirmationState (defaults to 'detected')
* @param {boolean} [isSuspicious] - fraud/anomaly signal
* @returns {Promise<{ state: string, changed: boolean, confirmationStatus: string, latestLedgerSequence: number }>}
*/
async function determineConfirmationState(
txLedger,
currentState = CONFIRMATION_STATES.DETECTED,
isSuspicious = false,
) {
const latestLedgerSequence = await getLatestLedgerSequence(
"determineConfirmationState",
);
const targetState = computeTargetState({
txLedger,
latestLedgerSequence,
isSuspicious,
confirmationThreshold: CONFIRMATION_THRESHOLD,
finalizationThreshold: FINALIZATION_THRESHOLD,
});
const { state, changed } = resolveNextState(currentState, targetState);
return {
state,
changed,
confirmationStatus: deriveLegacyConfirmationStatus(state),
latestLedgerSequence,
};
}
/**
* Detect memo collision: same memo used by a different sender within 24h,
* or payment amount is wildly outside the expected fee range.
* Query is school-scoped via schoolId.
*
* The senderAddress predicate uses $nin to express "not this sender and not
* null" in a single operator, avoiding the duplicate-key bug that previously
* caused { $ne: senderAddress, $exists: true, $ne: null } to collapse to
* { $exists: true, $ne: null } at JS parse time (ESLint no-dupe-keys, #1271).
*/
async function detectMemoCollision(
memo,
senderAddress,
paymentAmount,
expectedFee,
txDate,
schoolId,
) {
const COLLISION_WINDOW_MS = 24 * 60 * 60 * 1000;
const windowStart = new Date(txDate.getTime() - COLLISION_WINDOW_MS);
const recentFromOtherSender = await Payment.findOne({
schoolId,
memo,
// $nin expresses "not senderAddress AND not null" without a duplicate $ne key.
// $exists: true additionally excludes documents where the field is absent.
senderAddress: { $nin: [senderAddress, null], $exists: true },
confirmedAt: { $gte: windowStart },
deletedAt: { $in: [null, undefined] },
});
if (recentFromOtherSender) {
return {
suspicious: true,
reason:
'Memo "' +
memo +
'" was used by a different sender (' +
recentFromOtherSender.senderAddress +
") within the last 24 hours",
};
}
return { suspicious: false, reason: null };
}
/**
* Detect memo collision across schools: the same memo (student ID) was
* recorded as a confirmed payment for a *different* school within the last
* 24 hours. Memos are only unique within a school's own student roster
* (`Student.studentId` is school-scoped), so two unrelated schools can
* legitimately assign the same ID to different students — but a payment
* landing under that ID at two schools in a short window is worth flagging
* for manual review rather than silently trusting both.
*
* Deliberately independent of `detectMemoCollision` (which is single-school,
* sender-based) — this is the cross-school signal the original function
* explicitly does not cover.
*/
async function detectCrossSchoolMemoCollision(memo, schoolId, txDate) {
const COLLISION_WINDOW_MS = 24 * 60 * 60 * 1000;
const windowStart = new Date(txDate.getTime() - COLLISION_WINDOW_MS);
const recentFromOtherSchool = await Payment.findOne({
schoolId: { $ne: schoolId },
studentId: memo,
confirmedAt: { $gte: windowStart },
deletedAt: null,
});
if (recentFromOtherSchool) {
return {
suspicious: true,
reason:
'Memo "' +
memo +
'" was also used for a payment to a different school (' +
recentFromOtherSchool.schoolId +
") within the last 24 hours",
};
}
return { suspicious: false, reason: null };
}
/**
* Compute the mean and (population) standard deviation of a school's confirmed,
* non-suspicious payment amounts within a lookback window. Used to base the
* suspicious-amount threshold on each tenant's OWN distribution rather than a
* flat multiplier off the expected fee.
*
* @param {string} schoolId
* @param {number} windowDays lookback window
* @returns {Promise<{count:number, mean:number, std:number}>}
*/
async function computeHistoricalAmountStats(schoolId, windowDays) {
const windowStart = new Date(Date.now() - windowDays * 24 * 60 * 60 * 1000);
const rows = await Payment.aggregate([
{
$match: {
schoolId,
isSuspicious: false,
deletedAt: null,
confirmedAt: { $gte: windowStart },
amount: { $gt: 0 },
},
},
{
$group: {
_id: null,
count: { $sum: 1 },
mean: { $avg: "$amount" },
std: { $stdDevPop: "$amount" },
},
},
]);
if (!rows.length) return { count: 0, mean: 0, std: 0 };
return {
count: rows[0].count || 0,
mean: rows[0].mean || 0,
std: rows[0].std || 0,
};
}
/**
* Detect abnormal payment patterns:
* 1. Rapid repeated transactions — same sender sends more than RAPID_TX_LIMIT
* payments within RAPID_TX_WINDOW_MS.
* 2. Unusual amount — by default the payment deviates from the expected fee by
* more than the school's configured multiplier (default 3×). When the
* school opts into historical mode (`amountConfig.mode === 'historical'`)
* and enough history exists, the threshold is a z-score against the
* school's own confirmed-payment distribution instead.
*
* Returns { suspicious: boolean, reason: string|null }
*
* @param {object|null} amountConfig per-tenant suspiciousAmountConfig (optional)
*/
async function detectAbnormalPatterns(
senderAddress,
paymentAmount,
expectedFee,
txDate,
schoolId,
suspiciousPaymentMultiplier = 3.0,
amountConfig = null,
) {
const RAPID_TX_WINDOW_MS = 10 * 60 * 1000; // 10 minutes
const RAPID_TX_LIMIT = 3; // more than this many = suspicious
const reasons = [];
// 1. Velocity check — rapid repeated transactions from the same sender
if (senderAddress) {
const windowStart = new Date(txDate.getTime() - RAPID_TX_WINDOW_MS);
const recentCount = await Payment.countDocuments({
schoolId,
senderAddress,
confirmedAt: { $gte: windowStart },
deletedAt: null,
});
if (recentCount >= RAPID_TX_LIMIT) {
reasons.push(
`Sender ${senderAddress} made ${recentCount + 1} transactions within 10 minutes`,
);
}
}
// 2. Unusual amount check.
// 2a. Historical mode — flag amounts far from the school's own mean. Falls
// back to the fee-multiplier check below if there isn't enough history.
let historicalApplied = false;
if (amountConfig && amountConfig.mode === "historical" && schoolId) {
const windowDays = amountConfig.historicalWindowDays || 90;
const stdMultiplier = amountConfig.historicalStdDevMultiplier || 3.0;
const minSamples = amountConfig.historicalMinSamples || 20;
try {
const stats = await computeHistoricalAmountStats(schoolId, windowDays);
if (stats.count >= minSamples && stats.std > 0) {
historicalApplied = true;
const zScore = Math.abs(paymentAmount - stats.mean) / stats.std;
if (zScore >= stdMultiplier) {
reasons.push(
`Unusual payment amount (z-score ${zScore.toFixed(2)} vs school mean ${stats.mean.toFixed(2)}, threshold ${stdMultiplier.toFixed(1)}σ over ${stats.count} payments)`,
);
}
}
} catch (err) {
logger.warn("Historical suspicious-amount check failed; falling back to fee multiplier", {
schoolId,
error: err.message,
});
}
}
// 2b. Fee-multiplier mode (default, and the historical fallback).
if (!historicalApplied && expectedFee && expectedFee > 0) {
const ratio = paymentAmount / expectedFee;
const lowerThreshold = 1 / suspiciousPaymentMultiplier;
// For multiplier 5.0, use exclusive boundary; for others, use inclusive
const lowerBoundExclusive = Math.abs(suspiciousPaymentMultiplier - 5.0) < 0.01;
if (
ratio >= suspiciousPaymentMultiplier ||
(lowerBoundExclusive ? ratio < lowerThreshold : ratio <= lowerThreshold)
) {
reasons.push(
`Unusual payment amount (ratio ${ratio.toFixed(2)}, threshold ${suspiciousPaymentMultiplier.toFixed(1)}×)`,
);
}
}
if (reasons.length > 0) {
return { suspicious: true, reason: reasons.join("; ") };
}
return { suspicious: false, reason: null };
}
/**
* Verify a single transaction hash against a specific school wallet.
* Handles fee-bump transactions by unwrapping to the inner transaction.
* Throws structured errors for all failure cases.
*
* Error codes:
* NOT_FOUND (404) — txHash does not exist on Horizon
* HORIZON_UNAVAILABLE (503) — Horizon unreachable / rate-limited / 5xx
* TX_FAILED (400) — transaction found but failed on-chain
* MISSING_MEMO (400) — no memo on the transaction (MEMO_NONE)
* UNSUPPORTED_MEMO_TYPE (400) — memo type is not MEMO_TEXT
* INVALID_DESTINATION (400) — no payment op to the school wallet
* UNSUPPORTED_ASSET (400) — asset not accepted
* AMOUNT_TOO_LOW/HIGH (400) — outside configured limits
*/
async function verifyTransaction(txHash, walletAddress, schoolId = null) {
const tx = await withStellarRetry(
() => server.transactions().transaction(txHash).call(),
{ label: "verifyTransaction", context: `Transaction ${txHash}` },
);
// 1. Validate transaction success
if (tx.successful === false) {
const err = new Error(
"Transaction was not successful on the Stellar network",
);
err.code = "TX_FAILED";
throw err;
}
// #1556 — Fee-bump transactions: memo_type, memo and operations() all live
// on the TOP-LEVEL tx record. inner_transaction only contains { hash,
// signatures, max_fee }. Preserve the inner hash for traceability but never
// read memo/operations from it.
const innerHash = tx.inner_transaction ? tx.inner_transaction.hash : null;
// 2. Find matching payment operation first (destination + asset checks before memo)
const ops = await withStellarRetry(() => tx.operations(), {
label: "verifyTransaction.operations",
context: `Transaction ${txHash} operations`,
});
const payOp = ops.records.find(
(op) => op.type === "payment" && op.to === walletAddress,
);
if (!payOp) {
const err = new Error(
`No payment operation found targeting the school wallet (${walletAddress})`,
);
err.code = "INVALID_DESTINATION";
throw err;
}
const asset = detectAsset(payOp);
if (!asset) {
const assetCode =
payOp.asset_type === "native"
? "XLM"
: payOp.asset_code || payOp.asset_type;
const err = new Error(`Unsupported asset: ${assetCode}`);
err.code = "UNSUPPORTED_ASSET";
err.assetCode = assetCode;
throw err;
}
// 3. Check memo type (after destination/asset are validated)
const memoType = tx.memo_type || 'none';
if (memoType === 'none') {
const err = new Error(
"Transaction memo is missing or empty — cannot identify student",
);
err.code = "MISSING_MEMO";
throw err;
}
// MEMO_ID and MEMO_HASH are decoded back to the canonical intent memo (#1118)
// so wallets that cannot send free-text memos still match. MEMO_RETURN has no
// encoding and stays unsupported.
if (!normalizeMemoType(memoType)) {
const err = new Error(
`Transaction memo type '${memoType}' is not supported. Accepted types: ${SUPPORTED_MEMO_TYPES.join(', ')}.`,
);
err.code = "UNSUPPORTED_MEMO_TYPE";
err.memoType = memoType;
throw err;
}
const memo = decodeMemoToCanonical(tx.memo, memoType);
if (!memo) {
// A non-text memo that fails to decode carries no student identity — it is
// an unrecognised value rather than a missing one, so report it as such.
if (normalizeMemoType(memoType) !== 'MEMO_TEXT') {
const err = new Error(
`Transaction memo of type '${memoType}' could not be decoded to a payment reference.`,
);
err.code = "UNSUPPORTED_MEMO_TYPE";
err.memoType = memoType;
throw err;
}
const err = new Error(
"Transaction memo is missing or empty — cannot identify student",
);
err.code = "MISSING_MEMO";
throw err;
}
const amount = normalizeAmount(payOp.amount);
// 5. Validate payment amount is within configured limits
// #1555 — detectAsset returns { assetCode, assetType, assetIssuer }.
// The previous `asset?.code` was always undefined, causing every USDC
// payment to be validated against XLM limits. Use `assetCode` instead.
const limitValidation = await validatePaymentAmount(amount, {
schoolId,
asset: asset?.assetCode,
});
if (!limitValidation.valid) {
const err = new Error(limitValidation.error);
err.code = limitValidation.code;
throw err;
}
// 6. Look up student to validate fee. School-scope when schoolId is provided so
// the same studentId string used across two schools resolves to the correct one.
const studentQuery = schoolId ? { schoolId, studentId: memo } : { studentId: memo };
const student = await Student.findOne(studentQuery);
const feeAmount = student ? student.feeAmount : null;
const feeValidation =
feeAmount != null
? validatePaymentAgainstFee(amount, feeAmount)
: {
status: "unknown",
excessAmount: 0,
message: "Student not found, cannot validate fee",
};
// Extract network fee from transaction
const networkFee = parseFloat(tx.fee_paid || "0") / 10000000; // Convert stroops to XLM
return {
hash: tx.hash,
innerHash,
memo: memo,
studentId: memo,
amount: amount,
assetCode: asset.assetCode,
assetType: asset.assetType,
feeAmount,
feeValidation,
networkFee,
date: tx.created_at,
ledger: tx.ledger_attr || tx.ledger || null,
senderAddress: payOp.from || null,
};
}
/**
* Fetch recent transactions for a specific school wallet and record new payments.
* Returns a summary object with counts for each outcome category.
* Paginates through ALL transactions (200 per page) until an already-processed
* transaction is encountered, ensuring no payments are missed after downtime.
*
* @param {object} school - School document with { schoolId, stellarAddress }
* @returns {object} summary - { found, new: newCount, matched, unmatched, failed, alreadyProcessed, failedDetails }
*/
async function syncPaymentsForSchool(school) {
const { schoolId, stellarAddress } = school;
// Summary counters
const summary = {
found: 0,
new: 0,
matched: 0,
unmatched: 0,
failed: 0,
alreadyProcessed: 0,
failedDetails: [], // [{ txHash, reason }]
};
// Fetch up to 200 transactions per page (Horizon API maximum).
// Pagination continues until we hit an already-recorded transaction or
// exhaust all pages — fixing the previous limit(20) single-fetch bug.
let page = await withStellarRetry(
() =>
server
.transactions()
.forAccount(stellarAddress)
.order("desc")
.limit(200)
.call(),
{ label: `syncPaymentsForSchool(${schoolId})`, context: `Account ${stellarAddress} transactions` },
);
let done = false;
let newPayments = 0;
while (!done) {
for (const tx of page.records) {
summary.found++;
const existing = await Payment.findOne({ txHash: tx.hash, schoolId, deletedAt: null });
if (existing) { summary.alreadyProcessed++; done = true; break; }
summary.new++;
const valid = await extractValidPayment(tx, stellarAddress);
if (!valid) {
// Log if the tx was skipped due to wrong destination
if (tx.successful) {
const ops = await withStellarRetry(
() => tx.operations(),
{ label: 'syncPaymentsForSchool.destinationCheck' }
).catch(() => ({ records: [] }));
const wrongDest = ops.records.find(
op => op.type === 'payment' && op.to && op.to !== stellarAddress
);
if (wrongDest) {
logger.warn('Transaction skipped — destination does not match school wallet', {
txHash: tx.hash, schoolId,
destination: wrongDest.to,
expected: stellarAddress,
});
summary.failed++;
summary.failedDetails.push({ txHash: tx.hash, reason: `INVALID_DESTINATION: payment sent to ${wrongDest.to}, expected ${stellarAddress}` });
continue;
}
}
summary.unmatched++;
continue;
}
// `asset` is destructured for the per-asset limit lookup (#1117).
const { payOp, memo, asset } = valid;
// Explicit destination check — defence-in-depth beyond extractValidPayment
if (payOp.to !== stellarAddress) {
logger.warn('Transaction skipped — destination mismatch after extraction', {
txHash: tx.hash, schoolId, destination: payOp.to, expected: stellarAddress,
});
summary.failed++;
summary.failedDetails.push({ txHash: tx.hash, reason: `INVALID_DESTINATION: payment sent to ${payOp.to}` });
continue;
}
// Decouple crediting from intent existence (#848): a pending intent is the
// preferred path, but if none exists (expired or never created) we fall back
// to matching by memo (= studentId) directly. This ensures a late-but-valid
// on-chain payment is always credited even after the intent TTL'd away.
const intent = await PaymentIntent.findOne({ schoolId, memo, status: 'pending' });
let student;
if (intent) {
student = await Student.findOne({ schoolId, studentId: intent.studentId });
} else {
student = await Student.findOne({ schoolId, studentId: memo });
}
// A memo (or a still-pending intent) can point at a student who has since
// been soft-deleted (#77/#390). The funds already left the payer's wallet,
// so record them against the deleted student (studentDeleted=true) for the
// existing deleted-student payments audit view (getDeletedStudentPayments)
// instead of dropping them into the generic unmatched bucket, where they'd
// be indistinguishable from a mistyped memo and never surfaced for review.
let studentDeleted = false;
if (!student) {
const targetStudentId = intent ? intent.studentId : memo;
student = await Student.findOne({
schoolId,
studentId: targetStudentId,
deletedAt: { $ne: null },
});
studentDeleted = Boolean(student);
if (studentDeleted) {
logger.warn('Transaction matched a soft-deleted student — recording for manual review', {
txHash: tx.hash, schoolId, studentId: targetStudentId,
});
}
}
if (!student) { summary.unmatched++; continue; }
summary.matched++;
const paymentAmount = parseFloat(payOp.amount);
const limitValidation = await validatePaymentAmount(paymentAmount, {
schoolId,
asset: asset?.assetCode, // #1555: detectAsset returns assetCode, not code
});
if (!limitValidation.valid) {
summary.failed++;
summary.failedDetails.push({ txHash: tx.hash, reason: limitValidation.code });
continue;
}
const senderAddress = payOp.from || null;
const txDate = new Date(tx.created_at);
const txLedger = tx.ledger_attr || tx.ledger || null;
const collision = await detectMemoCollision(
memo,
senderAddress,
paymentAmount,
student.feeAmount,
txDate,
schoolId,
);
const crossSchoolCollision = await detectCrossSchoolMemoCollision(
memo,
schoolId,
txDate,
);
const isSuspicious = collision.suspicious || crossSchoolCollision.suspicious;
const suspicionReason =
[collision.reason, crossSchoolCollision.reason].filter(Boolean).join('; ') || null;
const confirmation = await determineConfirmationState(
txLedger,
CONFIRMATION_STATES.DETECTED,
isSuspicious,
);
const isConfirmed = isConfirmedOrAbove(confirmation.state);
const confirmationStatus = confirmation.confirmationStatus;
const studentId = student.studentId;
// Fee amount and category come from the intent when one exists; otherwise
// fall back to the student's current fee record (intent-decoupled crediting).
const feeAmountForRecord = intent ? intent.amount : student.feeAmount;
const feeCategory = intent ? (intent.feeCategory || null) : null;
// Issue #1026 — the balance write below is a non-atomic read-aggregate-then-
// absolute-write. It races against another sync pass touching the same
// student, and against verifyPayment's equivalent section, unless both hold
// this shared per-student lock around it.
const studentLockKey = lock.studentBalanceLockKey(schoolId, studentId);
const studentLockInfo = await lock.acquire(studentLockKey, STUDENT_BALANCE_LOCK_TTL_MS);
if (!studentLockInfo) {
// A concurrent verify/sync is updating this student's balance right now.
// Skip this transaction for this pass — it will be picked up on the next
// sync run once the in-flight update releases the lock.
summary.failed++;
summary.failedDetails.push({ txHash: tx.hash, reason: 'STUDENT_BALANCE_LOCK_CONTENDED' });
continue;
}
const studentLockToken = studentLockInfo.token;
const previousPayments = await Payment.aggregate([
{ $match: { schoolId, studentId, deletedAt: null } },
{ $group: { _id: null, total: { $sum: "$amount" } } },
]);
const previousTotal = previousPayments.length
? previousPayments[0].total
: 0;
const cumulativeTotal = parseFloat(
(previousTotal + paymentAmount).toFixed(7),
);
// Partial payments are accepted in the sync path — cumulative total determines status.
// A single payment below the fee is recorded as 'partial' (credit toward remainingBalance).
// Compare in exact stroop space (#842) so an exact cumulative match is never
// mis-classified as partial/overpaid by a float rounding epsilon.
const cumulativeVsFee = compareAmounts(cumulativeTotal, student.feeAmount);
let cumulativeStatus;
if (cumulativeVsFee < 0) cumulativeStatus = "partial";
else if (cumulativeVsFee > 0) cumulativeStatus = "overpaid";
else cumulativeStatus = "valid";
const excessAmount =
cumulativeStatus === "overpaid"
? stroopsToNumber(toStroops(cumulativeTotal) - toStroops(student.feeAmount))
: 0;
let session;
try {
session = await mongoose.connection.startSession();
await session.withTransaction(async () => {
await savePayment({
schoolId,
studentId,
txHash: tx.hash,
correlationId: deriveCorrelationId(tx.hash),
amount: paymentAmount,
feeAmount: feeAmountForRecord,
feeCategory,
feeValidationStatus: cumulativeStatus,
excessAmount,
status: "SUCCESS",
memo,
senderAddress,
isSuspicious,
suspicionReason,
ledger: txLedger,
ledgerSequence: txLedger,
confirmationStatus,
confirmationState: confirmation.state,
confirmedAt: txDate,
studentDeleted,
}, { session });
if (isConfirmed && !isSuspicious && !studentDeleted) {
const updateData = {
totalPaid: cumulativeTotal,
remainingBalance: parseFloat(Math.max(0, student.feeAmount - cumulativeTotal).toFixed(7)),
feePaid: cumulativeTotal >= student.feeAmount,
};
if (feeCategory && student.fees && student.fees.length > 0) {
const feeIndex = student.fees.findIndex(f => f.category === feeCategory);
if (feeIndex !== -1) {
const categoryPayments = await Payment.aggregate([
{
$match: {
schoolId,
studentId,
feeCategory,
confirmationStatus: "confirmed",
isSuspicious: false,
deletedAt: null,
},
},
{ $group: { _id: null, total: { $sum: "$amount" } } },
]).session(session);
const categoryTotalPaid = categoryPayments.length
? parseFloat(categoryPayments[0].total.toFixed(7))
: 0;
student.fees[feeIndex].totalPaid = categoryTotalPaid;
student.fees[feeIndex].remainingBalance = Math.max(
0,
student.fees[feeIndex].amount - categoryTotalPaid
);
student.fees[feeIndex].paid = categoryTotalPaid >= student.fees[feeIndex].amount;
updateData.fees = student.fees;
}
}
await Student.findOneAndUpdate(
{ schoolId, studentId },
updateData,
{ session }
);
}
if (intent) {
await PaymentIntent.findByIdAndUpdate(
intent._id,
{ status: "completed" },
{ session }
);
}
});
} catch (saveErr) {
if (saveErr.code === 'DUPLICATE_TX') {
// Another concurrent path (poller or verify) already recorded this tx.
// Treat as already-processed rather than an error.
summary.new--;
summary.alreadyProcessed++;
if (intent) {
await PaymentIntent.findByIdAndUpdate(intent._id, { status: 'completed' });
}
continue;
}
throw saveErr;
} finally {
if (session) await session.endSession();
await lock.release(studentLockKey, studentLockToken);
}
newPayments++;
logger.info("Transaction recorded", {
txHash: tx.hash,
schoolId,
studentId,
amount: paymentAmount,
feeValidationStatus: cumulativeStatus,
isSuspicious,
confirmationStatus,
confirmationState: confirmation.state,
intentMatched: Boolean(intent),
});
}
if (!done) {
if (page.records.length < 200) break; // last page
page = await withStellarRetry(() => page.next(), {
label: `syncPaymentsForSchool.next(${schoolId})`,
});
if (!page || !page.records.length) break;
}
}
return summary;
}
/**
* Re-check all non-terminal payments for a school and advance each one
* (detected/pending -> confirmed -> finalized) per the finality policy
* (issue #747). Safe to call repeatedly/concurrently for the same school:
* `determineConfirmationState` is idempotent, so a re-run over an unchanged
* ledger range leaves already-resolved payments untouched.
*
* Falls back to the legacy `confirmationStatus` field for payments written
* before `confirmationState` existed, so older pending records keep getting
* swept without a separate migration step.