@usions/sdk 2.13.0 → 2.16.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +2 -2
- package/src/browser.js +191 -23
- package/src/modules/core.js +46 -0
- package/src/modules/wallet.js +144 -22
- package/types/index.d.ts +13 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@usions/sdk",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.16.0",
|
|
4
4
|
"description": "Usion Mini App SDK for iframe games and services",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/modules/index.js",
|
|
@@ -60,4 +60,4 @@
|
|
|
60
60
|
"publishConfig": {
|
|
61
61
|
"access": "public"
|
|
62
62
|
}
|
|
63
|
-
}
|
|
63
|
+
}
|
package/src/browser.js
CHANGED
|
@@ -69,7 +69,7 @@ var Usion = (function () {
|
|
|
69
69
|
* Core Usion object with init, _post, _request
|
|
70
70
|
*/
|
|
71
71
|
const core = {
|
|
72
|
-
version: '2.
|
|
72
|
+
version: '2.16.0', // injected from package.json at build
|
|
73
73
|
config: {},
|
|
74
74
|
_initialized: false,
|
|
75
75
|
_initCallback: null,
|
|
@@ -192,10 +192,56 @@ var Usion = (function () {
|
|
|
192
192
|
}
|
|
193
193
|
});
|
|
194
194
|
|
|
195
|
+
// Report the user's first real interaction to the host (see below).
|
|
196
|
+
this._setupInteractionBeacon();
|
|
197
|
+
|
|
195
198
|
// Signal ready to parent
|
|
196
199
|
this._post({ type: 'READY' });
|
|
197
200
|
},
|
|
198
201
|
|
|
202
|
+
/**
|
|
203
|
+
* Tell the host the moment the user FIRST genuinely interacts with the
|
|
204
|
+
* mini-app — a tap, click, key press, or touch — and only once.
|
|
205
|
+
*
|
|
206
|
+
* The host uses this as the SOLE signal to surface the mini-app in the user's
|
|
207
|
+
* chat list — only after real engagement (opening alone, and automatic
|
|
208
|
+
* load-time SDK calls, never count). It works for a fully self-contained
|
|
209
|
+
* app/game that never calls any other SDK method: no `Usion.*` call is
|
|
210
|
+
* required for the host to know the user is engaged.
|
|
211
|
+
*
|
|
212
|
+
* Standalone (non-embedded) apps are unaffected — `_post` no-ops when there
|
|
213
|
+
* is no host. Note: input that produces no DOM gesture (pure device-motion,
|
|
214
|
+
* gamepad) won't trigger this until the first tap/click/key.
|
|
215
|
+
* @private
|
|
216
|
+
*/
|
|
217
|
+
_setupInteractionBeacon: function() {
|
|
218
|
+
const self = this;
|
|
219
|
+
if (self._interactionBeaconSetup) return;
|
|
220
|
+
self._interactionBeaconSetup = true;
|
|
221
|
+
if (typeof window === 'undefined' || !window.addEventListener) return;
|
|
222
|
+
|
|
223
|
+
const events = ['pointerdown', 'mousedown', 'touchstart', 'keydown'];
|
|
224
|
+
function fire(event) {
|
|
225
|
+
// Only count real user gestures, never programmatically dispatched ones.
|
|
226
|
+
if (event && event.isTrusted === false) return;
|
|
227
|
+
if (self._interactionReported) return;
|
|
228
|
+
self._interactionReported = true;
|
|
229
|
+
for (let i = 0; i < events.length; i++) {
|
|
230
|
+
try { window.removeEventListener(events[i], fire, true); } catch (e) { /* noop */ }
|
|
231
|
+
}
|
|
232
|
+
self._post({ type: 'USER_INTERACTION' });
|
|
233
|
+
}
|
|
234
|
+
for (let i = 0; i < events.length; i++) {
|
|
235
|
+
// Capture phase + passive so we observe the gesture without interfering
|
|
236
|
+
// with the app's own handlers or scroll performance.
|
|
237
|
+
try {
|
|
238
|
+
window.addEventListener(events[i], fire, { capture: true, passive: true });
|
|
239
|
+
} catch (e) {
|
|
240
|
+
try { window.addEventListener(events[i], fire, true); } catch (e2) { /* noop */ }
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
},
|
|
244
|
+
|
|
199
245
|
/**
|
|
200
246
|
* Get the current theme ('light' or 'dark')
|
|
201
247
|
* @returns {string}
|
|
@@ -426,6 +472,13 @@ var Usion = (function () {
|
|
|
426
472
|
_balance: null,
|
|
427
473
|
_balanceChangeHandler: null,
|
|
428
474
|
|
|
475
|
+
// Payment timing knobs (overridable, mainly for tests). The defaults are
|
|
476
|
+
// the production values.
|
|
477
|
+
_paymentTimeoutMs: 60000, // wait this long for the direct PAYMENT_SUCCESS
|
|
478
|
+
_recoveryPollMs: 2000, // gap between result-recovery re-queries
|
|
479
|
+
_recoveryMaxPolls: 10, // bounded recovery window after the timeout
|
|
480
|
+
_recoveryQueryTimeoutMs: 8000, // per re-query round-trip budget
|
|
481
|
+
|
|
429
482
|
/**
|
|
430
483
|
* Get current wallet balance
|
|
431
484
|
* @returns {Promise<number>} Balance in credits
|
|
@@ -456,20 +509,69 @@ var Usion = (function () {
|
|
|
456
509
|
},
|
|
457
510
|
|
|
458
511
|
/**
|
|
459
|
-
* Request payment from user with balance check
|
|
512
|
+
* Request payment from user with balance check.
|
|
513
|
+
*
|
|
514
|
+
* The charge is debited immediately by the host (escrow); the resolved
|
|
515
|
+
* value carries a `receiptToken` the mini-app server later settles/refunds.
|
|
516
|
+
*
|
|
517
|
+
* Reliability: if the host's PAYMENT_SUCCESS message is lost (network blip,
|
|
518
|
+
* backgrounded tab, race) the user may ALREADY be charged. Instead of
|
|
519
|
+
* rejecting at the timeout, this re-queries the host for the result and
|
|
520
|
+
* recovers the receipt token — so a dropped message never strands a paid
|
|
521
|
+
* charge. Pass `idempotencyKey` to additionally guarantee that any retry of
|
|
522
|
+
* the SAME intent reuses the SAME charge (the host + backend dedupe on it),
|
|
523
|
+
* so the user can never be charged twice even across reloads.
|
|
524
|
+
*
|
|
460
525
|
* @param {number} amount - Credit amount to charge
|
|
461
526
|
* @param {string} reason - Description shown to user
|
|
462
|
-
* @param {object} data - Optional
|
|
527
|
+
* @param {object} [data] - Optional options/payload. `data.idempotencyKey`
|
|
528
|
+
* (string) makes the charge safely retryable. Any other fields are
|
|
529
|
+
* forwarded to the host verbatim (backward compatible).
|
|
463
530
|
* @returns {Promise} Resolves on payment success, rejects on failure
|
|
464
531
|
*/
|
|
465
532
|
requestPayment: function(amount, reason, data) {
|
|
466
533
|
const self = this;
|
|
467
534
|
|
|
535
|
+
const opts = (data && typeof data === 'object') ? data : null;
|
|
536
|
+
const idempotencyKey = (opts && typeof opts.idempotencyKey === 'string' && opts.idempotencyKey)
|
|
537
|
+
? opts.idempotencyKey
|
|
538
|
+
: null;
|
|
539
|
+
|
|
468
540
|
return new Promise(function(resolve, reject) {
|
|
469
541
|
const requestId = getNextRequestId();
|
|
470
|
-
const timeoutMs = 60000;
|
|
542
|
+
const timeoutMs = self._paymentTimeoutMs || 60000;
|
|
543
|
+
let settled = false;
|
|
544
|
+
let recoverTimer = null;
|
|
545
|
+
|
|
546
|
+
function cleanup() {
|
|
547
|
+
clearTimeout(timer);
|
|
548
|
+
if (recoverTimer) clearTimeout(recoverTimer);
|
|
549
|
+
window.removeEventListener('message', handler);
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
function finishResolve(response) {
|
|
553
|
+
if (settled) return;
|
|
554
|
+
settled = true;
|
|
555
|
+
cleanup();
|
|
556
|
+
// Update cached balance from the authoritative new balance, else
|
|
557
|
+
// best-effort subtract (no-op if balance is unknown).
|
|
558
|
+
if (response && response.newBalance !== undefined) {
|
|
559
|
+
self._balance = response.newBalance;
|
|
560
|
+
} else if (self._balance !== null) {
|
|
561
|
+
self._balance -= amount;
|
|
562
|
+
}
|
|
563
|
+
resolve(response);
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
function finishReject(error) {
|
|
567
|
+
if (settled) return;
|
|
568
|
+
settled = true;
|
|
569
|
+
cleanup();
|
|
570
|
+
reject(error);
|
|
571
|
+
}
|
|
471
572
|
|
|
472
|
-
//
|
|
573
|
+
// Direct response from the host. Kept registered through the recovery
|
|
574
|
+
// window so a late PAYMENT_SUCCESS still resolves.
|
|
473
575
|
function handler(event) {
|
|
474
576
|
// Only honor payment results from the trusted host shell — a forged
|
|
475
577
|
// PAYMENT_SUCCESS must never resolve this promise.
|
|
@@ -483,32 +585,97 @@ var Usion = (function () {
|
|
|
483
585
|
}
|
|
484
586
|
|
|
485
587
|
// Only accept responses for this specific payment request.
|
|
486
|
-
if (response._requestId !== requestId) {
|
|
588
|
+
if (!response || response._requestId !== requestId) {
|
|
487
589
|
return;
|
|
488
590
|
}
|
|
489
591
|
|
|
490
592
|
if (response.type === 'PAYMENT_SUCCESS') {
|
|
491
|
-
|
|
492
|
-
window.removeEventListener('message', handler);
|
|
493
|
-
// Update cached balance
|
|
494
|
-
if (response.newBalance !== undefined) {
|
|
495
|
-
self._balance = response.newBalance;
|
|
496
|
-
} else if (self._balance !== null) {
|
|
497
|
-
self._balance -= amount;
|
|
498
|
-
}
|
|
499
|
-
resolve(response);
|
|
593
|
+
finishResolve(response);
|
|
500
594
|
} else if (response.type === 'PAYMENT_FAILED') {
|
|
501
|
-
|
|
502
|
-
window.removeEventListener('message', handler);
|
|
503
|
-
reject(new Error(response.reason || 'Payment failed'));
|
|
595
|
+
finishReject(new Error(response.reason || 'Payment failed'));
|
|
504
596
|
}
|
|
505
597
|
}
|
|
506
598
|
|
|
599
|
+
// After the direct deadline, re-query the host for this payment's
|
|
600
|
+
// result instead of rejecting.
|
|
601
|
+
// succeeded -> resolve with the recovered token
|
|
602
|
+
// failed -> reject
|
|
603
|
+
// pending -> the modal is STILL OPEN / charge still in progress, so
|
|
604
|
+
// the charge is fully recoverable: keep waiting (do NOT
|
|
605
|
+
// count this toward the give-up cap). A slow user who
|
|
606
|
+
// confirms minutes later still resolves — via the next
|
|
607
|
+
// 'succeeded' poll or the direct handler, which stays
|
|
608
|
+
// registered. Without this, a charge made after we gave
|
|
609
|
+
// up would be stranded (the original bug, shifted to the
|
|
610
|
+
// slow-confirm case).
|
|
611
|
+
// none/other -> likely no charge; confirm twice then reject cleanly so
|
|
612
|
+
// a fresh requestPayment is safe.
|
|
613
|
+
// The bounded cap only governs the give-up paths (none-streak and a host
|
|
614
|
+
// that never answers the query at all, e.g. an older host shell).
|
|
615
|
+
function recoverPaymentResult() {
|
|
616
|
+
const pollMs = self._recoveryPollMs || 2000;
|
|
617
|
+
const maxPolls = self._recoveryMaxPolls || 10;
|
|
618
|
+
const queryTimeoutMs = self._recoveryQueryTimeoutMs || 8000;
|
|
619
|
+
let noneStreak = 0;
|
|
620
|
+
let errorStreak = 0;
|
|
621
|
+
|
|
622
|
+
function poll() {
|
|
623
|
+
if (settled) return;
|
|
624
|
+
Usion._request('PAYMENT_RESULT_QUERY', {
|
|
625
|
+
paymentRequestId: requestId,
|
|
626
|
+
idempotencyKey: idempotencyKey || undefined,
|
|
627
|
+
}, queryTimeoutMs).then(function(res) {
|
|
628
|
+
if (settled) return;
|
|
629
|
+
const status = res && res.status;
|
|
630
|
+
if (status === 'succeeded') {
|
|
631
|
+
finishResolve({
|
|
632
|
+
type: 'PAYMENT_SUCCESS',
|
|
633
|
+
_requestId: requestId,
|
|
634
|
+
newBalance: res.newBalance,
|
|
635
|
+
transactionId: res.transactionId,
|
|
636
|
+
receiptToken: res.receiptToken,
|
|
637
|
+
});
|
|
638
|
+
return;
|
|
639
|
+
}
|
|
640
|
+
if (status === 'failed') {
|
|
641
|
+
finishReject(new Error(res.reason || 'Payment failed'));
|
|
642
|
+
return;
|
|
643
|
+
}
|
|
644
|
+
if (status === 'pending') {
|
|
645
|
+
// Charge still recoverable — keep waiting, no give-up budget.
|
|
646
|
+
noneStreak = 0;
|
|
647
|
+
errorStreak = 0;
|
|
648
|
+
recoverTimer = setTimeout(poll, pollMs);
|
|
649
|
+
return;
|
|
650
|
+
}
|
|
651
|
+
// 'none' or unknown — host is confident no charge exists. Confirm
|
|
652
|
+
// twice (guards a host that simply hasn't recorded yet) then reject.
|
|
653
|
+
errorStreak = 0;
|
|
654
|
+
noneStreak += 1;
|
|
655
|
+
if (noneStreak >= 2) {
|
|
656
|
+
finishReject(new Error('Payment confirmation timeout'));
|
|
657
|
+
return;
|
|
658
|
+
}
|
|
659
|
+
recoverTimer = setTimeout(poll, pollMs);
|
|
660
|
+
}).catch(function() {
|
|
661
|
+
// Host didn't answer the query (e.g. an older host shell without
|
|
662
|
+
// PAYMENT_RESULT_QUERY support). Retry a bounded number of times,
|
|
663
|
+
// then fall back to the original timeout rejection.
|
|
664
|
+
if (settled) return;
|
|
665
|
+
errorStreak += 1;
|
|
666
|
+
if (errorStreak >= maxPolls) {
|
|
667
|
+
finishReject(new Error('Payment confirmation timeout'));
|
|
668
|
+
return;
|
|
669
|
+
}
|
|
670
|
+
recoverTimer = setTimeout(poll, pollMs);
|
|
671
|
+
});
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
poll();
|
|
675
|
+
}
|
|
676
|
+
|
|
507
677
|
window.addEventListener('message', handler);
|
|
508
|
-
const timer = setTimeout(
|
|
509
|
-
window.removeEventListener('message', handler);
|
|
510
|
-
reject(new Error('Payment confirmation timeout'));
|
|
511
|
-
}, timeoutMs);
|
|
678
|
+
const timer = setTimeout(recoverPaymentResult, timeoutMs);
|
|
512
679
|
|
|
513
680
|
// Send payment request
|
|
514
681
|
Usion._post({
|
|
@@ -516,7 +683,8 @@ var Usion = (function () {
|
|
|
516
683
|
_requestId: requestId,
|
|
517
684
|
amount: amount,
|
|
518
685
|
reason: reason,
|
|
519
|
-
data: data
|
|
686
|
+
data: data,
|
|
687
|
+
idempotencyKey: idempotencyKey || undefined,
|
|
520
688
|
});
|
|
521
689
|
});
|
|
522
690
|
},
|
package/src/modules/core.js
CHANGED
|
@@ -189,10 +189,56 @@ export const core = {
|
|
|
189
189
|
}
|
|
190
190
|
});
|
|
191
191
|
|
|
192
|
+
// Report the user's first real interaction to the host (see below).
|
|
193
|
+
this._setupInteractionBeacon();
|
|
194
|
+
|
|
192
195
|
// Signal ready to parent
|
|
193
196
|
this._post({ type: 'READY' });
|
|
194
197
|
},
|
|
195
198
|
|
|
199
|
+
/**
|
|
200
|
+
* Tell the host the moment the user FIRST genuinely interacts with the
|
|
201
|
+
* mini-app — a tap, click, key press, or touch — and only once.
|
|
202
|
+
*
|
|
203
|
+
* The host uses this as the SOLE signal to surface the mini-app in the user's
|
|
204
|
+
* chat list — only after real engagement (opening alone, and automatic
|
|
205
|
+
* load-time SDK calls, never count). It works for a fully self-contained
|
|
206
|
+
* app/game that never calls any other SDK method: no `Usion.*` call is
|
|
207
|
+
* required for the host to know the user is engaged.
|
|
208
|
+
*
|
|
209
|
+
* Standalone (non-embedded) apps are unaffected — `_post` no-ops when there
|
|
210
|
+
* is no host. Note: input that produces no DOM gesture (pure device-motion,
|
|
211
|
+
* gamepad) won't trigger this until the first tap/click/key.
|
|
212
|
+
* @private
|
|
213
|
+
*/
|
|
214
|
+
_setupInteractionBeacon: function() {
|
|
215
|
+
const self = this;
|
|
216
|
+
if (self._interactionBeaconSetup) return;
|
|
217
|
+
self._interactionBeaconSetup = true;
|
|
218
|
+
if (typeof window === 'undefined' || !window.addEventListener) return;
|
|
219
|
+
|
|
220
|
+
const events = ['pointerdown', 'mousedown', 'touchstart', 'keydown'];
|
|
221
|
+
function fire(event) {
|
|
222
|
+
// Only count real user gestures, never programmatically dispatched ones.
|
|
223
|
+
if (event && event.isTrusted === false) return;
|
|
224
|
+
if (self._interactionReported) return;
|
|
225
|
+
self._interactionReported = true;
|
|
226
|
+
for (let i = 0; i < events.length; i++) {
|
|
227
|
+
try { window.removeEventListener(events[i], fire, true); } catch (e) { /* noop */ }
|
|
228
|
+
}
|
|
229
|
+
self._post({ type: 'USER_INTERACTION' });
|
|
230
|
+
}
|
|
231
|
+
for (let i = 0; i < events.length; i++) {
|
|
232
|
+
// Capture phase + passive so we observe the gesture without interfering
|
|
233
|
+
// with the app's own handlers or scroll performance.
|
|
234
|
+
try {
|
|
235
|
+
window.addEventListener(events[i], fire, { capture: true, passive: true });
|
|
236
|
+
} catch (e) {
|
|
237
|
+
try { window.addEventListener(events[i], fire, true); } catch (e2) { /* noop */ }
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
},
|
|
241
|
+
|
|
196
242
|
/**
|
|
197
243
|
* Get the current theme ('light' or 'dark')
|
|
198
244
|
* @returns {string}
|
package/src/modules/wallet.js
CHANGED
|
@@ -12,6 +12,13 @@ export function createWalletModule(Usion) {
|
|
|
12
12
|
_balance: null,
|
|
13
13
|
_balanceChangeHandler: null,
|
|
14
14
|
|
|
15
|
+
// Payment timing knobs (overridable, mainly for tests). The defaults are
|
|
16
|
+
// the production values.
|
|
17
|
+
_paymentTimeoutMs: 60000, // wait this long for the direct PAYMENT_SUCCESS
|
|
18
|
+
_recoveryPollMs: 2000, // gap between result-recovery re-queries
|
|
19
|
+
_recoveryMaxPolls: 10, // bounded recovery window after the timeout
|
|
20
|
+
_recoveryQueryTimeoutMs: 8000, // per re-query round-trip budget
|
|
21
|
+
|
|
15
22
|
/**
|
|
16
23
|
* Get current wallet balance
|
|
17
24
|
* @returns {Promise<number>} Balance in credits
|
|
@@ -42,20 +49,69 @@ export function createWalletModule(Usion) {
|
|
|
42
49
|
},
|
|
43
50
|
|
|
44
51
|
/**
|
|
45
|
-
* Request payment from user with balance check
|
|
52
|
+
* Request payment from user with balance check.
|
|
53
|
+
*
|
|
54
|
+
* The charge is debited immediately by the host (escrow); the resolved
|
|
55
|
+
* value carries a `receiptToken` the mini-app server later settles/refunds.
|
|
56
|
+
*
|
|
57
|
+
* Reliability: if the host's PAYMENT_SUCCESS message is lost (network blip,
|
|
58
|
+
* backgrounded tab, race) the user may ALREADY be charged. Instead of
|
|
59
|
+
* rejecting at the timeout, this re-queries the host for the result and
|
|
60
|
+
* recovers the receipt token — so a dropped message never strands a paid
|
|
61
|
+
* charge. Pass `idempotencyKey` to additionally guarantee that any retry of
|
|
62
|
+
* the SAME intent reuses the SAME charge (the host + backend dedupe on it),
|
|
63
|
+
* so the user can never be charged twice even across reloads.
|
|
64
|
+
*
|
|
46
65
|
* @param {number} amount - Credit amount to charge
|
|
47
66
|
* @param {string} reason - Description shown to user
|
|
48
|
-
* @param {object} data - Optional
|
|
67
|
+
* @param {object} [data] - Optional options/payload. `data.idempotencyKey`
|
|
68
|
+
* (string) makes the charge safely retryable. Any other fields are
|
|
69
|
+
* forwarded to the host verbatim (backward compatible).
|
|
49
70
|
* @returns {Promise} Resolves on payment success, rejects on failure
|
|
50
71
|
*/
|
|
51
72
|
requestPayment: function(amount, reason, data) {
|
|
52
73
|
const self = this;
|
|
53
74
|
|
|
75
|
+
const opts = (data && typeof data === 'object') ? data : null;
|
|
76
|
+
const idempotencyKey = (opts && typeof opts.idempotencyKey === 'string' && opts.idempotencyKey)
|
|
77
|
+
? opts.idempotencyKey
|
|
78
|
+
: null;
|
|
79
|
+
|
|
54
80
|
return new Promise(function(resolve, reject) {
|
|
55
81
|
const requestId = getNextRequestId();
|
|
56
|
-
const timeoutMs = 60000;
|
|
82
|
+
const timeoutMs = self._paymentTimeoutMs || 60000;
|
|
83
|
+
let settled = false;
|
|
84
|
+
let recoverTimer = null;
|
|
85
|
+
|
|
86
|
+
function cleanup() {
|
|
87
|
+
clearTimeout(timer);
|
|
88
|
+
if (recoverTimer) clearTimeout(recoverTimer);
|
|
89
|
+
window.removeEventListener('message', handler);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function finishResolve(response) {
|
|
93
|
+
if (settled) return;
|
|
94
|
+
settled = true;
|
|
95
|
+
cleanup();
|
|
96
|
+
// Update cached balance from the authoritative new balance, else
|
|
97
|
+
// best-effort subtract (no-op if balance is unknown).
|
|
98
|
+
if (response && response.newBalance !== undefined) {
|
|
99
|
+
self._balance = response.newBalance;
|
|
100
|
+
} else if (self._balance !== null) {
|
|
101
|
+
self._balance -= amount;
|
|
102
|
+
}
|
|
103
|
+
resolve(response);
|
|
104
|
+
}
|
|
57
105
|
|
|
58
|
-
|
|
106
|
+
function finishReject(error) {
|
|
107
|
+
if (settled) return;
|
|
108
|
+
settled = true;
|
|
109
|
+
cleanup();
|
|
110
|
+
reject(error);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Direct response from the host. Kept registered through the recovery
|
|
114
|
+
// window so a late PAYMENT_SUCCESS still resolves.
|
|
59
115
|
function handler(event) {
|
|
60
116
|
// Only honor payment results from the trusted host shell — a forged
|
|
61
117
|
// PAYMENT_SUCCESS must never resolve this promise.
|
|
@@ -69,32 +125,97 @@ export function createWalletModule(Usion) {
|
|
|
69
125
|
}
|
|
70
126
|
|
|
71
127
|
// Only accept responses for this specific payment request.
|
|
72
|
-
if (response._requestId !== requestId) {
|
|
128
|
+
if (!response || response._requestId !== requestId) {
|
|
73
129
|
return;
|
|
74
130
|
}
|
|
75
131
|
|
|
76
132
|
if (response.type === 'PAYMENT_SUCCESS') {
|
|
77
|
-
|
|
78
|
-
window.removeEventListener('message', handler);
|
|
79
|
-
// Update cached balance
|
|
80
|
-
if (response.newBalance !== undefined) {
|
|
81
|
-
self._balance = response.newBalance;
|
|
82
|
-
} else if (self._balance !== null) {
|
|
83
|
-
self._balance -= amount;
|
|
84
|
-
}
|
|
85
|
-
resolve(response);
|
|
133
|
+
finishResolve(response);
|
|
86
134
|
} else if (response.type === 'PAYMENT_FAILED') {
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
135
|
+
finishReject(new Error(response.reason || 'Payment failed'));
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// After the direct deadline, re-query the host for this payment's
|
|
140
|
+
// result instead of rejecting.
|
|
141
|
+
// succeeded -> resolve with the recovered token
|
|
142
|
+
// failed -> reject
|
|
143
|
+
// pending -> the modal is STILL OPEN / charge still in progress, so
|
|
144
|
+
// the charge is fully recoverable: keep waiting (do NOT
|
|
145
|
+
// count this toward the give-up cap). A slow user who
|
|
146
|
+
// confirms minutes later still resolves — via the next
|
|
147
|
+
// 'succeeded' poll or the direct handler, which stays
|
|
148
|
+
// registered. Without this, a charge made after we gave
|
|
149
|
+
// up would be stranded (the original bug, shifted to the
|
|
150
|
+
// slow-confirm case).
|
|
151
|
+
// none/other -> likely no charge; confirm twice then reject cleanly so
|
|
152
|
+
// a fresh requestPayment is safe.
|
|
153
|
+
// The bounded cap only governs the give-up paths (none-streak and a host
|
|
154
|
+
// that never answers the query at all, e.g. an older host shell).
|
|
155
|
+
function recoverPaymentResult() {
|
|
156
|
+
const pollMs = self._recoveryPollMs || 2000;
|
|
157
|
+
const maxPolls = self._recoveryMaxPolls || 10;
|
|
158
|
+
const queryTimeoutMs = self._recoveryQueryTimeoutMs || 8000;
|
|
159
|
+
let noneStreak = 0;
|
|
160
|
+
let errorStreak = 0;
|
|
161
|
+
|
|
162
|
+
function poll() {
|
|
163
|
+
if (settled) return;
|
|
164
|
+
Usion._request('PAYMENT_RESULT_QUERY', {
|
|
165
|
+
paymentRequestId: requestId,
|
|
166
|
+
idempotencyKey: idempotencyKey || undefined,
|
|
167
|
+
}, queryTimeoutMs).then(function(res) {
|
|
168
|
+
if (settled) return;
|
|
169
|
+
const status = res && res.status;
|
|
170
|
+
if (status === 'succeeded') {
|
|
171
|
+
finishResolve({
|
|
172
|
+
type: 'PAYMENT_SUCCESS',
|
|
173
|
+
_requestId: requestId,
|
|
174
|
+
newBalance: res.newBalance,
|
|
175
|
+
transactionId: res.transactionId,
|
|
176
|
+
receiptToken: res.receiptToken,
|
|
177
|
+
});
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
if (status === 'failed') {
|
|
181
|
+
finishReject(new Error(res.reason || 'Payment failed'));
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
if (status === 'pending') {
|
|
185
|
+
// Charge still recoverable — keep waiting, no give-up budget.
|
|
186
|
+
noneStreak = 0;
|
|
187
|
+
errorStreak = 0;
|
|
188
|
+
recoverTimer = setTimeout(poll, pollMs);
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
191
|
+
// 'none' or unknown — host is confident no charge exists. Confirm
|
|
192
|
+
// twice (guards a host that simply hasn't recorded yet) then reject.
|
|
193
|
+
errorStreak = 0;
|
|
194
|
+
noneStreak += 1;
|
|
195
|
+
if (noneStreak >= 2) {
|
|
196
|
+
finishReject(new Error('Payment confirmation timeout'));
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
199
|
+
recoverTimer = setTimeout(poll, pollMs);
|
|
200
|
+
}).catch(function() {
|
|
201
|
+
// Host didn't answer the query (e.g. an older host shell without
|
|
202
|
+
// PAYMENT_RESULT_QUERY support). Retry a bounded number of times,
|
|
203
|
+
// then fall back to the original timeout rejection.
|
|
204
|
+
if (settled) return;
|
|
205
|
+
errorStreak += 1;
|
|
206
|
+
if (errorStreak >= maxPolls) {
|
|
207
|
+
finishReject(new Error('Payment confirmation timeout'));
|
|
208
|
+
return;
|
|
209
|
+
}
|
|
210
|
+
recoverTimer = setTimeout(poll, pollMs);
|
|
211
|
+
});
|
|
90
212
|
}
|
|
213
|
+
|
|
214
|
+
poll();
|
|
91
215
|
}
|
|
92
216
|
|
|
93
217
|
window.addEventListener('message', handler);
|
|
94
|
-
const timer = setTimeout(
|
|
95
|
-
window.removeEventListener('message', handler);
|
|
96
|
-
reject(new Error('Payment confirmation timeout'));
|
|
97
|
-
}, timeoutMs);
|
|
218
|
+
const timer = setTimeout(recoverPaymentResult, timeoutMs);
|
|
98
219
|
|
|
99
220
|
// Send payment request
|
|
100
221
|
Usion._post({
|
|
@@ -102,7 +223,8 @@ export function createWalletModule(Usion) {
|
|
|
102
223
|
_requestId: requestId,
|
|
103
224
|
amount: amount,
|
|
104
225
|
reason: reason,
|
|
105
|
-
data: data
|
|
226
|
+
data: data,
|
|
227
|
+
idempotencyKey: idempotencyKey || undefined,
|
|
106
228
|
});
|
|
107
229
|
});
|
|
108
230
|
},
|
package/types/index.d.ts
CHANGED
|
@@ -43,6 +43,17 @@ export interface PaymentResponse {
|
|
|
43
43
|
transactionId?: string;
|
|
44
44
|
}
|
|
45
45
|
|
|
46
|
+
export interface PaymentOptions {
|
|
47
|
+
/**
|
|
48
|
+
* Optional idempotency key. When set, the host + backend dedupe on it so any
|
|
49
|
+
* retry of the SAME intent (network error, reload, double-tap) reuses the
|
|
50
|
+
* SAME charge instead of debiting again, and returns the same receiptToken.
|
|
51
|
+
* Omit for the legacy one-shot behavior.
|
|
52
|
+
*/
|
|
53
|
+
idempotencyKey?: string;
|
|
54
|
+
[key: string]: any;
|
|
55
|
+
}
|
|
56
|
+
|
|
46
57
|
// ─── User ────────────────────────────────────────────────────────
|
|
47
58
|
|
|
48
59
|
export interface UserProfile {
|
|
@@ -82,7 +93,7 @@ export interface FileStorageModule {
|
|
|
82
93
|
export interface WalletModule {
|
|
83
94
|
getBalance(): Promise<number>;
|
|
84
95
|
hasCredits(amount: number): Promise<boolean>;
|
|
85
|
-
requestPayment(amount: number, reason: string, data?:
|
|
96
|
+
requestPayment(amount: number, reason: string, data?: PaymentOptions): Promise<PaymentResponse>;
|
|
86
97
|
onBalanceChange(callback: (balance: number) => void): UnsubscribeFn;
|
|
87
98
|
}
|
|
88
99
|
|
|
@@ -768,7 +779,7 @@ export interface UsionSDK {
|
|
|
768
779
|
getLaunchParams(): { path: string | null; ref: string | null; roomId: string | null };
|
|
769
780
|
|
|
770
781
|
// Payments
|
|
771
|
-
requestPayment(amount: number, reason: string, data?:
|
|
782
|
+
requestPayment(amount: number, reason: string, data?: PaymentOptions): Promise<PaymentResponse>;
|
|
772
783
|
|
|
773
784
|
// Service communication
|
|
774
785
|
submit(data: Record<string, any>): void;
|