@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.
Files changed (57) hide show
  1. package/dist/discovery/announcer.d.ts +5 -2
  2. package/dist/discovery/announcer.d.ts.map +1 -1
  3. package/dist/discovery/announcer.js +10 -5
  4. package/dist/discovery/announcer.js.map +1 -1
  5. package/dist/discovery/metadata/fields/service-performance.d.ts +7 -0
  6. package/dist/discovery/metadata/fields/service-performance.d.ts.map +1 -0
  7. package/dist/discovery/metadata/fields/service-performance.js +63 -0
  8. package/dist/discovery/metadata/fields/service-performance.js.map +1 -0
  9. package/dist/discovery/metadata/index.d.ts.map +1 -1
  10. package/dist/discovery/metadata/index.js +2 -1
  11. package/dist/discovery/metadata/index.js.map +1 -1
  12. package/dist/discovery/metadata/versions/v14.d.ts +5 -0
  13. package/dist/discovery/metadata/versions/v14.d.ts.map +1 -0
  14. package/dist/discovery/metadata/versions/v14.js +16 -0
  15. package/dist/discovery/metadata/versions/v14.js.map +1 -0
  16. package/dist/discovery/metadata-validator.d.ts.map +1 -1
  17. package/dist/discovery/metadata-validator.js +29 -1
  18. package/dist/discovery/metadata-validator.js.map +1 -1
  19. package/dist/discovery/service-catalog.d.ts +5 -2
  20. package/dist/discovery/service-catalog.d.ts.map +1 -1
  21. package/dist/discovery/service-catalog.js +3 -1
  22. package/dist/discovery/service-catalog.js.map +1 -1
  23. package/dist/health/model-health-checker.d.ts +9 -0
  24. package/dist/health/model-health-checker.d.ts.map +1 -1
  25. package/dist/health/model-health-checker.js +23 -0
  26. package/dist/health/model-health-checker.js.map +1 -1
  27. package/dist/health/service-performance.d.ts +28 -0
  28. package/dist/health/service-performance.d.ts.map +1 -0
  29. package/dist/health/service-performance.js +144 -0
  30. package/dist/health/service-performance.js.map +1 -0
  31. package/dist/health/stream-outcome.d.ts +19 -0
  32. package/dist/health/stream-outcome.d.ts.map +1 -0
  33. package/dist/health/stream-outcome.js +91 -0
  34. package/dist/health/stream-outcome.js.map +1 -0
  35. package/dist/index.d.ts +2 -2
  36. package/dist/index.d.ts.map +1 -1
  37. package/dist/index.js +2 -2
  38. package/dist/index.js.map +1 -1
  39. package/dist/node.d.ts +2 -0
  40. package/dist/node.d.ts.map +1 -1
  41. package/dist/node.js +7 -0
  42. package/dist/node.js.map +1 -1
  43. package/dist/payments/seller-payment-manager.d.ts +50 -0
  44. package/dist/payments/seller-payment-manager.d.ts.map +1 -1
  45. package/dist/payments/seller-payment-manager.js +320 -80
  46. package/dist/payments/seller-payment-manager.js.map +1 -1
  47. package/dist/routing/seller-health.d.ts +21 -0
  48. package/dist/routing/seller-health.d.ts.map +1 -1
  49. package/dist/routing/seller-health.js +19 -3
  50. package/dist/routing/seller-health.js.map +1 -1
  51. package/dist/seller-request-handler.d.ts +5 -0
  52. package/dist/seller-request-handler.d.ts.map +1 -1
  53. package/dist/seller-request-handler.js +129 -18
  54. package/dist/seller-request-handler.js.map +1 -1
  55. package/dist/types/peer.d.ts +4 -2
  56. package/dist/types/peer.d.ts.map +1 -1
  57. 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 existing = this._buyerLocks.get(lockKey);
445
- let result = 'rejected';
446
- const lock = (existing ?? Promise.resolve()).then(async () => {
447
- result = isOneOff
448
- ? await this._handleOneOffSpendingAuth(buyerPeerId, payload, paymentMux)
449
- : await this._handleSpendingAuthInner(buyerPeerId, payload, paymentMux, disconnectMarker);
450
- });
451
- const tail = lock.catch(() => { });
452
- this._buyerLocks.set(lockKey, tail);
453
- try {
454
- await lock;
455
- }
456
- finally {
457
- if (isOneOff && this._buyerLocks.get(lockKey) === tail)
458
- this._buyerLocks.delete(lockKey);
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
- // Call topUp() on-chain — includes settle of current cumulative spend
589
- const { amount: settleAmount, metadata: settleMetadata, sig: settleSig } = this._getSettleParams(channelId);
590
- debugLog(`[SellerPayment] Top-up verified: channel=${channelId.slice(0, 18)}... ceiling ${currentReserveMax} → ${newMaxAmount} (settling cumulative=${settleAmount})`);
591
- try {
592
- await this._channelsClient.topUp(this._signer, channelId, settleAmount, settleMetadata, settleSig, newMaxAmount, BigInt(topUpDeadline), payload.spendingAuthSig);
593
- // Update tracking
594
- this._hydratedChannelIds.delete(channelId);
595
- this._reserveMax.set(channelId, newMaxAmount);
596
- const session = this._channelStore.getChannel(channelId);
597
- if (session) {
598
- session.previousConsumption = newMaxAmount.toString(); // repurposed: stores reserveMax
599
- session.deadline = topUpDeadline;
600
- session.updatedAt = Date.now();
601
- this._channelStore.upsertChannel(session);
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
- // Reject if buyer's cumulative doesn't cover what the seller has already spent
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
- debugWarn(`[SellerPayment] Rejecting underfunded SpendingAuth: ` +
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
- const currentReserveMax = this._reserveMax.get(channelId) ?? 0n;
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
- // Retry any deferred topUp now that we have a higher settle amount.
700
- const pendingTopUp = this._pendingTopUp.get(channelId);
701
- if (pendingTopUp) {
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
- this._pendingTopUp.delete(channelId);
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._reserveMax.set(channelId, pendingTopUp.newMaxAmount);
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
- await this.settleSession(buyerPeerId);
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
- this._adoptBuyerCloseAuth(channelId, buyerAuth);
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.