@antseed/node 0.2.157 → 0.2.158
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/dist/discovery/announcer.d.ts +5 -2
- package/dist/discovery/announcer.d.ts.map +1 -1
- package/dist/discovery/announcer.js +10 -5
- package/dist/discovery/announcer.js.map +1 -1
- package/dist/discovery/metadata/fields/service-performance.d.ts +7 -0
- package/dist/discovery/metadata/fields/service-performance.d.ts.map +1 -0
- package/dist/discovery/metadata/fields/service-performance.js +63 -0
- package/dist/discovery/metadata/fields/service-performance.js.map +1 -0
- package/dist/discovery/metadata/index.d.ts.map +1 -1
- package/dist/discovery/metadata/index.js +2 -1
- package/dist/discovery/metadata/index.js.map +1 -1
- package/dist/discovery/metadata/versions/v14.d.ts +5 -0
- package/dist/discovery/metadata/versions/v14.d.ts.map +1 -0
- package/dist/discovery/metadata/versions/v14.js +16 -0
- package/dist/discovery/metadata/versions/v14.js.map +1 -0
- package/dist/discovery/metadata-validator.d.ts.map +1 -1
- package/dist/discovery/metadata-validator.js +29 -1
- package/dist/discovery/metadata-validator.js.map +1 -1
- package/dist/discovery/service-catalog.d.ts +5 -2
- package/dist/discovery/service-catalog.d.ts.map +1 -1
- package/dist/discovery/service-catalog.js +3 -1
- package/dist/discovery/service-catalog.js.map +1 -1
- package/dist/health/model-health-checker.d.ts +9 -0
- package/dist/health/model-health-checker.d.ts.map +1 -1
- package/dist/health/model-health-checker.js +23 -0
- package/dist/health/model-health-checker.js.map +1 -1
- package/dist/health/service-performance.d.ts +28 -0
- package/dist/health/service-performance.d.ts.map +1 -0
- package/dist/health/service-performance.js +144 -0
- package/dist/health/service-performance.js.map +1 -0
- package/dist/health/stream-outcome.d.ts +19 -0
- package/dist/health/stream-outcome.d.ts.map +1 -0
- package/dist/health/stream-outcome.js +91 -0
- package/dist/health/stream-outcome.js.map +1 -0
- package/dist/index.d.ts +2 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -2
- package/dist/index.js.map +1 -1
- package/dist/node.d.ts +2 -0
- package/dist/node.d.ts.map +1 -1
- package/dist/node.js +7 -0
- package/dist/node.js.map +1 -1
- package/dist/payments/seller-payment-manager.d.ts +50 -0
- package/dist/payments/seller-payment-manager.d.ts.map +1 -1
- package/dist/payments/seller-payment-manager.js +320 -80
- package/dist/payments/seller-payment-manager.js.map +1 -1
- package/dist/routing/seller-health.d.ts +21 -0
- package/dist/routing/seller-health.d.ts.map +1 -1
- package/dist/routing/seller-health.js +19 -3
- package/dist/routing/seller-health.js.map +1 -1
- package/dist/seller-request-handler.d.ts +5 -0
- package/dist/seller-request-handler.d.ts.map +1 -1
- package/dist/seller-request-handler.js +129 -18
- package/dist/seller-request-handler.js.map +1 -1
- package/dist/types/peer.d.ts +4 -2
- package/dist/types/peer.d.ts.map +1 -1
- package/package.json +3 -3
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { keccak256, verifyTypedData } from 'ethers';
|
|
2
|
+
import { DEFAULT_TOP_UP_SETTLED_THRESHOLD_BPS } from '../types/protocol.js';
|
|
2
3
|
import { ChannelsClient } from './evm/channels-client.js';
|
|
3
4
|
import { SPENDING_AUTH_TYPES, RESERVE_AUTH_TYPES, makeChannelsDomain, encodeMetadata, ZERO_METADATA, } from './evm/signatures.js';
|
|
4
5
|
import { debugLog, debugWarn } from '../utils/debug.js';
|
|
@@ -20,6 +21,10 @@ const DEFAULT_MIN_SETTLE_DELTA = BigInt(DEFAULT_MIN_SETTLE_DELTA_STR);
|
|
|
20
21
|
* the wire when the close request arrives.
|
|
21
22
|
*/
|
|
22
23
|
const CLOSE_CATCH_UP_WAIT_MS = 5_000;
|
|
24
|
+
/** How long an auth only a mining top-up covers waits for it (outside the buyer lock). */
|
|
25
|
+
const OVER_CEILING_AUTH_WAIT_MS = 30_000;
|
|
26
|
+
/** Minimum gap between on-chain reads of one channel's deposit. */
|
|
27
|
+
const RESERVE_REFRESH_MIN_INTERVAL_MS = 10_000;
|
|
23
28
|
/** `retryAfterMs` hint returned with 'busy' / 'pending_auth' rejections. */
|
|
24
29
|
const CLOSE_RETRY_AFTER_MS = 2_000;
|
|
25
30
|
const TOP_UP_THRESHOLD_NOT_MET_SELECTOR = '0x1ea4506b';
|
|
@@ -89,6 +94,10 @@ export class SellerPaymentManager {
|
|
|
89
94
|
_spent = new Map();
|
|
90
95
|
/** channelId -> on-chain reserveMaxAmount (budget ceiling from ReserveAuth) */
|
|
91
96
|
_reserveMax = new Map();
|
|
97
|
+
/** When each channel's ceiling was last checked against the chain (see refreshReserveMaxFromChain). */
|
|
98
|
+
_reserveRefreshedAt = new Map();
|
|
99
|
+
/** channelId -> on-chain ceiling read in progress, shared by concurrent callers */
|
|
100
|
+
_reserveRefreshes = new Map();
|
|
92
101
|
/** channelId -> latest buyer-signed auth (both sigs + cumulative values + metadata) for settle/close */
|
|
93
102
|
_latestAuth = new Map();
|
|
94
103
|
/**
|
|
@@ -115,6 +124,13 @@ export class SellerPaymentManager {
|
|
|
115
124
|
* before a retry succeeds, we keep the most recent higher ceiling because it
|
|
116
125
|
* subsumes the older request. */
|
|
117
126
|
_pendingTopUp = new Map();
|
|
127
|
+
/** Channels with a background topUp() running, and those asked for another meanwhile. */
|
|
128
|
+
_topUpsRunning = new Map();
|
|
129
|
+
_topUpsWanted = new Set();
|
|
130
|
+
/** channelId -> ceiling of the topUp() being submitted now. */
|
|
131
|
+
_topUpInFlight = new Map();
|
|
132
|
+
/** AntseedChannels TOP_UP_SETTLED_THRESHOLD_BPS, read once. */
|
|
133
|
+
_topUpThreshold = null;
|
|
118
134
|
/** Channels that must not serve more paid work until closed/renegotiated. */
|
|
119
135
|
_blockedChannels = new Set();
|
|
120
136
|
/** channelId -> close waiting for in-flight requests (closeSessionWhenIdle). */
|
|
@@ -441,23 +457,34 @@ export class SellerPaymentManager {
|
|
|
441
457
|
// delays the buyer's session-channel auths.
|
|
442
458
|
const isOneOff = payload.oneOffRequestId != null || this._oneOffChannelIds.has(payload.channelId);
|
|
443
459
|
const lockKey = isOneOff ? `${buyerPeerId}\n${payload.channelId.toLowerCase()}` : buyerPeerId;
|
|
444
|
-
const
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
this._buyerLocks.
|
|
460
|
+
const runLocked = async (allowTopUpWait) => {
|
|
461
|
+
const existing = this._buyerLocks.get(lockKey);
|
|
462
|
+
let outcome = 'rejected';
|
|
463
|
+
const lock = (existing ?? Promise.resolve()).then(async () => {
|
|
464
|
+
outcome = isOneOff
|
|
465
|
+
? await this._handleOneOffSpendingAuth(buyerPeerId, payload, paymentMux)
|
|
466
|
+
: await this._handleSpendingAuthInner(buyerPeerId, payload, paymentMux, disconnectMarker, allowTopUpWait);
|
|
467
|
+
});
|
|
468
|
+
const tail = lock.catch(() => { });
|
|
469
|
+
this._buyerLocks.set(lockKey, tail);
|
|
470
|
+
try {
|
|
471
|
+
await lock;
|
|
472
|
+
}
|
|
473
|
+
finally {
|
|
474
|
+
if (isOneOff && this._buyerLocks.get(lockKey) === tail)
|
|
475
|
+
this._buyerLocks.delete(lockKey);
|
|
476
|
+
}
|
|
477
|
+
return outcome;
|
|
478
|
+
};
|
|
479
|
+
let result = await runLocked(true);
|
|
480
|
+
if (result === 'awaiting-top-up') {
|
|
481
|
+
// Only a top-up that is mining covers this auth: wait for it with the
|
|
482
|
+
// lock released, so this buyer's other auths and requests keep moving,
|
|
483
|
+
// then judge the auth again against the new ceiling.
|
|
484
|
+
await this.waitForTopUp(payload.channelId, OVER_CEILING_AUTH_WAIT_MS);
|
|
485
|
+
result = await runLocked(false);
|
|
459
486
|
}
|
|
460
|
-
return result;
|
|
487
|
+
return result === 'awaiting-top-up' ? 'rejected' : result;
|
|
461
488
|
}
|
|
462
489
|
/**
|
|
463
490
|
* Wait for any in-flight SpendingAuth processing for this buyer to complete.
|
|
@@ -470,7 +497,7 @@ export class SellerPaymentManager {
|
|
|
470
497
|
await pending;
|
|
471
498
|
}
|
|
472
499
|
}
|
|
473
|
-
async _handleSpendingAuthInner(buyerPeerId, payload, paymentMux, disconnectMarker) {
|
|
500
|
+
async _handleSpendingAuthInner(buyerPeerId, payload, paymentMux, disconnectMarker, allowTopUpWait = false) {
|
|
474
501
|
const buyerEvmAddr = peerIdToAddress(buyerPeerId);
|
|
475
502
|
try {
|
|
476
503
|
const channelId = payload.channelId;
|
|
@@ -481,9 +508,26 @@ export class SellerPaymentManager {
|
|
|
481
508
|
`channel=${channelId.slice(0, 18)}...`);
|
|
482
509
|
return 'rejected';
|
|
483
510
|
}
|
|
511
|
+
// The signature check below only proves the sender signed the auth, not
|
|
512
|
+
// that the channel is theirs. An auth from one buyer must never move
|
|
513
|
+
// another buyer's channel: it would raise what that buyer may spend
|
|
514
|
+
// with an authorization the contract cannot settle.
|
|
515
|
+
const owner = existingCumulative !== undefined ? this._channelStore.getChannel(channelId)?.peerId : undefined;
|
|
516
|
+
if (owner !== undefined && owner !== buyerPeerId) {
|
|
517
|
+
debugWarn(`[SellerPayment] Rejecting SpendingAuth for a channel another buyer owns: ` +
|
|
518
|
+
`channel=${channelId.slice(0, 18)}... sender=${buyerPeerId.slice(0, 12)}...`);
|
|
519
|
+
return 'rejected';
|
|
520
|
+
}
|
|
484
521
|
const { channels: channelsAddr } = await this._resolvedAddresses;
|
|
485
522
|
const channelsDomain = makeChannelsDomain(this._config.chainId, channelsAddr);
|
|
486
523
|
if (existingCumulative === undefined) {
|
|
524
|
+
// A channel this seller already closed stays closed: a lagging RPC
|
|
525
|
+
// node can still report it active, and recovering it would block the
|
|
526
|
+
// buyer's replacement channel. No chain read is needed to say so.
|
|
527
|
+
if (this._channelStore.getChannel(channelId)?.status === CHANNEL_STATUS.SETTLED) {
|
|
528
|
+
debugLog(`[SellerPayment] SpendingAuth for closed channel ${channelId.slice(0, 18)}... — rejected`);
|
|
529
|
+
return 'rejected';
|
|
530
|
+
}
|
|
487
531
|
const hasReserveFields = payload.reserveSalt != null
|
|
488
532
|
|| payload.reserveMaxAmount != null
|
|
489
533
|
|| payload.reserveDeadline != null;
|
|
@@ -585,48 +629,20 @@ export class SellerPaymentManager {
|
|
|
585
629
|
debugWarn(`[SellerPayment] Invalid top-up ReserveAuth signature: recovered=${recovered} expected=${buyerEvmAddr}`);
|
|
586
630
|
return 'rejected';
|
|
587
631
|
}
|
|
588
|
-
//
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
}
|
|
603
|
-
debugLog(`[SellerPayment] Top-up completed: channel=${channelId.slice(0, 18)}... new ceiling=${newMaxAmount}`);
|
|
604
|
-
}
|
|
605
|
-
catch (topUpErr) {
|
|
606
|
-
const failureKind = this._classifyTopUpFailure(topUpErr);
|
|
607
|
-
if (failureKind === 'retryable-threshold' || failureKind === 'retryable-tx-backpressure') {
|
|
608
|
-
// TopUpThresholdNotMet is a timing/settlement race; tx backpressure
|
|
609
|
-
// means the RPC/delegated-account queue is saturated. In both cases
|
|
610
|
-
// keep the ReserveAuth pending and retry after a later SpendingAuth
|
|
611
|
-
// instead of treating the active channel as permanently broken.
|
|
612
|
-
const reason = failureKind === 'retryable-threshold' ? 'threshold not met' : 'transaction backpressure';
|
|
613
|
-
debugWarn(`[SellerPayment] Top-up ${reason}: channel=${channelId.slice(0, 18)}... ` +
|
|
614
|
-
`error=${this._formatError(topUpErr)} — ` +
|
|
615
|
-
`deferring topUp (will retry after next SpendingAuth)`);
|
|
616
|
-
this._storePendingTopUp(channelId, {
|
|
617
|
-
newMaxAmount,
|
|
618
|
-
deadline: topUpDeadline,
|
|
619
|
-
reserveAuthSig: payload.spendingAuthSig,
|
|
620
|
-
});
|
|
621
|
-
return 'accepted';
|
|
622
|
-
}
|
|
623
|
-
debugWarn(`[SellerPayment] Top-up on-chain failed permanently: channel=${channelId.slice(0, 18)}... ` +
|
|
624
|
-
`kind=${failureKind} error=${this._formatError(topUpErr)} — closing latest auth and rejecting topUp`);
|
|
625
|
-
this._pendingTopUp.delete(channelId);
|
|
626
|
-
this._blockedChannels.add(channelId);
|
|
627
|
-
await this.settleSession(buyerPeerId);
|
|
628
|
-
return 'rejected';
|
|
629
|
-
}
|
|
632
|
+
// The on-chain topUp() runs outside this per-buyer lock, once the
|
|
633
|
+
// contract will accept it: with Base's block time and RPC latency a
|
|
634
|
+
// topUp() takes seconds, and every auth and request for this buyer
|
|
635
|
+
// waits on the lock. Until it lands, serving uses the current ceiling.
|
|
636
|
+
debugLog(`[SellerPayment] Top-up verified: channel=${channelId.slice(0, 18)}... ceiling ${currentReserveMax} → ${newMaxAmount}`);
|
|
637
|
+
// A replay of the top-up being submitted right now adds nothing.
|
|
638
|
+
if (newMaxAmount <= (this._topUpInFlight.get(channelId) ?? 0n))
|
|
639
|
+
return 'accepted';
|
|
640
|
+
this._storePendingTopUp(channelId, {
|
|
641
|
+
newMaxAmount,
|
|
642
|
+
deadline: topUpDeadline,
|
|
643
|
+
reserveAuthSig: payload.spendingAuthSig,
|
|
644
|
+
});
|
|
645
|
+
this._scheduleTopUp(buyerPeerId, channelId);
|
|
630
646
|
return 'accepted';
|
|
631
647
|
}
|
|
632
648
|
else {
|
|
@@ -657,19 +673,32 @@ export class SellerPaymentManager {
|
|
|
657
673
|
this._acknowledgeRetainedChannel(buyerPeerId, channelId, paymentMux, disconnectMarker);
|
|
658
674
|
return 'accepted';
|
|
659
675
|
}
|
|
660
|
-
//
|
|
676
|
+
// An auth below what the seller has spent is still accepted: it only
|
|
677
|
+
// raises what the seller can claim. With requests in parallel, another
|
|
678
|
+
// one often finishes between a NeedAuth and the buyer's answer, so the
|
|
679
|
+
// answer is behind by then; rejecting it left the accepted amount stuck
|
|
680
|
+
// and parked every later request. Serving stays gated on spent <=
|
|
681
|
+
// accepted (see the request handler), so this never serves more.
|
|
661
682
|
const spent = this._spent.get(channelId) ?? 0n;
|
|
662
683
|
if (cumulativeAmount < spent) {
|
|
663
|
-
|
|
684
|
+
debugLog(`[SellerPayment] SpendingAuth behind spend, accepted: ` +
|
|
664
685
|
`cumulative=${cumulativeAmount} < spent=${spent} channel=${channelId.slice(0, 18)}...`);
|
|
665
|
-
return 'rejected';
|
|
666
686
|
}
|
|
667
687
|
// Reject if cumulative exceeds the on-chain deposit. Pending topUps do
|
|
668
688
|
// not count here: until topUp() succeeds, the extra funds are not
|
|
669
689
|
// locked, and accepting an over-reserve SpendingAuth would leave the
|
|
670
690
|
// seller with an auth the contract cannot settle.
|
|
671
|
-
|
|
691
|
+
let currentReserveMax = this._reserveMax.get(channelId) ?? 0n;
|
|
672
692
|
const pendingTopUpForCheck = this._pendingTopUp.get(channelId);
|
|
693
|
+
// The buyer signs up to a top-up it sent: while that topUp() is mining,
|
|
694
|
+
// ask the caller to wait for it (with the lock released) instead of
|
|
695
|
+
// rejecting an auth only the new ceiling covers.
|
|
696
|
+
if (allowTopUpWait && currentReserveMax > 0n && cumulativeAmount > currentReserveMax && this._topUpsRunning.has(channelId)) {
|
|
697
|
+
return 'awaiting-top-up';
|
|
698
|
+
}
|
|
699
|
+
if (currentReserveMax > 0n && cumulativeAmount > currentReserveMax) {
|
|
700
|
+
currentReserveMax = await this.refreshReserveMaxFromChain(channelId);
|
|
701
|
+
}
|
|
673
702
|
if (currentReserveMax > 0n && cumulativeAmount > currentReserveMax) {
|
|
674
703
|
debugWarn(`[SellerPayment] Rejecting SpendingAuth exceeding deposit ceiling: ` +
|
|
675
704
|
`cumulative=${cumulativeAmount} > reserveMax=${currentReserveMax}` +
|
|
@@ -696,12 +725,9 @@ export class SellerPaymentManager {
|
|
|
696
725
|
this._channelStore.upsertChannel(session);
|
|
697
726
|
}
|
|
698
727
|
debugLog(`[SellerPayment] Budget updated: channel=${channelId.slice(0, 18)}... cumulative=${cumulativeAmount}`);
|
|
699
|
-
//
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
const { amount: retrySettleAmount, metadata: retryMetadata, sig: retrySig } = this._getSettleParams(channelId);
|
|
703
|
-
await this._retryPendingTopUp(buyerPeerId, channelId, pendingTopUp, retrySettleAmount, retryMetadata, retrySig);
|
|
704
|
-
}
|
|
728
|
+
// A deferred topUp may be accepted now that more is authorized.
|
|
729
|
+
if (this._pendingTopUp.has(channelId))
|
|
730
|
+
this._scheduleTopUp(buyerPeerId, channelId);
|
|
705
731
|
if (requiresReactivation
|
|
706
732
|
&& await this._validateRetainedChannel(buyerPeerId, channelId, cumulativeAmount, disconnectMarker)) {
|
|
707
733
|
this._acknowledgeRetainedChannel(buyerPeerId, channelId, paymentMux, disconnectMarker);
|
|
@@ -1271,19 +1297,122 @@ export class SellerPaymentManager {
|
|
|
1271
1297
|
debugWarn(`[SellerPayment] Failed to settle channel after ${reason}: ${err instanceof Error ? err.message : err}`);
|
|
1272
1298
|
}
|
|
1273
1299
|
}
|
|
1300
|
+
/**
|
|
1301
|
+
* Submit the channel's pending topUp in the background, one at a time per
|
|
1302
|
+
* channel, and only once the authorized amount reaches the contract's
|
|
1303
|
+
* settled threshold: anything earlier reverts TopUpThresholdNotMet after a
|
|
1304
|
+
* full chain round trip. A request made while one is running is picked up
|
|
1305
|
+
* when it finishes.
|
|
1306
|
+
*/
|
|
1307
|
+
_scheduleTopUp(buyerPeerId, channelId) {
|
|
1308
|
+
if (this._topUpsRunning.has(channelId)) {
|
|
1309
|
+
this._topUpsWanted.add(channelId);
|
|
1310
|
+
return;
|
|
1311
|
+
}
|
|
1312
|
+
// Starts after it is registered below, so every exit path finds itself there.
|
|
1313
|
+
const run = Promise.resolve().then(async () => {
|
|
1314
|
+
try {
|
|
1315
|
+
for (;;) {
|
|
1316
|
+
this._topUpsWanted.delete(channelId);
|
|
1317
|
+
const pending = this._pendingTopUp.get(channelId);
|
|
1318
|
+
if (!pending)
|
|
1319
|
+
break;
|
|
1320
|
+
// A replay of a top-up that already landed: the contract would
|
|
1321
|
+
// reject it (TopUpAmountTooLow) and that would close the channel.
|
|
1322
|
+
if (pending.newMaxAmount <= (this._reserveMax.get(channelId) ?? 0n)) {
|
|
1323
|
+
this._pendingTopUp.delete(channelId);
|
|
1324
|
+
break;
|
|
1325
|
+
}
|
|
1326
|
+
if (!await this._topUpEligible(channelId, this._getSettleParams(channelId).amount)) {
|
|
1327
|
+
// A higher auth may have arrived during the threshold read.
|
|
1328
|
+
if (this._topUpsWanted.has(channelId))
|
|
1329
|
+
continue;
|
|
1330
|
+
break;
|
|
1331
|
+
}
|
|
1332
|
+
// Submit what is pending now: a higher ReserveAuth may have replaced
|
|
1333
|
+
// it during the read, and settle with the latest authorization.
|
|
1334
|
+
const latest = this._pendingTopUp.get(channelId);
|
|
1335
|
+
if (!latest)
|
|
1336
|
+
break;
|
|
1337
|
+
const { amount, metadata, sig } = this._getSettleParams(channelId);
|
|
1338
|
+
this._topUpInFlight.set(channelId, latest.newMaxAmount);
|
|
1339
|
+
let outcome;
|
|
1340
|
+
try {
|
|
1341
|
+
outcome = await this._retryPendingTopUp(buyerPeerId, channelId, latest, amount, metadata, sig);
|
|
1342
|
+
}
|
|
1343
|
+
finally {
|
|
1344
|
+
this._topUpInFlight.delete(channelId);
|
|
1345
|
+
}
|
|
1346
|
+
if (outcome === 'permanent-failure')
|
|
1347
|
+
break;
|
|
1348
|
+
// After a success, a higher top-up queued meanwhile goes next; after
|
|
1349
|
+
// a retryable failure, only a newer request retries it.
|
|
1350
|
+
if (outcome === 'succeeded' ? !this._pendingTopUp.has(channelId) : !this._topUpsWanted.has(channelId))
|
|
1351
|
+
break;
|
|
1352
|
+
}
|
|
1353
|
+
}
|
|
1354
|
+
catch (err) {
|
|
1355
|
+
debugWarn(`[SellerPayment] Background topUp failed: channel=${channelId.slice(0, 18)}... error=${this._formatError(err)}`);
|
|
1356
|
+
}
|
|
1357
|
+
finally {
|
|
1358
|
+
if (this._topUpsRunning.get(channelId) === run)
|
|
1359
|
+
this._topUpsRunning.delete(channelId);
|
|
1360
|
+
this._topUpsWanted.delete(channelId);
|
|
1361
|
+
}
|
|
1362
|
+
});
|
|
1363
|
+
this._topUpsRunning.set(channelId, run);
|
|
1364
|
+
}
|
|
1365
|
+
/** Resolves once no background topUp() is running for the channel. */
|
|
1366
|
+
async waitForTopUp(channelId, timeoutMs = Infinity) {
|
|
1367
|
+
const deadline = Date.now() + timeoutMs;
|
|
1368
|
+
while (this._topUpsRunning.has(channelId)) {
|
|
1369
|
+
const remaining = deadline - Date.now();
|
|
1370
|
+
if (remaining <= 0)
|
|
1371
|
+
return false;
|
|
1372
|
+
let timer;
|
|
1373
|
+
const timedOut = new Promise((resolve) => {
|
|
1374
|
+
if (Number.isFinite(remaining))
|
|
1375
|
+
timer = setTimeout(resolve, remaining);
|
|
1376
|
+
});
|
|
1377
|
+
try {
|
|
1378
|
+
await Promise.race([this._topUpsRunning.get(channelId), timedOut]);
|
|
1379
|
+
}
|
|
1380
|
+
finally {
|
|
1381
|
+
clearTimeout(timer);
|
|
1382
|
+
}
|
|
1383
|
+
}
|
|
1384
|
+
return true;
|
|
1385
|
+
}
|
|
1386
|
+
/** Whether a background topUp() is being submitted for the channel now. */
|
|
1387
|
+
isTopUpRunning(channelId) {
|
|
1388
|
+
return this._topUpsRunning.has(channelId);
|
|
1389
|
+
}
|
|
1390
|
+
/** Whether topUp() settling `settleAmount` would pass the contract's settled threshold. */
|
|
1391
|
+
async _topUpEligible(channelId, settleAmount) {
|
|
1392
|
+
const deposit = this._reserveMax.get(channelId) ?? 0n;
|
|
1393
|
+
if (deposit <= 0n)
|
|
1394
|
+
return true;
|
|
1395
|
+
const thresholdBps = await this._topUpThresholdBps();
|
|
1396
|
+
return settleAmount * 10000n >= deposit * thresholdBps;
|
|
1397
|
+
}
|
|
1398
|
+
_topUpThresholdBps() {
|
|
1399
|
+
this._topUpThreshold ??= this._channelsClient.getTopUpSettledThresholdBps()
|
|
1400
|
+
.then((bps) => (bps > 0n && bps <= 10000n ? bps : DEFAULT_TOP_UP_SETTLED_THRESHOLD_BPS))
|
|
1401
|
+
.catch((err) => {
|
|
1402
|
+
debugWarn(`[SellerPayment] Failed to read the top-up threshold; using ${DEFAULT_TOP_UP_SETTLED_THRESHOLD_BPS}: ${this._formatError(err)}`);
|
|
1403
|
+
this._topUpThreshold = null;
|
|
1404
|
+
return DEFAULT_TOP_UP_SETTLED_THRESHOLD_BPS;
|
|
1405
|
+
});
|
|
1406
|
+
return this._topUpThreshold;
|
|
1407
|
+
}
|
|
1274
1408
|
async _retryPendingTopUp(buyerPeerId, channelId, pendingTopUp, settleAmount, settleMetadata, settleSig) {
|
|
1275
|
-
|
|
1409
|
+
// A higher ReserveAuth queued meanwhile stays pending for the next round.
|
|
1410
|
+
if (this._pendingTopUp.get(channelId) === pendingTopUp)
|
|
1411
|
+
this._pendingTopUp.delete(channelId);
|
|
1276
1412
|
debugLog(`[SellerPayment] Retrying deferred topUp: channel=${channelId.slice(0, 18)}... settling=${settleAmount} newMax=${pendingTopUp.newMaxAmount}`);
|
|
1277
1413
|
try {
|
|
1278
1414
|
await this._channelsClient.topUp(this._signer, channelId, settleAmount, settleMetadata, settleSig, pendingTopUp.newMaxAmount, BigInt(pendingTopUp.deadline), pendingTopUp.reserveAuthSig);
|
|
1279
|
-
this.
|
|
1280
|
-
const topUpSession = this._channelStore.getChannel(channelId);
|
|
1281
|
-
if (topUpSession) {
|
|
1282
|
-
topUpSession.previousConsumption = pendingTopUp.newMaxAmount.toString();
|
|
1283
|
-
topUpSession.deadline = pendingTopUp.deadline;
|
|
1284
|
-
topUpSession.updatedAt = Date.now();
|
|
1285
|
-
this._channelStore.upsertChannel(topUpSession);
|
|
1286
|
-
}
|
|
1415
|
+
this._updateReserveCeiling(channelId, pendingTopUp.newMaxAmount, pendingTopUp.deadline);
|
|
1287
1416
|
debugLog(`[SellerPayment] Deferred topUp succeeded: channel=${channelId.slice(0, 18)}... new ceiling=${pendingTopUp.newMaxAmount}`);
|
|
1288
1417
|
return 'succeeded';
|
|
1289
1418
|
}
|
|
@@ -1298,8 +1427,12 @@ export class SellerPaymentManager {
|
|
|
1298
1427
|
}
|
|
1299
1428
|
debugWarn(`[SellerPayment] Deferred topUp failed permanently: channel=${channelId.slice(0, 18)}... ` +
|
|
1300
1429
|
`kind=${failureKind} error=${this._formatError(retryErr)} — closing latest auth and dropping pending topUp`);
|
|
1430
|
+
// Stop admitting work at once, but close only after the requests already
|
|
1431
|
+
// running on this channel have recorded their spend and been signed for.
|
|
1301
1432
|
this._blockedChannels.add(channelId);
|
|
1302
|
-
|
|
1433
|
+
if (this._channelStore.getActiveChannelByPeer(buyerPeerId, CHANNEL_ROLE.SELLER)?.sessionId === channelId) {
|
|
1434
|
+
await this.closeSessionWhenIdle(buyerPeerId);
|
|
1435
|
+
}
|
|
1303
1436
|
return 'permanent-failure';
|
|
1304
1437
|
}
|
|
1305
1438
|
}
|
|
@@ -1524,6 +1657,24 @@ export class SellerPaymentManager {
|
|
|
1524
1657
|
const session = this._channelStore.getActiveChannelByPeer(buyerPeerId, CHANNEL_ROLE.SELLER);
|
|
1525
1658
|
return session != null && this._closingChannels.has(session.sessionId);
|
|
1526
1659
|
}
|
|
1660
|
+
/**
|
|
1661
|
+
* Wait (up to `timeoutMs`) for the close in flight on the buyer's active
|
|
1662
|
+
* channel. Returns whether it finished in time.
|
|
1663
|
+
*/
|
|
1664
|
+
async waitForClosingChannel(buyerPeerId, timeoutMs) {
|
|
1665
|
+
const session = this._channelStore.getActiveChannelByPeer(buyerPeerId, CHANNEL_ROLE.SELLER);
|
|
1666
|
+
const closing = session ? this._closingChannels.get(session.sessionId) : undefined;
|
|
1667
|
+
if (!closing)
|
|
1668
|
+
return true;
|
|
1669
|
+
let timer;
|
|
1670
|
+
const timedOut = new Promise((resolve) => { timer = setTimeout(() => resolve(false), timeoutMs); });
|
|
1671
|
+
try {
|
|
1672
|
+
return await Promise.race([closing.then(() => true, () => true), timedOut]);
|
|
1673
|
+
}
|
|
1674
|
+
finally {
|
|
1675
|
+
clearTimeout(timer);
|
|
1676
|
+
}
|
|
1677
|
+
}
|
|
1527
1678
|
// ── Disconnect handling ───────────────────────────────────────
|
|
1528
1679
|
onBuyerDisconnect(buyerPeerId) {
|
|
1529
1680
|
const session = this._channelStore.getActiveChannelByPeer(buyerPeerId, CHANNEL_ROLE.SELLER);
|
|
@@ -1788,6 +1939,16 @@ export class SellerPaymentManager {
|
|
|
1788
1939
|
while (this.hasInFlightRequests(buyerPeerId) && Date.now() < drainDeadline) {
|
|
1789
1940
|
await new Promise((resolve) => setTimeout(resolve, IDLE_CLOSE_POLL_MS));
|
|
1790
1941
|
}
|
|
1942
|
+
// The buyer's auths for the work just finished are still on the wire or
|
|
1943
|
+
// queued behind the lock: close at them, not at what was accepted before.
|
|
1944
|
+
await this.waitForPendingAuths(buyerPeerId);
|
|
1945
|
+
const spent = this._spent.get(channelId) ?? 0n;
|
|
1946
|
+
const ceiling = this._reserveMax.get(channelId) ?? 0n;
|
|
1947
|
+
const claimable = ceiling > 0n && spent > ceiling ? ceiling : spent;
|
|
1948
|
+
if ((this._acceptedCumulative.get(channelId) ?? 0n) < claimable) {
|
|
1949
|
+
await this.awaitAcceptedAtLeast(channelId, claimable, CLOSE_CATCH_UP_WAIT_MS);
|
|
1950
|
+
await this.waitForPendingAuths(buyerPeerId);
|
|
1951
|
+
}
|
|
1791
1952
|
if (this._channelStore.getActiveChannelByPeer(buyerPeerId, CHANNEL_ROLE.SELLER)?.sessionId !== channelId)
|
|
1792
1953
|
return;
|
|
1793
1954
|
if ((this._acceptedCumulative.get(channelId) ?? 0n) > 0n || !this._blockedChannels.has(channelId)) {
|
|
@@ -1830,6 +1991,70 @@ export class SellerPaymentManager {
|
|
|
1830
1991
|
getEffectiveReserveMax(sessionId) {
|
|
1831
1992
|
return this.getReserveMax(sessionId);
|
|
1832
1993
|
}
|
|
1994
|
+
/**
|
|
1995
|
+
* Raise the local reserve ceiling to the channel's on-chain deposit when it
|
|
1996
|
+
* is behind: a top-up that confirmed while the seller stopped is not in the
|
|
1997
|
+
* store. Checked before treating a channel as exhausted or rejecting an
|
|
1998
|
+
* auth above the ceiling, so a stale ceiling never closes a channel early.
|
|
1999
|
+
* Never lowers the ceiling. Concurrent callers share one read, and a channel
|
|
2000
|
+
* is read at most once per interval. Returns the effective ceiling.
|
|
2001
|
+
*/
|
|
2002
|
+
async refreshReserveMaxFromChain(channelId) {
|
|
2003
|
+
return (await this._refreshReserveCeiling(channelId)).ceiling;
|
|
2004
|
+
}
|
|
2005
|
+
/**
|
|
2006
|
+
* The effective ceiling, and whether it reflects a successful chain read
|
|
2007
|
+
* (now or within the interval). A failed read returns the stored ceiling,
|
|
2008
|
+
* unverified, and is retried on the next call.
|
|
2009
|
+
*/
|
|
2010
|
+
_refreshReserveCeiling(channelId) {
|
|
2011
|
+
const pending = this._reserveRefreshes.get(channelId);
|
|
2012
|
+
if (pending)
|
|
2013
|
+
return pending;
|
|
2014
|
+
if (Date.now() - (this._reserveRefreshedAt.get(channelId) ?? 0) < RESERVE_REFRESH_MIN_INTERVAL_MS) {
|
|
2015
|
+
return Promise.resolve({ ceiling: this.getEffectiveReserveMax(channelId), verified: true });
|
|
2016
|
+
}
|
|
2017
|
+
const refresh = this._readReserveMaxFromChain(channelId).then((verified) => {
|
|
2018
|
+
if (verified) {
|
|
2019
|
+
this._reserveRefreshedAt.set(channelId, Date.now());
|
|
2020
|
+
if (this._reserveRefreshedAt.size > 1_024)
|
|
2021
|
+
this._reserveRefreshedAt.delete(this._reserveRefreshedAt.keys().next().value);
|
|
2022
|
+
}
|
|
2023
|
+
return { ceiling: this.getEffectiveReserveMax(channelId), verified };
|
|
2024
|
+
}).finally(() => {
|
|
2025
|
+
this._reserveRefreshes.delete(channelId);
|
|
2026
|
+
});
|
|
2027
|
+
this._reserveRefreshes.set(channelId, refresh);
|
|
2028
|
+
return refresh;
|
|
2029
|
+
}
|
|
2030
|
+
/** Raise the ceiling to the on-chain deposit when it is behind. Returns whether the read succeeded. */
|
|
2031
|
+
async _readReserveMaxFromChain(channelId) {
|
|
2032
|
+
try {
|
|
2033
|
+
const state = classifyOnChainChannel(await this._channelsClient.getSession(channelId));
|
|
2034
|
+
const current = this._reserveMax.get(channelId) ?? 0n;
|
|
2035
|
+
if (state.exists && state.status === 'active' && state.channel.deposit > current) {
|
|
2036
|
+
this._updateReserveCeiling(channelId, state.channel.deposit);
|
|
2037
|
+
debugLog(`[SellerPayment] Reserve ceiling ${current} behind on-chain deposit ${state.channel.deposit}: channel=${channelId.slice(0, 18)}...`);
|
|
2038
|
+
}
|
|
2039
|
+
return true;
|
|
2040
|
+
}
|
|
2041
|
+
catch (err) {
|
|
2042
|
+
debugWarn(`[SellerPayment] Reserve ceiling refresh failed: channel=${channelId.slice(0, 18)}... error=${this._formatError(err)}`);
|
|
2043
|
+
return false;
|
|
2044
|
+
}
|
|
2045
|
+
}
|
|
2046
|
+
/** Set a channel's reserve ceiling in memory and in the store (and its deadline, after a top-up). */
|
|
2047
|
+
_updateReserveCeiling(channelId, reserveMax, deadline) {
|
|
2048
|
+
this._reserveMax.set(channelId, reserveMax);
|
|
2049
|
+
const session = this._channelStore.getChannel(channelId);
|
|
2050
|
+
if (!session)
|
|
2051
|
+
return;
|
|
2052
|
+
session.previousConsumption = reserveMax.toString(); // repurposed: stores reserveMax
|
|
2053
|
+
if (deadline !== undefined)
|
|
2054
|
+
session.deadline = deadline;
|
|
2055
|
+
session.updatedAt = Date.now();
|
|
2056
|
+
this._channelStore.upsertChannel(session);
|
|
2057
|
+
}
|
|
1833
2058
|
/** Whether this channel is blocked from serving more paid work. */
|
|
1834
2059
|
isChannelBlocked(sessionId) {
|
|
1835
2060
|
return this._blockedChannels.has(sessionId);
|
|
@@ -1914,7 +2139,22 @@ export class SellerPaymentManager {
|
|
|
1914
2139
|
return reject('invalid_auth', err instanceof Error ? err.message : String(err));
|
|
1915
2140
|
}
|
|
1916
2141
|
if (buyerAuth) {
|
|
1917
|
-
|
|
2142
|
+
// An auth above the deposit cannot be settled: close() would revert and
|
|
2143
|
+
// the channel would stay open. Keep the settleable auth instead.
|
|
2144
|
+
const { ceiling, verified } = await this._refreshReserveCeiling(channelId);
|
|
2145
|
+
if (ceiling > 0n && buyerAuth.cumulativeAmount > ceiling && !verified) {
|
|
2146
|
+
// The stored ceiling may just be stale: never drop the buyer's auth on it.
|
|
2147
|
+
return reject('close_failed', 'could not read the on-chain deposit to check the close authorization', {
|
|
2148
|
+
retryAfterMs: CLOSE_RETRY_AFTER_MS,
|
|
2149
|
+
});
|
|
2150
|
+
}
|
|
2151
|
+
if (ceiling > 0n && buyerAuth.cumulativeAmount > ceiling) {
|
|
2152
|
+
debugWarn(`[SellerPayment] Ignoring close auth above the deposit: cumulative=${buyerAuth.cumulativeAmount} ` +
|
|
2153
|
+
`> reserveMax=${ceiling} channel=${channelId.slice(0, 18)}...`);
|
|
2154
|
+
}
|
|
2155
|
+
else {
|
|
2156
|
+
this._adoptBuyerCloseAuth(channelId, buyerAuth);
|
|
2157
|
+
}
|
|
1918
2158
|
}
|
|
1919
2159
|
// Anything served but not yet signed for is unclaimable — ask for the
|
|
1920
2160
|
// catch-up auth rather than closing at a loss.
|