@antseed/node 0.2.99 → 0.2.101

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 (52) hide show
  1. package/dist/buyer-request-handler.d.ts +2 -0
  2. package/dist/buyer-request-handler.d.ts.map +1 -1
  3. package/dist/buyer-request-handler.js +2 -2
  4. package/dist/buyer-request-handler.js.map +1 -1
  5. package/dist/discovery/announcer.d.ts +2 -0
  6. package/dist/discovery/announcer.d.ts.map +1 -1
  7. package/dist/discovery/announcer.js +8 -2
  8. package/dist/discovery/announcer.js.map +1 -1
  9. package/dist/index.d.ts +2 -1
  10. package/dist/index.d.ts.map +1 -1
  11. package/dist/index.js +1 -0
  12. package/dist/index.js.map +1 -1
  13. package/dist/interfaces/plugin.d.ts +42 -1
  14. package/dist/interfaces/plugin.d.ts.map +1 -1
  15. package/dist/interfaces/plugin.js +1 -1
  16. package/dist/interfaces/plugin.js.map +1 -1
  17. package/dist/node.d.ts +25 -1
  18. package/dist/node.d.ts.map +1 -1
  19. package/dist/node.js +94 -1
  20. package/dist/node.js.map +1 -1
  21. package/dist/p2p/connection-manager.d.ts.map +1 -1
  22. package/dist/p2p/connection-manager.js +5 -2
  23. package/dist/p2p/connection-manager.js.map +1 -1
  24. package/dist/p2p/payment-codec.d.ts +5 -1
  25. package/dist/p2p/payment-codec.d.ts.map +1 -1
  26. package/dist/p2p/payment-codec.js +54 -1
  27. package/dist/p2p/payment-codec.js.map +1 -1
  28. package/dist/p2p/payment-mux.d.ts +7 -1
  29. package/dist/p2p/payment-mux.d.ts.map +1 -1
  30. package/dist/p2p/payment-mux.js +24 -1
  31. package/dist/p2p/payment-mux.js.map +1 -1
  32. package/dist/payments/buyer-payment-manager.d.ts +14 -1
  33. package/dist/payments/buyer-payment-manager.d.ts.map +1 -1
  34. package/dist/payments/buyer-payment-manager.js +35 -0
  35. package/dist/payments/buyer-payment-manager.js.map +1 -1
  36. package/dist/payments/buyer-payment-negotiator.d.ts +18 -0
  37. package/dist/payments/buyer-payment-negotiator.d.ts.map +1 -1
  38. package/dist/payments/buyer-payment-negotiator.js +104 -6
  39. package/dist/payments/buyer-payment-negotiator.js.map +1 -1
  40. package/dist/payments/seller-payment-manager.d.ts +67 -6
  41. package/dist/payments/seller-payment-manager.d.ts.map +1 -1
  42. package/dist/payments/seller-payment-manager.js +340 -71
  43. package/dist/payments/seller-payment-manager.js.map +1 -1
  44. package/dist/seller-request-handler.d.ts +5 -0
  45. package/dist/seller-request-handler.d.ts.map +1 -1
  46. package/dist/seller-request-handler.js +113 -2
  47. package/dist/seller-request-handler.js.map +1 -1
  48. package/dist/types/protocol.d.ts +60 -0
  49. package/dist/types/protocol.d.ts.map +1 -1
  50. package/dist/types/protocol.js +24 -0
  51. package/dist/types/protocol.js.map +1 -1
  52. package/package.json +1 -1
@@ -10,6 +10,15 @@ const DEFAULT_MIN_BUDGET_PER_REQUEST = '500000';
10
10
  /** ~200× typical Base settle gas cost at 0.006 gwei. */
11
11
  export const DEFAULT_MIN_SETTLE_DELTA_STR = '2000';
12
12
  const DEFAULT_MIN_SETTLE_DELTA = BigInt(DEFAULT_MIN_SETTLE_DELTA_STR);
13
+ /**
14
+ * How long a buyer-requested close waits for an in-flight SpendingAuth to land
15
+ * before declaring the channel still mid-accumulation. Mirrors the request
16
+ * path's catch-up wait: the buyer's auth for the last response may still be on
17
+ * the wire when the close request arrives.
18
+ */
19
+ const CLOSE_CATCH_UP_WAIT_MS = 5_000;
20
+ /** `retryAfterMs` hint returned with 'busy' / 'pending_auth' rejections. */
21
+ const CLOSE_RETRY_AFTER_MS = 2_000;
13
22
  const TOP_UP_THRESHOLD_NOT_MET_SELECTOR = '0x1ea4506b';
14
23
  const INSUFFICIENT_BALANCE_SELECTOR = '0xf4d678b8';
15
24
  const IN_FLIGHT_TX_LIMIT_PHRASE = 'in-flight transaction limit';
@@ -67,8 +76,20 @@ export class SellerPaymentManager {
67
76
  _pendingTopUp = new Map();
68
77
  /** Channels that must not serve more paid work until closed/renegotiated. */
69
78
  _blockedChannels = new Set();
70
- /** channelIds with an in-flight close() tx/estimate. Prevents duplicate close submissions. */
71
- _closingChannels = new Set();
79
+ /**
80
+ * channelId -> in-flight close() result. Every close path shares this map so
81
+ * replacement negotiation can await an existing close instead of submitting
82
+ * a duplicate transaction.
83
+ */
84
+ _closingChannels = new Map();
85
+ /**
86
+ * buyerPeerId -> number of billable requests currently being served.
87
+ * Incremented before the provider call and decremented only after the
88
+ * request's spend has been recorded and its NeedAuth sent, so a non-zero
89
+ * count means "this buyer is still accumulating" and the channel must not be
90
+ * closed out from under it.
91
+ */
92
+ _inFlightRequests = new Map();
72
93
  /** channelId -> cumulative amount last successfully settled on-chain by this
73
94
  * process. Lets the idle-settle loop skip the `getSession` RPC when the
74
95
  * local accepted cumulative hasn't moved since our last settle. */
@@ -182,16 +203,40 @@ export class SellerPaymentManager {
182
203
  this._spent.delete(channelId);
183
204
  this._latestAuth.delete(channelId);
184
205
  this._closeRetryCount.delete(channelId);
185
- this._closingChannels.delete(channelId);
186
206
  this._hydratedChannelIds.delete(channelId);
187
207
  this._reserveMax.delete(channelId);
188
208
  this._pendingTopUp.delete(channelId);
189
209
  this._blockedChannels.delete(channelId);
190
210
  this._lastSettledCumulative.delete(channelId);
191
211
  this._releaseAcceptedWaiters(channelId);
192
- this._activeBuyers.delete(peerId);
212
+ this._deactivateBuyerForChannel(peerId, channelId);
193
213
  debugLog(`[SellerPayment] Evicted stale channel ${channelId.slice(0, 18)}... — ${reason}`);
194
214
  }
215
+ /** Remove connectivity only if it still belongs to the channel being cleaned up. */
216
+ _deactivateBuyerForChannel(peerId, channelId) {
217
+ const current = this._channelStore.getActiveChannelByPeer(peerId, CHANNEL_ROLE.SELLER);
218
+ if (!current || current.sessionId === channelId) {
219
+ this._activeBuyers.delete(peerId);
220
+ }
221
+ }
222
+ /**
223
+ * Submit at most one close transaction per channel and share its outcome.
224
+ * Callers that join an in-flight close receive the amount that close actually
225
+ * submitted, not the one they asked for — persist that, not `amount`.
226
+ */
227
+ _submitClose(channelId, amount, submit) {
228
+ const existing = this._closingChannels.get(channelId);
229
+ if (existing)
230
+ return existing;
231
+ const operation = submit().then((txHash) => ({ closed: true, amount, txHash }), (error) => ({ closed: false, error }));
232
+ this._closingChannels.set(channelId, operation);
233
+ void operation.finally(() => {
234
+ if (this._closingChannels.get(channelId) === operation) {
235
+ this._closingChannels.delete(channelId);
236
+ }
237
+ });
238
+ return operation;
239
+ }
195
240
  /**
196
241
  * Restore a persisted SpendingAuth after in-memory cleanup. This prevents the
197
242
  * timeout checker from mistaking an active channel with a durable signed auth
@@ -359,6 +404,15 @@ export class SellerPaymentManager {
359
404
  return 'rejected';
360
405
  }
361
406
  debugLog(`[SellerPayment] ReserveAuth verified for buyer ${buyerPeerId.slice(0, 12)}...`);
407
+ const superseded = this._channelStore.getActiveChannelByPeer(buyerPeerId, CHANNEL_ROLE.SELLER);
408
+ if (superseded && superseded.sessionId !== channelId) {
409
+ const closed = await this._closeSupersededChannel(superseded);
410
+ if (!closed) {
411
+ debugWarn(`[SellerPayment] Cannot reserve replacement channel ${channelId.slice(0, 18)}... ` +
412
+ `while prior channel ${superseded.sessionId.slice(0, 18)}... remains active`);
413
+ return 'rejected';
414
+ }
415
+ }
362
416
  debugLog(`[SellerPayment] Reserving channel ${channelId.slice(0, 18)}... on-chain`);
363
417
  const reserveSalt = payload.reserveSalt ?? channelId;
364
418
  await this._channelsClient.reserve(this._signer, buyerEvmAddr, reserveSalt, reserveMaxAmount, BigInt(reserveDeadline), payload.spendingAuthSig);
@@ -383,7 +437,10 @@ export class SellerPaymentManager {
383
437
  settledAmount: null,
384
438
  status: CHANNEL_STATUS.ACTIVE,
385
439
  latestBuyerSig: payload.spendingAuthSig,
386
- latestSpendingAuthSig: payload.spendingAuthSig,
440
+ // The initial signature is a ReserveAuth, not a SpendingAuth. Keep it
441
+ // out of the persisted SpendingAuth column so restart hydration cannot
442
+ // restore it under the wrong EIP-712 type and repeatedly fail close().
443
+ latestSpendingAuthSig: null,
387
444
  latestMetadata: payload.metadata,
388
445
  createdAt: now,
389
446
  updatedAt: now,
@@ -781,10 +838,6 @@ export class SellerPaymentManager {
781
838
  this._channelStore.updateTokensDelivered(sessionId, newSpent.toString(), 0);
782
839
  }
783
840
  // ── Settlement ──────────────────────────────────────────────
784
- /**
785
- * Close a completed session on-chain using the latest buyer-signed dual signatures.
786
- * Uses close() for final settlement (releases remaining deposit to buyer).
787
- */
788
841
  /** Get the latest SpendingAuth params for a channel, or zero-auth if none exists. */
789
842
  _getSettleParams(channelId) {
790
843
  const latestAuth = this._latestAuth.get(channelId);
@@ -823,54 +876,77 @@ export class SellerPaymentManager {
823
876
  return;
824
877
  }
825
878
  else {
826
- if (this._closingChannels.has(channelId)) {
827
- debugLog(`[SellerPayment] Close already in flight for ${channelId.slice(0, 18)}... — skipping duplicate request`);
828
- return;
829
- }
830
879
  const retries = this._closeRetryCount.get(channelId) ?? 0;
831
880
  if (retries >= SellerPaymentManager.MAX_CLOSE_RETRIES) {
832
881
  debugWarn(`[SellerPayment] close() failed ${retries} times for ${channelId.slice(0, 18)}... — falling back to timeout path`);
882
+ this._channelStore.updateChannelStatus(channelId, CHANNEL_STATUS.TIMEOUT);
833
883
  }
834
884
  else {
835
885
  debugLog(`[SellerPayment] Closing channel ${channelId.slice(0, 18)}... cumulative=${amount} (attempt ${retries + 1}/${SellerPaymentManager.MAX_CLOSE_RETRIES})`);
836
- this._closingChannels.add(channelId);
837
- try {
838
- await this._channelsClient.close(this._signer, channelId, amount, metadata, sig);
839
- this._channelStore.updateChannelStatus(channelId, CHANNEL_STATUS.SETTLED, amount.toString());
886
+ const closeResult = await this._submitClose(channelId, amount, () => this._channelsClient.close(this._signer, channelId, amount, metadata, sig));
887
+ if (closeResult.closed) {
888
+ this._channelStore.updateChannelStatus(channelId, CHANNEL_STATUS.SETTLED, closeResult.amount.toString());
840
889
  this._closeRetryCount.delete(channelId);
841
890
  }
842
- catch (err) {
843
- if (this._isRetryableTxSubmissionFailure(err)) {
891
+ else {
892
+ if (this._isRetryableTxSubmissionFailure(closeResult.error)) {
844
893
  debugWarn(`[SellerPayment] Close hit transaction backpressure for ${channelId.slice(0, 18)}... ` +
845
- `— keeping channel state for retry: ${this._formatError(err)}`);
894
+ `— keeping channel state for retry: ${this._formatError(closeResult.error)}`);
846
895
  if (cleanupOnFailure) {
847
- this._activeBuyers.delete(buyerPeerId);
896
+ this._deactivateBuyerForChannel(buyerPeerId, channelId);
848
897
  }
849
898
  return;
850
899
  }
851
- debugWarn(`[SellerPayment] Failed to close channel (attempt ${retries + 1}): ${err instanceof Error ? err.message : err}`);
852
- this._closeRetryCount.set(channelId, retries + 1);
853
- if (!cleanupOnFailure)
900
+ const failedAttempts = retries + 1;
901
+ debugWarn(`[SellerPayment] Failed to close channel (attempt ${failedAttempts}): ${this._formatError(closeResult.error)}`);
902
+ this._closeRetryCount.set(channelId, failedAttempts);
903
+ if (failedAttempts < SellerPaymentManager.MAX_CLOSE_RETRIES) {
904
+ if (cleanupOnFailure) {
905
+ this._deactivateBuyerForChannel(buyerPeerId, channelId);
906
+ }
854
907
  return;
855
- }
856
- finally {
857
- this._closingChannels.delete(channelId);
908
+ }
909
+ debugWarn(`[SellerPayment] close() failed ${failedAttempts} times for ${channelId.slice(0, 18)}... — falling back to timeout path`);
910
+ this._channelStore.updateChannelStatus(channelId, CHANNEL_STATUS.TIMEOUT);
858
911
  }
859
912
  }
860
913
  }
861
914
  // Clean up maps after successful close, zero-cumulative deferral, or exhausted retries
915
+ this._forgetChannel(channelId, buyerPeerId);
916
+ }
917
+ /**
918
+ * Drop all in-memory state for a channel that is no longer live. Does not
919
+ * touch persisted status — callers set that first (SETTLED / TIMEOUT).
920
+ */
921
+ _forgetChannel(channelId, buyerPeerId) {
862
922
  this._acceptedCumulative.delete(channelId);
863
923
  this._spent.delete(channelId);
864
924
  this._latestAuth.delete(channelId);
865
925
  this._closeRetryCount.delete(channelId);
866
- this._closingChannels.delete(channelId);
867
926
  this._reserveMax.delete(channelId);
868
927
  this._pendingTopUp.delete(channelId);
869
928
  this._blockedChannels.delete(channelId);
870
929
  this._lastSettledCumulative.delete(channelId);
871
930
  this._hydratedChannelIds.delete(channelId);
872
931
  this._releaseAcceptedWaiters(channelId);
873
- this._activeBuyers.delete(buyerPeerId);
932
+ this._deactivateBuyerForChannel(buyerPeerId, channelId);
933
+ }
934
+ // ── In-flight request tracking ────────────────────────────────
935
+ /** Mark a billable request as started for this buyer. */
936
+ beginBillableRequest(buyerPeerId) {
937
+ this._inFlightRequests.set(buyerPeerId, (this._inFlightRequests.get(buyerPeerId) ?? 0) + 1);
938
+ }
939
+ /** Mark a billable request as fully accounted for (spend recorded, NeedAuth sent). */
940
+ endBillableRequest(buyerPeerId) {
941
+ const next = (this._inFlightRequests.get(buyerPeerId) ?? 0) - 1;
942
+ if (next > 0)
943
+ this._inFlightRequests.set(buyerPeerId, next);
944
+ else
945
+ this._inFlightRequests.delete(buyerPeerId);
946
+ }
947
+ /** Whether this buyer has requests still being served and billed. */
948
+ hasInFlightRequests(buyerPeerId) {
949
+ return (this._inFlightRequests.get(buyerPeerId) ?? 0) > 0;
874
950
  }
875
951
  // ── Disconnect handling ───────────────────────────────────────
876
952
  onBuyerDisconnect(buyerPeerId) {
@@ -949,6 +1025,47 @@ export class SellerPaymentManager {
949
1025
  }
950
1026
  }
951
1027
  }
1028
+ /**
1029
+ * Close the seller's previous channel before accepting a replacement
1030
+ * ReserveAuth from the same buyer. Buyers may lose local channel state and
1031
+ * generate a new channel ID while the previous reserve remains active
1032
+ * on-chain. The contract rejects a second active channel for the same pair,
1033
+ * so recover synchronously using the seller's durable state instead of
1034
+ * leaving both peers in an unrecoverable negotiation loop.
1035
+ */
1036
+ async _closeSupersededChannel(channel) {
1037
+ const channelId = channel.sessionId;
1038
+ const accepted = this._acceptedCumulative.get(channelId)
1039
+ ?? this._restorePersistedSpendingAuth(channel)
1040
+ ?? 0n;
1041
+ try {
1042
+ if (accepted > 0n) {
1043
+ const { amount, metadata, sig } = this._getSettleParams(channelId);
1044
+ const closeResult = await this._submitClose(channelId, amount, () => this._channelsClient.close(this._signer, channelId, amount, metadata, sig));
1045
+ if (!closeResult.closed)
1046
+ return false;
1047
+ this._evictStaleChannel(channelId, channel.peerId, 'closed before replacement reserve', CHANNEL_STATUS.SETTLED);
1048
+ }
1049
+ else {
1050
+ const onChainState = classifyOnChainChannel(await this._channelsClient.getSession(channelId));
1051
+ if (!onChainState.exists || onChainState.status !== 'active') {
1052
+ this._evictStaleChannel(channelId, channel.peerId, `replacement reserve: on-chain status=${onChainState.exists ? onChainState.status : 'missing'}`);
1053
+ }
1054
+ else {
1055
+ const closeResult = await this._submitClose(channelId, onChainState.channel.settled, () => this._channelsClient.close(this._signer, channelId, onChainState.channel.settled, '0x', '0x'));
1056
+ if (!closeResult.closed)
1057
+ return false;
1058
+ this._evictStaleChannel(channelId, channel.peerId, 'zero-auth channel closed before replacement reserve', CHANNEL_STATUS.SETTLED);
1059
+ }
1060
+ }
1061
+ return true;
1062
+ }
1063
+ catch (err) {
1064
+ debugWarn(`[SellerPayment] Failed to close superseded channel ${channelId.slice(0, 18)}...: ` +
1065
+ `${err instanceof Error ? err.message : err}`);
1066
+ return false;
1067
+ }
1068
+ }
952
1069
  /**
953
1070
  * Close a zombie channel (buyer gone, no signed auth) using the current
954
1071
  * on-chain settled cumulative as finalAmount. This intentionally settles no
@@ -956,35 +1073,25 @@ export class SellerPaymentManager {
956
1073
  * remaining reserved deposit back to the buyer.
957
1074
  */
958
1075
  async _closeWithoutAuth(channelId, peerId, onChainSettled) {
959
- if (this._closingChannels.has(channelId)) {
960
- debugLog(`[SellerPayment] Close already in flight for ${channelId.slice(0, 18)}... — skipping zombie close`);
961
- return;
962
- }
963
1076
  const retries = this._closeRetryCount.get(channelId) ?? 0;
964
1077
  if (retries >= SellerPaymentManager.MAX_CLOSE_RETRIES) {
965
1078
  debugWarn(`[SellerPayment] Zombie close failed ${retries} times for ${channelId.slice(0, 18)}... — falling back to local eviction`);
966
1079
  this._evictStaleChannel(channelId, peerId, 'no auths, past deadline, close retries exhausted', CHANNEL_STATUS.TIMEOUT);
967
1080
  return;
968
1081
  }
969
- this._closingChannels.add(channelId);
970
1082
  debugLog(`[SellerPayment] Closing zombie channel ${channelId.slice(0, 18)}... finalAmount=${onChainSettled} (attempt ${retries + 1}/${SellerPaymentManager.MAX_CLOSE_RETRIES})`);
971
- try {
972
- await this._channelsClient.close(this._signer, channelId, onChainSettled, '0x', '0x');
1083
+ const closeResult = await this._submitClose(channelId, onChainSettled, () => this._channelsClient.close(this._signer, channelId, onChainSettled, '0x', '0x'));
1084
+ if (closeResult.closed) {
973
1085
  this._closeRetryCount.delete(channelId);
974
1086
  this._evictStaleChannel(channelId, peerId, 'zombie closed on-chain', CHANNEL_STATUS.SETTLED);
975
1087
  }
976
- catch (err) {
977
- if (this._isRetryableTxSubmissionFailure(err)) {
978
- debugWarn(`[SellerPayment] Zombie close hit transaction backpressure for ${channelId.slice(0, 18)}... ` +
979
- `— keeping channel for retry: ${this._formatError(err)}`);
980
- }
981
- else {
982
- this._closeRetryCount.set(channelId, retries + 1);
983
- debugWarn(`[SellerPayment] Zombie close failed (attempt ${retries + 1}): ${err instanceof Error ? err.message : err}`);
984
- }
1088
+ else if (this._isRetryableTxSubmissionFailure(closeResult.error)) {
1089
+ debugWarn(`[SellerPayment] Zombie close hit transaction backpressure for ${channelId.slice(0, 18)}... ` +
1090
+ `— keeping channel for retry: ${this._formatError(closeResult.error)}`);
985
1091
  }
986
- finally {
987
- this._closingChannels.delete(channelId);
1092
+ else {
1093
+ this._closeRetryCount.set(channelId, retries + 1);
1094
+ debugWarn(`[SellerPayment] Zombie close failed (attempt ${retries + 1}): ${this._formatError(closeResult.error)}`);
988
1095
  }
989
1096
  }
990
1097
  // ── Queries ───────────────────────────────────────────────────
@@ -1043,6 +1150,184 @@ export class SellerPaymentManager {
1043
1150
  ...(pricing?.cachedInputUsdPerMillion != null ? { cachedInputUsdPerMillion: pricing.cachedInputUsdPerMillion } : {}),
1044
1151
  };
1045
1152
  }
1153
+ // ── Buyer-requested cooperative close ─────────────────────────
1154
+ /**
1155
+ * Handle a buyer's CloseChannelRequest: close the channel on-chain right
1156
+ * away so the buyer's reserve is released without the `requestClose()` →
1157
+ * 15-minute grace → `withdraw()` detour.
1158
+ *
1159
+ * The seller only agrees when it is not mid-accumulation with this buyer:
1160
+ * no billable request in flight, and no served work the buyer has not signed
1161
+ * for. It closes at `max(own last-accepted auth, buyer-supplied auth)`, so a
1162
+ * buyer cannot use this path to settle below what it already owes, and a
1163
+ * seller that lost the buyer's latest auth can still be paid in full by the
1164
+ * copy the buyer attaches.
1165
+ */
1166
+ async handleCloseChannelRequest(buyerPeerId, payload, paymentMux) {
1167
+ const reject = (code, reason, extra = {}) => {
1168
+ debugLog(`[SellerPayment] Declining close of ${payload.channelId.slice(0, 18)}... — ${code}: ${reason}`);
1169
+ return { version: 1, channelId: payload.channelId, status: 'rejected', code, reason, ...extra };
1170
+ };
1171
+ let session = this._channelStore.getActiveChannelByPeer(buyerPeerId, CHANNEL_ROLE.SELLER);
1172
+ if (!session) {
1173
+ return reject('no_channel', 'no active channel for this buyer');
1174
+ }
1175
+ if (session.sessionId.toLowerCase() !== payload.channelId.toLowerCase()) {
1176
+ return reject('no_channel', `active channel is ${session.sessionId}, not the requested channel`);
1177
+ }
1178
+ const channelId = session.sessionId;
1179
+ if (this.hasInFlightRequests(buyerPeerId)) {
1180
+ return reject('busy', 'a request is still being served on this channel', {
1181
+ retryAfterMs: CLOSE_RETRY_AFTER_MS,
1182
+ });
1183
+ }
1184
+ if (this._closingChannels.has(channelId)) {
1185
+ return reject('busy', 'a close is already in flight for this channel', {
1186
+ retryAfterMs: CLOSE_RETRY_AFTER_MS,
1187
+ });
1188
+ }
1189
+ // Let any SpendingAuth already being processed finish before reading the
1190
+ // accepted cumulative, then re-check the channel still exists.
1191
+ await this.waitForPendingAuths(buyerPeerId);
1192
+ session = this._channelStore.getActiveChannelByPeer(buyerPeerId, CHANNEL_ROLE.SELLER);
1193
+ if (!session || session.sessionId !== channelId) {
1194
+ return reject('no_channel', 'channel was retired while the close request was being processed');
1195
+ }
1196
+ // Verify the buyer's optional auth before comparing it with our own.
1197
+ let buyerAuth;
1198
+ try {
1199
+ buyerAuth = await this._verifyBuyerCloseAuth(session, payload);
1200
+ }
1201
+ catch (err) {
1202
+ return reject('invalid_auth', err instanceof Error ? err.message : String(err));
1203
+ }
1204
+ if (buyerAuth) {
1205
+ this._adoptBuyerCloseAuth(channelId, buyerAuth);
1206
+ }
1207
+ // Anything served but not yet signed for is unclaimable — ask for the
1208
+ // catch-up auth rather than closing at a loss.
1209
+ let spent = this._spent.get(channelId) ?? 0n;
1210
+ let best = this._getSettleParams(channelId);
1211
+ if (spent > best.amount) {
1212
+ await this.awaitAcceptedAtLeast(channelId, spent, CLOSE_CATCH_UP_WAIT_MS);
1213
+ spent = this._spent.get(channelId) ?? 0n;
1214
+ best = this._getSettleParams(channelId);
1215
+ }
1216
+ if (spent > best.amount) {
1217
+ const accepted = this._acceptedCumulative.get(channelId) ?? 0n;
1218
+ try {
1219
+ paymentMux.sendNeedAuth({
1220
+ channelId,
1221
+ requiredCumulativeAmount: spent.toString(),
1222
+ currentAcceptedCumulative: accepted.toString(),
1223
+ deposit: session.authMax ?? '0',
1224
+ });
1225
+ }
1226
+ catch (err) {
1227
+ debugWarn(`[SellerPayment] Failed to send catch-up NeedAuth before close: ${this._formatError(err)}`);
1228
+ }
1229
+ return reject('pending_auth', `unsigned spend outstanding (spent=${spent} signed=${best.amount})`, {
1230
+ retryAfterMs: CLOSE_RETRY_AFTER_MS,
1231
+ requiredCumulativeAmount: spent.toString(),
1232
+ });
1233
+ }
1234
+ // The contract rejects a finalAmount at or below what is already settled
1235
+ // (InvalidAmount) and skips signature verification when they are equal, so
1236
+ // fall back to an unsigned close-at-settled when we have nothing better.
1237
+ let onChainSettled;
1238
+ try {
1239
+ onChainSettled = (await this._channelsClient.getSession(channelId)).settled;
1240
+ }
1241
+ catch (err) {
1242
+ return reject('close_failed', `could not read on-chain channel state: ${this._formatError(err)}`);
1243
+ }
1244
+ const useSignedAuth = best.sig !== '0x' && best.amount > onChainSettled;
1245
+ const finalAmount = useSignedAuth ? best.amount : onChainSettled;
1246
+ const metadata = useSignedAuth ? best.metadata : '0x';
1247
+ const sig = useSignedAuth ? best.sig : '0x';
1248
+ debugLog(`[SellerPayment] Buyer-requested close of ${channelId.slice(0, 18)}... ` +
1249
+ `finalAmount=${finalAmount} (signed=${useSignedAuth})`);
1250
+ // Share the close with every other close path. The busy check above runs
1251
+ // before several awaits, so a background close can still start in between —
1252
+ // report that transaction rather than submitting a competing one.
1253
+ const closeResult = await this._submitClose(channelId, finalAmount, () => this._channelsClient.close(this._signer, channelId, finalAmount, metadata, sig));
1254
+ if (!closeResult.closed) {
1255
+ const message = this._formatError(closeResult.error);
1256
+ debugWarn(`[SellerPayment] Buyer-requested close failed for ${channelId.slice(0, 18)}...: ${message}`);
1257
+ return reject('close_failed', message, {
1258
+ ...(this._isRetryableTxSubmissionFailure(closeResult.error) ? { retryAfterMs: CLOSE_RETRY_AFTER_MS } : {}),
1259
+ });
1260
+ }
1261
+ this._channelStore.updateChannelStatus(channelId, CHANNEL_STATUS.SETTLED, closeResult.amount.toString());
1262
+ this._forgetChannel(channelId, buyerPeerId);
1263
+ debugLog(`[SellerPayment] Channel ${channelId.slice(0, 18)}... closed on buyer request (tx=${closeResult.txHash})`);
1264
+ return {
1265
+ version: 1,
1266
+ channelId,
1267
+ status: 'closed',
1268
+ txHash: closeResult.txHash,
1269
+ finalAmount: closeResult.amount.toString(),
1270
+ };
1271
+ }
1272
+ /**
1273
+ * Verify a SpendingAuth attached to a CloseChannelRequest. Returns null when
1274
+ * the buyer attached nothing; throws with a caller-safe message when the
1275
+ * attached auth does not check out.
1276
+ */
1277
+ async _verifyBuyerCloseAuth(session, payload) {
1278
+ const { cumulativeAmount, metadataHash, metadata, spendingAuthSig } = payload;
1279
+ if (cumulativeAmount === undefined || metadataHash === undefined
1280
+ || metadata === undefined || spendingAuthSig === undefined) {
1281
+ return null;
1282
+ }
1283
+ let amount;
1284
+ try {
1285
+ amount = BigInt(cumulativeAmount);
1286
+ }
1287
+ catch {
1288
+ throw new Error(`cumulativeAmount "${cumulativeAmount}" is not an integer`);
1289
+ }
1290
+ if (amount < 0n)
1291
+ throw new Error('cumulativeAmount must not be negative');
1292
+ if (keccak256(metadata).toLowerCase() !== metadataHash.toLowerCase()) {
1293
+ throw new Error('metadataHash does not match metadata');
1294
+ }
1295
+ const { channels } = await this._resolvedAddresses;
1296
+ const channelsDomain = makeChannelsDomain(this._config.chainId, channels);
1297
+ const recovered = verifyTypedData(channelsDomain, SPENDING_AUTH_TYPES, { channelId: session.sessionId, cumulativeAmount: amount, metadataHash }, spendingAuthSig);
1298
+ if (recovered.toLowerCase() !== session.buyerEvmAddr.toLowerCase()) {
1299
+ throw new Error(`signature recovers to ${recovered}, not the channel buyer`);
1300
+ }
1301
+ return { spendingAuthSig, cumulativeAmount: amount, metadataHash, metadata };
1302
+ }
1303
+ /**
1304
+ * Take the buyer-supplied auth as our latest when it authorizes strictly more
1305
+ * than what we already hold. Persisted so a crash between here and close()
1306
+ * doesn't lose the higher claim.
1307
+ */
1308
+ _adoptBuyerCloseAuth(channelId, buyerAuth) {
1309
+ const current = this._latestAuth.get(channelId);
1310
+ if (current && current.spendingAuthSig.length > 0 && current.cumulativeAmount >= buyerAuth.cumulativeAmount) {
1311
+ return;
1312
+ }
1313
+ this._latestAuth.set(channelId, buyerAuth);
1314
+ const accepted = this._acceptedCumulative.get(channelId) ?? 0n;
1315
+ if (buyerAuth.cumulativeAmount > accepted) {
1316
+ this._acceptedCumulative.set(channelId, buyerAuth.cumulativeAmount);
1317
+ this._notifyAcceptedUpdate(channelId, buyerAuth.cumulativeAmount);
1318
+ }
1319
+ const stored = this._channelStore.getChannel(channelId);
1320
+ if (stored) {
1321
+ stored.authMax = (buyerAuth.cumulativeAmount > BigInt(stored.authMax || '0') ? buyerAuth.cumulativeAmount : BigInt(stored.authMax)).toString();
1322
+ stored.latestBuyerSig = buyerAuth.spendingAuthSig;
1323
+ stored.latestSpendingAuthSig = buyerAuth.spendingAuthSig;
1324
+ stored.latestMetadata = buyerAuth.metadata;
1325
+ stored.updatedAt = Date.now();
1326
+ this._channelStore.upsertChannel(stored);
1327
+ }
1328
+ debugLog(`[SellerPayment] Adopted buyer-supplied close auth for ${channelId.slice(0, 18)}... ` +
1329
+ `cumulative=${buyerAuth.cumulativeAmount} (was ${current?.cumulativeAmount ?? 0n})`);
1330
+ }
1046
1331
  // ── CloseRequested handling ───────────────────────────────────
1047
1332
  /**
1048
1333
  * Handle a CloseRequested event for a channel this seller manages.
@@ -1054,15 +1339,14 @@ export class SellerPaymentManager {
1054
1339
  if (accepted > 0n) {
1055
1340
  const { amount, metadata, sig } = this._getSettleParams(channelId);
1056
1341
  debugLog(`[SellerPayment] CloseRequested for channel ${channelId.slice(0, 18)}... — closing with cumulative=${amount}`);
1057
- try {
1058
- await this._channelsClient.close(this._signer, channelId, amount, metadata, sig);
1059
- this._channelStore.updateChannelStatus(channelId, CHANNEL_STATUS.SETTLED, amount.toString());
1060
- debugLog(`[SellerPayment] Channel ${channelId.slice(0, 18)}... closed successfully after CloseRequested`);
1061
- }
1062
- catch (err) {
1063
- debugWarn(`[SellerPayment] Failed to close channel ${channelId.slice(0, 18)}... after CloseRequested: ${err instanceof Error ? err.message : err}`);
1342
+ const closeResult = await this._submitClose(channelId, amount, () => this._channelsClient.close(this._signer, channelId, amount, metadata, sig));
1343
+ if (!closeResult.closed) {
1344
+ debugWarn(`[SellerPayment] Failed to close channel ${channelId.slice(0, 18)}... ` +
1345
+ `after CloseRequested: ${this._formatError(closeResult.error)}`);
1064
1346
  return;
1065
1347
  }
1348
+ this._channelStore.updateChannelStatus(channelId, CHANNEL_STATUS.SETTLED, closeResult.amount.toString());
1349
+ debugLog(`[SellerPayment] Channel ${channelId.slice(0, 18)}... closed successfully after CloseRequested`);
1066
1350
  }
1067
1351
  else {
1068
1352
  // No voucher — seller can't claim anything. Clean up locally;
@@ -1070,23 +1354,8 @@ export class SellerPaymentManager {
1070
1354
  debugLog(`[SellerPayment] CloseRequested for channel ${channelId.slice(0, 18)}... — no SpendingAuth, cleaning up locally`);
1071
1355
  this._channelStore.updateChannelStatus(channelId, CHANNEL_STATUS.TIMEOUT);
1072
1356
  }
1073
- // Clean up in-memory state
1074
- this._acceptedCumulative.delete(channelId);
1075
- this._spent.delete(channelId);
1076
- this._latestAuth.delete(channelId);
1077
- this._closeRetryCount.delete(channelId);
1078
- this._closingChannels.delete(channelId);
1079
- this._reserveMax.delete(channelId);
1080
- this._pendingTopUp.delete(channelId);
1081
- this._blockedChannels.delete(channelId);
1082
- this._lastSettledCumulative.delete(channelId);
1083
- this._hydratedChannelIds.delete(channelId);
1084
- this._releaseAcceptedWaiters(channelId);
1085
- // Find and remove buyer from active set
1086
- const channel = this._channelStore.getChannel(channelId);
1087
- if (channel) {
1088
- this._activeBuyers.delete(channel.peerId);
1089
- }
1357
+ // Clean up in-memory state, including removing the buyer from the active set
1358
+ this._forgetChannel(channelId, this._channelStore.getChannel(channelId)?.peerId ?? '');
1090
1359
  }
1091
1360
  /**
1092
1361
  * Poll for CloseRequested events and handle any that match active channels.