@antseed/node 0.2.100 → 0.2.102
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/buyer-request-handler.d.ts +2 -0
- package/dist/buyer-request-handler.d.ts.map +1 -1
- package/dist/buyer-request-handler.js +2 -2
- package/dist/buyer-request-handler.js.map +1 -1
- package/dist/discovery/announcer.d.ts +2 -0
- package/dist/discovery/announcer.d.ts.map +1 -1
- package/dist/discovery/announcer.js +8 -2
- package/dist/discovery/announcer.js.map +1 -1
- package/dist/index.d.ts +2 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/interfaces/plugin.d.ts +42 -1
- package/dist/interfaces/plugin.d.ts.map +1 -1
- package/dist/interfaces/plugin.js +1 -1
- package/dist/interfaces/plugin.js.map +1 -1
- package/dist/node.d.ts +25 -1
- package/dist/node.d.ts.map +1 -1
- package/dist/node.js +94 -1
- package/dist/node.js.map +1 -1
- package/dist/p2p/connection-manager.d.ts.map +1 -1
- package/dist/p2p/connection-manager.js +5 -2
- package/dist/p2p/connection-manager.js.map +1 -1
- package/dist/p2p/payment-codec.d.ts +5 -1
- package/dist/p2p/payment-codec.d.ts.map +1 -1
- package/dist/p2p/payment-codec.js +54 -1
- package/dist/p2p/payment-codec.js.map +1 -1
- package/dist/p2p/payment-mux.d.ts +7 -1
- package/dist/p2p/payment-mux.d.ts.map +1 -1
- package/dist/p2p/payment-mux.js +22 -0
- package/dist/p2p/payment-mux.js.map +1 -1
- package/dist/payments/buyer-payment-manager.d.ts +14 -1
- package/dist/payments/buyer-payment-manager.d.ts.map +1 -1
- package/dist/payments/buyer-payment-manager.js +35 -0
- package/dist/payments/buyer-payment-manager.js.map +1 -1
- package/dist/payments/buyer-payment-negotiator.d.ts +18 -0
- package/dist/payments/buyer-payment-negotiator.d.ts.map +1 -1
- package/dist/payments/buyer-payment-negotiator.js +104 -6
- package/dist/payments/buyer-payment-negotiator.js.map +1 -1
- package/dist/payments/seller-payment-manager.d.ts +67 -6
- package/dist/payments/seller-payment-manager.d.ts.map +1 -1
- package/dist/payments/seller-payment-manager.js +340 -71
- package/dist/payments/seller-payment-manager.js.map +1 -1
- package/dist/seller-request-handler.d.ts +4 -0
- package/dist/seller-request-handler.d.ts.map +1 -1
- package/dist/seller-request-handler.js +103 -0
- package/dist/seller-request-handler.js.map +1 -1
- package/dist/types/protocol.d.ts +60 -0
- package/dist/types/protocol.d.ts.map +1 -1
- package/dist/types/protocol.js +24 -0
- package/dist/types/protocol.js.map +1 -1
- package/package.json +2 -2
|
@@ -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
|
-
/**
|
|
71
|
-
|
|
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.
|
|
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
|
-
|
|
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.
|
|
837
|
-
|
|
838
|
-
|
|
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
|
-
|
|
843
|
-
if (this._isRetryableTxSubmissionFailure(
|
|
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(
|
|
894
|
+
`— keeping channel state for retry: ${this._formatError(closeResult.error)}`);
|
|
846
895
|
if (cleanupOnFailure) {
|
|
847
|
-
this.
|
|
896
|
+
this._deactivateBuyerForChannel(buyerPeerId, channelId);
|
|
848
897
|
}
|
|
849
898
|
return;
|
|
850
899
|
}
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
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
|
-
|
|
857
|
-
this.
|
|
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.
|
|
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
|
-
|
|
972
|
-
|
|
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
|
-
|
|
977
|
-
|
|
978
|
-
|
|
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
|
-
|
|
987
|
-
this.
|
|
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
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
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.
|
|
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.
|