@antseed/node 0.2.129 → 0.2.131

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 (66) hide show
  1. package/dist/billing/index.d.ts +1 -1
  2. package/dist/billing/index.d.ts.map +1 -1
  3. package/dist/billing/index.js +1 -1
  4. package/dist/billing/index.js.map +1 -1
  5. package/dist/discovery/metadata-codec.d.ts.map +1 -1
  6. package/dist/discovery/metadata-codec.js +95 -4
  7. package/dist/discovery/metadata-codec.js.map +1 -1
  8. package/dist/discovery/metadata-validator.d.ts.map +1 -1
  9. package/dist/discovery/metadata-validator.js +6 -4
  10. package/dist/discovery/metadata-validator.js.map +1 -1
  11. package/dist/discovery/peer-metadata.d.ts +1 -1
  12. package/dist/discovery/peer-metadata.d.ts.map +1 -1
  13. package/dist/discovery/peer-metadata.js +1 -1
  14. package/dist/discovery/peer-metadata.js.map +1 -1
  15. package/dist/discovery/service-catalog.d.ts +6 -2
  16. package/dist/discovery/service-catalog.d.ts.map +1 -1
  17. package/dist/discovery/service-catalog.js +13 -8
  18. package/dist/discovery/service-catalog.js.map +1 -1
  19. package/dist/health/model-health-checker.d.ts +1 -1
  20. package/dist/health/model-health-checker.d.ts.map +1 -1
  21. package/dist/health/model-health-checker.js +4 -3
  22. package/dist/health/model-health-checker.js.map +1 -1
  23. package/dist/index.d.ts +3 -1
  24. package/dist/index.d.ts.map +1 -1
  25. package/dist/index.js +3 -1
  26. package/dist/index.js.map +1 -1
  27. package/dist/interfaces/seller-provider.d.ts +4 -2
  28. package/dist/interfaces/seller-provider.d.ts.map +1 -1
  29. package/dist/node.d.ts +7 -4
  30. package/dist/node.d.ts.map +1 -1
  31. package/dist/node.js +34 -7
  32. package/dist/node.js.map +1 -1
  33. package/dist/payments/channel-store.d.ts +4 -0
  34. package/dist/payments/channel-store.d.ts.map +1 -1
  35. package/dist/payments/channel-store.js +53 -6
  36. package/dist/payments/channel-store.js.map +1 -1
  37. package/dist/payments/seller-payment-manager.d.ts +60 -1
  38. package/dist/payments/seller-payment-manager.d.ts.map +1 -1
  39. package/dist/payments/seller-payment-manager.js +399 -10
  40. package/dist/payments/seller-payment-manager.js.map +1 -1
  41. package/dist/resources/resource-ownership-store.d.ts +30 -0
  42. package/dist/resources/resource-ownership-store.d.ts.map +1 -0
  43. package/dist/resources/resource-ownership-store.js +72 -0
  44. package/dist/resources/resource-ownership-store.js.map +1 -0
  45. package/dist/seller-request-handler.d.ts +34 -0
  46. package/dist/seller-request-handler.d.ts.map +1 -1
  47. package/dist/seller-request-handler.js +322 -56
  48. package/dist/seller-request-handler.js.map +1 -1
  49. package/dist/storage/migrations/channels/006_add_video_payments.d.ts +3 -0
  50. package/dist/storage/migrations/channels/006_add_video_payments.d.ts.map +1 -0
  51. package/dist/storage/migrations/channels/006_add_video_payments.js +24 -0
  52. package/dist/storage/migrations/channels/006_add_video_payments.js.map +1 -0
  53. package/dist/storage/migrations/channels/index.d.ts.map +1 -1
  54. package/dist/storage/migrations/channels/index.js +2 -1
  55. package/dist/storage/migrations/channels/index.js.map +1 -1
  56. package/dist/storage/migrations/metering/003_create_resource_tables.d.ts +3 -0
  57. package/dist/storage/migrations/metering/003_create_resource_tables.d.ts.map +1 -0
  58. package/dist/storage/migrations/metering/003_create_resource_tables.js +34 -0
  59. package/dist/storage/migrations/metering/003_create_resource_tables.js.map +1 -0
  60. package/dist/storage/migrations/metering/index.d.ts.map +1 -1
  61. package/dist/storage/migrations/metering/index.js +2 -1
  62. package/dist/storage/migrations/metering/index.js.map +1 -1
  63. package/dist/verification/samples.d.ts.map +1 -1
  64. package/dist/verification/samples.js +2 -0
  65. package/dist/verification/samples.js.map +1 -1
  66. package/package.json +4 -4
@@ -3,7 +3,7 @@ import { ChannelsClient } from './evm/channels-client.js';
3
3
  import { SPENDING_AUTH_TYPES, RESERVE_AUTH_TYPES, makeChannelsDomain, encodeMetadata, ZERO_METADATA, } from './evm/signatures.js';
4
4
  import { debugLog, debugWarn } from '../utils/debug.js';
5
5
  import { peerIdToAddress } from '../types/peer.js';
6
- import { CHANNEL_ROLE, CHANNEL_STATUS } from './channel-store.js';
6
+ import { CHANNEL_KIND, CHANNEL_ROLE, CHANNEL_STATUS } from './channel-store.js';
7
7
  import { classifyOnChainChannel, matchesChannelParties } from './channel-session-state.js';
8
8
  /** Default minimum budget per request: $0.50 USDC (base units). */
9
9
  const DEFAULT_MIN_BUDGET_PER_REQUEST = '500000';
@@ -24,6 +24,15 @@ const INSUFFICIENT_BALANCE_SELECTOR = '0xf4d678b8';
24
24
  const IN_FLIGHT_TX_LIMIT_PHRASE = 'in-flight transaction limit';
25
25
  /** Backoff stays well inside the buyer's 30-second AuthAck timeout. */
26
26
  const RESERVE_BACKPRESSURE_RETRY_DELAYS_MS = [250, 500, 1_000, 2_000, 4_000];
27
+ /** How long a one-off plan offered in a 402 stays valid for the buyer's opening auth. */
28
+ const ONE_OFF_PLAN_TTL_MS = 2 * 60_000;
29
+ /**
30
+ * A one-off channel whose request was never paid is closed after this long,
31
+ * releasing the buyer's reserve. Long enough to finish and download a video.
32
+ */
33
+ export const ONE_OFF_CHANNEL_TTL_MS = 24 * 60 * 60_000;
34
+ /** A one-off channel whose request never started is closed after this long. */
35
+ export const ONE_OFF_UNCLAIMED_TTL_MS = 10 * 60_000;
27
36
  /**
28
37
  * Manages seller-side payment sessions.
29
38
  * The buyer sends a single SpendingAuth signature with a monotonically
@@ -97,6 +106,14 @@ export class SellerPaymentManager {
97
106
  * process. Lets the idle-settle loop skip the `getSession` RPC when the
98
107
  * local accepted cumulative hasn't moved since our last settle. */
99
108
  _lastSettledCumulative = new Map();
109
+ /** buyerPeerId + requestId -> one-off channel terms offered in a 402. */
110
+ _pendingOneOffPlans = new Map();
111
+ /**
112
+ * Active one-off channels. They share the channel-keyed maps above but are
113
+ * never a buyer's session channel: they are not in `_activeBuyers`, are not
114
+ * closed on disconnect, and never supersede the buyer's session channel.
115
+ */
116
+ _oneOffChannelIds = new Set();
100
117
  _minSettleDelta;
101
118
  _serveWhileClosePending;
102
119
  /** Max close() retries before giving up (buyer must requestClose on-chain) */
@@ -150,6 +167,20 @@ export class SellerPaymentManager {
150
167
  });
151
168
  }
152
169
  }
170
+ for (const channel of this._channelStore.getActiveChannels(CHANNEL_ROLE.SELLER, CHANNEL_KIND.ONE_OFF)) {
171
+ this._oneOffChannelIds.add(channel.sessionId);
172
+ this._acceptedCumulative.set(channel.sessionId, BigInt(channel.authMax || '0'));
173
+ this._spent.set(channel.sessionId, BigInt(channel.tokensDelivered || '0'));
174
+ this._reserveMax.set(channel.sessionId, BigInt(channel.previousConsumption || '0'));
175
+ if (channel.latestSpendingAuthSig) {
176
+ this._latestAuth.set(channel.sessionId, {
177
+ spendingAuthSig: channel.latestSpendingAuthSig,
178
+ cumulativeAmount: BigInt(channel.authMax),
179
+ metadataHash: '',
180
+ metadata: channel.latestMetadata ?? '',
181
+ });
182
+ }
183
+ }
153
184
  }
154
185
  /**
155
186
  * Validate hydrated channels against on-chain state.
@@ -157,6 +188,7 @@ export class SellerPaymentManager {
157
188
  * Must be called after construction (async, cannot run in constructor).
158
189
  */
159
190
  async validateHydratedChannels() {
191
+ await this._checkOneOffChannels();
160
192
  const activeChannels = this._channelStore.getActiveChannels(CHANNEL_ROLE.SELLER);
161
193
  if (activeChannels.length === 0)
162
194
  return;
@@ -221,7 +253,8 @@ export class SellerPaymentManager {
221
253
  this._blockedChannels.delete(channelId);
222
254
  this._lastSettledCumulative.delete(channelId);
223
255
  this._releaseAcceptedWaiters(channelId);
224
- this._deactivateBuyerForChannel(peerId, channelId);
256
+ if (!this._oneOffChannelIds.delete(channelId))
257
+ this._deactivateBuyerForChannel(peerId, channelId);
225
258
  debugLog(`[SellerPayment] Evicted stale channel ${channelId.slice(0, 18)}... — ${reason}`);
226
259
  }
227
260
  /** Remove connectivity only if it still belongs to the channel being cleaned up. */
@@ -362,14 +395,27 @@ export class SellerPaymentManager {
362
395
  */
363
396
  async handleSpendingAuth(buyerPeerId, payload, paymentMux) {
364
397
  const disconnectMarker = this._buyerDisconnectMarkers.get(buyerPeerId);
365
- // Per-buyer mutex: serialize concurrent auths for the same buyer
366
- const existing = this._buyerLocks.get(buyerPeerId);
398
+ // Per-buyer mutex: serialize concurrent auths for the same buyer. A one-off
399
+ // channel gets its own lock so opening it (reserve + topUp on-chain) never
400
+ // delays the buyer's session-channel auths.
401
+ const isOneOff = payload.oneOffRequestId != null || this._oneOffChannelIds.has(payload.channelId);
402
+ const lockKey = isOneOff ? `${buyerPeerId}\n${payload.channelId.toLowerCase()}` : buyerPeerId;
403
+ const existing = this._buyerLocks.get(lockKey);
367
404
  let result = 'rejected';
368
405
  const lock = (existing ?? Promise.resolve()).then(async () => {
369
- result = await this._handleSpendingAuthInner(buyerPeerId, payload, paymentMux, disconnectMarker);
406
+ result = isOneOff
407
+ ? await this._handleOneOffSpendingAuth(buyerPeerId, payload, paymentMux)
408
+ : await this._handleSpendingAuthInner(buyerPeerId, payload, paymentMux, disconnectMarker);
370
409
  });
371
- this._buyerLocks.set(buyerPeerId, lock.catch(() => { }));
372
- await lock;
410
+ const tail = lock.catch(() => { });
411
+ this._buyerLocks.set(lockKey, tail);
412
+ try {
413
+ await lock;
414
+ }
415
+ finally {
416
+ if (isOneOff && this._buyerLocks.get(lockKey) === tail)
417
+ this._buyerLocks.delete(lockKey);
418
+ }
373
419
  return result;
374
420
  }
375
421
  /**
@@ -627,6 +673,336 @@ export class SellerPaymentManager {
627
673
  return 'rejected';
628
674
  }
629
675
  }
676
+ /**
677
+ * Verify a reserve batch: a threshold SpendingAuth within the opening
678
+ * reserve plus a ReserveAuth for a larger final ceiling, both signed by the
679
+ * buyer. Returns the SpendingAuth to submit with topUp(), or null.
680
+ */
681
+ _verifyReserveBatch(channelId, batch, openingReserve, buyerEvmAddr, channelsDomain) {
682
+ const cumulativeAmount = BigInt(batch.cumulativeAmount);
683
+ const finalReserveMax = BigInt(batch.maxAmount);
684
+ if (cumulativeAmount <= 0n
685
+ || cumulativeAmount > openingReserve
686
+ || finalReserveMax <= openingReserve
687
+ || keccak256(batch.metadata) !== batch.metadataHash) {
688
+ debugWarn(`[SellerPayment] Invalid reserve batch for channel ${channelId.slice(0, 18)}...`);
689
+ return null;
690
+ }
691
+ const spendingRecovered = verifyTypedData(channelsDomain, SPENDING_AUTH_TYPES, {
692
+ channelId,
693
+ cumulativeAmount,
694
+ metadataHash: batch.metadataHash,
695
+ }, batch.spendingAuthSig);
696
+ const topUpRecovered = verifyTypedData(channelsDomain, RESERVE_AUTH_TYPES, {
697
+ channelId,
698
+ maxAmount: finalReserveMax,
699
+ deadline: BigInt(batch.deadline),
700
+ }, batch.reserveAuthSig);
701
+ if (spendingRecovered.toLowerCase() !== buyerEvmAddr.toLowerCase()
702
+ || topUpRecovered.toLowerCase() !== buyerEvmAddr.toLowerCase()) {
703
+ debugWarn(`[SellerPayment] Invalid reserve batch signatures for channel ${channelId.slice(0, 18)}...`);
704
+ return null;
705
+ }
706
+ return {
707
+ spendingAuthSig: batch.spendingAuthSig,
708
+ cumulativeAmount,
709
+ metadataHash: batch.metadataHash,
710
+ metadata: batch.metadata,
711
+ };
712
+ }
713
+ // ── One-off channels ──────────────────────────────────────────
714
+ /**
715
+ * Offer a one-off channel for a single request (a video create). The plan is
716
+ * what the buyer must sign; the opening SpendingAuth is only accepted when it
717
+ * matches a plan this seller offered for the same buyer and requestId.
718
+ */
719
+ registerOneOffPlan(buyerPeerId, requestId, plan) {
720
+ const now = Date.now();
721
+ for (const [key, pending] of this._pendingOneOffPlans) {
722
+ if (pending.expiresAt <= now)
723
+ this._pendingOneOffPlans.delete(key);
724
+ }
725
+ this._pendingOneOffPlans.set(oneOffPlanKey(buyerPeerId, requestId), {
726
+ openingReserveAmount: BigInt(plan.openingReserveAmount),
727
+ requiredCumulativeAmount: BigInt(plan.requiredCumulativeAmount),
728
+ requestCost: BigInt(plan.requestCost),
729
+ expiresAt: now + ONE_OFF_PLAN_TTL_MS,
730
+ });
731
+ }
732
+ /** The active one-off channel opened for this buyer's request, or null. */
733
+ getOneOffChannelForRequest(buyerPeerId, requestId) {
734
+ const channel = this._channelStore.getOneOffChannelByRequest(buyerPeerId, CHANNEL_ROLE.SELLER, requestId);
735
+ if (!channel || channel.status !== CHANNEL_STATUS.ACTIVE || !this._oneOffChannelIds.has(channel.sessionId))
736
+ return null;
737
+ return channel;
738
+ }
739
+ /** Any channel this seller tracks, active or not. */
740
+ getChannel(channelId) {
741
+ return this._channelStore.getChannel(channelId);
742
+ }
743
+ isOneOffChannel(channelId) {
744
+ return this._oneOffChannelIds.has(channelId);
745
+ }
746
+ /**
747
+ * Mark the one-off channel's single request as started. Returns false when
748
+ * it already ran, so a resent request cannot start a second job on it.
749
+ */
750
+ claimOneOffChannel(channelId) {
751
+ const channel = this._channelStore.getChannel(channelId);
752
+ if (!channel || !this._oneOffChannelIds.has(channelId) || channel.status !== CHANNEL_STATUS.ACTIVE)
753
+ return false;
754
+ if (channel.requestCount > 0)
755
+ return false;
756
+ this._channelStore.upsertChannel({ ...channel, requestCount: 1, updatedAt: Date.now() });
757
+ return true;
758
+ }
759
+ /**
760
+ * Close a one-off channel with the buyer's latest SpendingAuth (or the
761
+ * already-settled amount when there is none), releasing the rest of the
762
+ * reserve to the buyer. Used once the request is paid, and when it failed
763
+ * or was abandoned.
764
+ */
765
+ async closeOneOffChannel(channelId, reason) {
766
+ if (!this._oneOffChannelIds.has(channelId))
767
+ return false;
768
+ const channel = this._channelStore.getChannel(channelId);
769
+ if (!channel)
770
+ return false;
771
+ const retries = this._closeRetryCount.get(channelId) ?? 0;
772
+ if (retries >= SellerPaymentManager.MAX_CLOSE_RETRIES) {
773
+ debugWarn(`[SellerPayment] One-off close failed ${retries} times for ${channelId.slice(0, 18)}... — leaving it to the buyer's requestClose()`);
774
+ this._channelStore.updateChannelStatus(channelId, CHANNEL_STATUS.TIMEOUT);
775
+ this._forgetChannel(channelId, channel.peerId);
776
+ return false;
777
+ }
778
+ const { amount, metadata, sig } = this._getSettleParams(channelId);
779
+ debugLog(`[SellerPayment] Closing one-off channel ${channelId.slice(0, 18)}... at ${amount} (${reason})`);
780
+ const closeResult = await this._submitClose(channelId, amount, () => this._channelsClient.close(this._signer, channelId, amount, metadata, sig));
781
+ if (!closeResult.closed) {
782
+ if (!this._isRetryableTxSubmissionFailure(closeResult.error))
783
+ this._closeRetryCount.set(channelId, retries + 1);
784
+ debugWarn(`[SellerPayment] Failed to close one-off channel ${channelId.slice(0, 18)}...: ${this._formatError(closeResult.error)}`);
785
+ return false;
786
+ }
787
+ this._channelStore.updateChannelStatus(channelId, CHANNEL_STATUS.SETTLED, closeResult.amount.toString());
788
+ this._closeRetryCount.delete(channelId);
789
+ this._forgetChannel(channelId, channel.peerId);
790
+ return true;
791
+ }
792
+ async _handleOneOffSpendingAuth(buyerPeerId, payload, paymentMux) {
793
+ try {
794
+ if (!this._metadataMatchesHash(payload)) {
795
+ debugWarn(`[SellerPayment] Rejecting one-off SpendingAuth: metadataHash mismatch channel=${payload.channelId.slice(0, 18)}...`);
796
+ return 'rejected';
797
+ }
798
+ const { channels: channelsAddr } = await this._resolvedAddresses;
799
+ const channelsDomain = makeChannelsDomain(this._config.chainId, channelsAddr);
800
+ if (this._oneOffChannelIds.has(payload.channelId)) {
801
+ return await this._acceptOneOffSpendingAuth(buyerPeerId, payload, channelsDomain);
802
+ }
803
+ return await this._openOneOffChannel(buyerPeerId, payload, paymentMux, channelsDomain);
804
+ }
805
+ catch (err) {
806
+ debugWarn(`[SellerPayment] Failed to process one-off SpendingAuth: ${this._formatError(err)}`);
807
+ return 'rejected';
808
+ }
809
+ }
810
+ /**
811
+ * Open a one-off channel: reserve() the opening amount and, for a price
812
+ * above it, topUp() to the full price with the buyer's threshold auth in the
813
+ * same flow. Never touches the buyer's session channel.
814
+ */
815
+ async _openOneOffChannel(buyerPeerId, payload, paymentMux, channelsDomain) {
816
+ const requestId = payload.oneOffRequestId;
817
+ const channelId = payload.channelId;
818
+ const buyerEvmAddr = peerIdToAddress(buyerPeerId);
819
+ if (!requestId || !payload.reserveSalt || !payload.reserveMaxAmount || payload.reserveDeadline == null) {
820
+ debugWarn(`[SellerPayment] Rejecting one-off auth without reserve fields for ${channelId.slice(0, 18)}...`);
821
+ return 'rejected';
822
+ }
823
+ const planKey = oneOffPlanKey(buyerPeerId, requestId);
824
+ const plan = this._pendingOneOffPlans.get(planKey);
825
+ if (!plan || plan.expiresAt <= Date.now()) {
826
+ debugWarn(`[SellerPayment] Rejecting one-off channel ${channelId.slice(0, 18)}...: no offered plan for request ${requestId}`);
827
+ return 'rejected';
828
+ }
829
+ if (this._channelStore.getOneOffChannelByRequest(buyerPeerId, CHANNEL_ROLE.SELLER, requestId)?.status === CHANNEL_STATUS.ACTIVE) {
830
+ debugWarn(`[SellerPayment] Rejecting one-off channel ${channelId.slice(0, 18)}...: request ${requestId} already has one`);
831
+ return 'rejected';
832
+ }
833
+ const openingReserve = BigInt(payload.reserveMaxAmount);
834
+ const reserveDeadline = payload.reserveDeadline;
835
+ if (openingReserve !== plan.openingReserveAmount) {
836
+ debugWarn(`[SellerPayment] One-off opening reserve ${openingReserve} does not match plan ${plan.openingReserveAmount}`);
837
+ return 'rejected';
838
+ }
839
+ const reserveRecovered = verifyTypedData(channelsDomain, RESERVE_AUTH_TYPES, {
840
+ channelId,
841
+ maxAmount: openingReserve,
842
+ deadline: BigInt(reserveDeadline),
843
+ }, payload.spendingAuthSig);
844
+ if (reserveRecovered.toLowerCase() !== buyerEvmAddr.toLowerCase()) {
845
+ debugWarn(`[SellerPayment] Invalid one-off ReserveAuth signature: recovered=${reserveRecovered} expected=${buyerEvmAddr}`);
846
+ return 'rejected';
847
+ }
848
+ const batch = payload.reserveBatch;
849
+ let latestAuth = { spendingAuthSig: '', cumulativeAmount: 0n, metadataHash: payload.metadataHash, metadata: payload.metadata };
850
+ let finalReserve = openingReserve;
851
+ let finalDeadline = reserveDeadline;
852
+ if (plan.requiredCumulativeAmount > 0n) {
853
+ if (!batch) {
854
+ debugWarn(`[SellerPayment] One-off channel ${channelId.slice(0, 18)}... needs a reserve batch`);
855
+ return 'rejected';
856
+ }
857
+ const verified = this._verifyReserveBatch(channelId, batch, openingReserve, buyerEvmAddr, channelsDomain);
858
+ if (!verified
859
+ || verified.cumulativeAmount !== plan.requiredCumulativeAmount
860
+ || BigInt(batch.maxAmount) !== plan.requestCost) {
861
+ debugWarn(`[SellerPayment] One-off reserve batch does not match plan for ${channelId.slice(0, 18)}...`);
862
+ return 'rejected';
863
+ }
864
+ latestAuth = verified;
865
+ finalReserve = plan.requestCost;
866
+ finalDeadline = batch.deadline;
867
+ }
868
+ else if (batch || openingReserve !== plan.requestCost) {
869
+ debugWarn(`[SellerPayment] Unexpected one-off reserve batch for ${channelId.slice(0, 18)}...`);
870
+ return 'rejected';
871
+ }
872
+ this._pendingOneOffPlans.delete(planKey);
873
+ await this._reserveWithBackpressureRetry(channelId, () => this._channelsClient.reserve(this._signer, buyerEvmAddr, payload.reserveSalt, openingReserve, BigInt(reserveDeadline), payload.spendingAuthSig));
874
+ if (batch) {
875
+ try {
876
+ await this._channelsClient.topUp(this._signer, channelId, latestAuth.cumulativeAmount, batch.metadata, batch.spendingAuthSig, finalReserve, BigInt(finalDeadline), batch.reserveAuthSig);
877
+ }
878
+ catch (err) {
879
+ try {
880
+ await this._channelsClient.close(this._signer, channelId, 0n, encodeMetadata(ZERO_METADATA), '0x');
881
+ }
882
+ catch (closeErr) {
883
+ debugWarn(`[SellerPayment] Failed to release one-off reserve after top-up failure: ${this._formatError(closeErr)}`);
884
+ }
885
+ throw err;
886
+ }
887
+ }
888
+ const now = Date.now();
889
+ const { seller: sellerEvmAddr } = await this._resolvedAddresses;
890
+ const session = {
891
+ sessionId: channelId,
892
+ peerId: buyerPeerId,
893
+ role: CHANNEL_ROLE.SELLER,
894
+ channelKind: CHANNEL_KIND.ONE_OFF,
895
+ oneOffRequestId: requestId,
896
+ sellerEvmAddr,
897
+ buyerEvmAddr,
898
+ nonce: 0,
899
+ authMax: latestAuth.cumulativeAmount.toString(),
900
+ previousConsumption: finalReserve.toString(), // repurposed: stores reserveMax
901
+ deadline: finalDeadline,
902
+ previousSessionId: '',
903
+ tokensDelivered: '0',
904
+ requestCount: 0,
905
+ reservedAt: now,
906
+ settledAt: null,
907
+ settledAmount: null,
908
+ status: CHANNEL_STATUS.ACTIVE,
909
+ latestBuyerSig: latestAuth.spendingAuthSig || payload.spendingAuthSig,
910
+ latestSpendingAuthSig: latestAuth.spendingAuthSig || null,
911
+ latestMetadata: latestAuth.metadata,
912
+ createdAt: now,
913
+ updatedAt: now,
914
+ };
915
+ this._channelStore.upsertChannel(session);
916
+ this._oneOffChannelIds.add(channelId);
917
+ this._acceptedCumulative.set(channelId, latestAuth.cumulativeAmount);
918
+ this._reserveMax.set(channelId, finalReserve);
919
+ this._spent.set(channelId, 0n);
920
+ this._latestAuth.set(channelId, latestAuth);
921
+ if (latestAuth.cumulativeAmount > 0n)
922
+ this._lastSettledCumulative.set(channelId, latestAuth.cumulativeAmount);
923
+ paymentMux.sendAuthAck({ channelId });
924
+ debugLog(`[SellerPayment] One-off channel ${channelId.slice(0, 18)}... opened for request ${requestId} (reserve=${finalReserve}, settled=${latestAuth.cumulativeAmount})`);
925
+ return 'reserved';
926
+ }
927
+ /**
928
+ * Accept a later SpendingAuth on a one-off channel. Once it covers the
929
+ * recorded charge, the request is paid and the channel is closed at once.
930
+ */
931
+ async _acceptOneOffSpendingAuth(buyerPeerId, payload, channelsDomain) {
932
+ const channelId = payload.channelId;
933
+ const cumulativeAmount = BigInt(payload.cumulativeAmount);
934
+ const channel = this._channelStore.getChannel(channelId);
935
+ if (!channel || channel.peerId !== buyerPeerId || channel.status !== CHANNEL_STATUS.ACTIVE)
936
+ return 'rejected';
937
+ const recovered = verifyTypedData(channelsDomain, SPENDING_AUTH_TYPES, {
938
+ channelId,
939
+ cumulativeAmount,
940
+ metadataHash: payload.metadataHash,
941
+ }, payload.spendingAuthSig);
942
+ if (recovered.toLowerCase() !== peerIdToAddress(buyerPeerId).toLowerCase()) {
943
+ debugWarn(`[SellerPayment] Invalid one-off SpendingAuth signature for ${channelId.slice(0, 18)}...`);
944
+ return 'rejected';
945
+ }
946
+ const accepted = this._acceptedCumulative.get(channelId) ?? 0n;
947
+ const spent = this._spent.get(channelId) ?? 0n;
948
+ const reserveMax = this._reserveMax.get(channelId) ?? 0n;
949
+ if (cumulativeAmount < accepted || cumulativeAmount < spent || cumulativeAmount > reserveMax) {
950
+ debugWarn(`[SellerPayment] Rejecting one-off SpendingAuth cumulative=${cumulativeAmount} (accepted=${accepted} spent=${spent} reserve=${reserveMax})`);
951
+ return 'rejected';
952
+ }
953
+ if (cumulativeAmount > accepted) {
954
+ const auth = {
955
+ spendingAuthSig: payload.spendingAuthSig,
956
+ cumulativeAmount,
957
+ metadataHash: payload.metadataHash,
958
+ metadata: payload.metadata,
959
+ };
960
+ this._acceptedCumulative.set(channelId, cumulativeAmount);
961
+ this._latestAuth.set(channelId, auth);
962
+ this._channelStore.upsertChannel({
963
+ ...channel,
964
+ authMax: cumulativeAmount.toString(),
965
+ latestBuyerSig: payload.spendingAuthSig,
966
+ latestSpendingAuthSig: payload.spendingAuthSig,
967
+ latestMetadata: payload.metadata,
968
+ updatedAt: Date.now(),
969
+ });
970
+ this._notifyAcceptedUpdate(channelId, cumulativeAmount);
971
+ }
972
+ if (spent > 0n && cumulativeAmount >= spent) {
973
+ await this.closeOneOffChannel(channelId, 'request paid');
974
+ }
975
+ return 'accepted';
976
+ }
977
+ /** Close one-off channels that are gone on-chain, unpaid past their TTL, or never used. */
978
+ async _checkOneOffChannels() {
979
+ const now = Date.now();
980
+ for (const channel of this._channelStore.getActiveChannels(CHANNEL_ROLE.SELLER, CHANNEL_KIND.ONE_OFF)) {
981
+ try {
982
+ const state = classifyOnChainChannel(await this._channelsClient.getSession(channel.sessionId));
983
+ if (!state.exists || (state.status !== 'active' && state.status !== 'unknown')) {
984
+ this._evictStaleChannel(channel.sessionId, channel.peerId, `one-off on-chain status=${state.exists ? state.status : 'missing'}`);
985
+ continue;
986
+ }
987
+ this._oneOffChannelIds.add(channel.sessionId);
988
+ const age = now - channel.reservedAt;
989
+ const spent = this._spent.get(channel.sessionId) ?? 0n;
990
+ const accepted = this._acceptedCumulative.get(channel.sessionId) ?? 0n;
991
+ if (spent > 0n && accepted >= spent) {
992
+ await this.closeOneOffChannel(channel.sessionId, 'request paid');
993
+ }
994
+ else if (age > ONE_OFF_CHANNEL_TTL_MS) {
995
+ await this.closeOneOffChannel(channel.sessionId, 'unpaid past TTL');
996
+ }
997
+ else if (channel.requestCount === 0 && age > ONE_OFF_UNCLAIMED_TTL_MS) {
998
+ await this.closeOneOffChannel(channel.sessionId, 'request never started');
999
+ }
1000
+ }
1001
+ catch (err) {
1002
+ debugWarn(`[SellerPayment] Failed to check one-off channel ${channel.sessionId.slice(0, 18)}...: ${this._formatError(err)}`);
1003
+ }
1004
+ }
1005
+ }
630
1006
  _canAcknowledgeRetainedChannel(buyerPeerId, channelId, disconnectMarker) {
631
1007
  const session = this._channelStore.getActiveChannelByPeer(buyerPeerId, CHANNEL_ROLE.SELLER);
632
1008
  return session?.sessionId === channelId
@@ -923,8 +1299,10 @@ export class SellerPaymentManager {
923
1299
  }
924
1300
  const newSpent = current + costUsdc;
925
1301
  this._spent.set(sessionId, newSpent);
926
- // Persist spent amount to ChannelStore (using tokensDelivered field)
927
- this._channelStore.updateTokensDelivered(sessionId, newSpent.toString(), 0);
1302
+ // Persist spent amount to ChannelStore (using tokensDelivered field). Keep
1303
+ // the request count: it marks a one-off channel's create as started.
1304
+ const requestCount = this._channelStore.getChannel(sessionId)?.requestCount ?? 0;
1305
+ this._channelStore.updateTokensDelivered(sessionId, newSpent.toString(), requestCount);
928
1306
  }
929
1307
  // ── Settlement ──────────────────────────────────────────────
930
1308
  /** Get the latest SpendingAuth params for a channel, or zero-auth if none exists. */
@@ -1008,6 +1386,14 @@ export class SellerPaymentManager {
1008
1386
  * touch persisted status — callers set that first (SETTLED / TIMEOUT).
1009
1387
  */
1010
1388
  _forgetChannel(channelId, buyerPeerId) {
1389
+ if (this._oneOffChannelIds.delete(channelId)) {
1390
+ this._forgetChannelState(channelId);
1391
+ return;
1392
+ }
1393
+ this._forgetChannelState(channelId);
1394
+ this._deactivateBuyerForChannel(buyerPeerId, channelId);
1395
+ }
1396
+ _forgetChannelState(channelId) {
1011
1397
  this._acceptedCumulative.delete(channelId);
1012
1398
  this._spent.delete(channelId);
1013
1399
  this._latestAuth.delete(channelId);
@@ -1018,7 +1404,6 @@ export class SellerPaymentManager {
1018
1404
  this._lastSettledCumulative.delete(channelId);
1019
1405
  this._hydratedChannelIds.delete(channelId);
1020
1406
  this._releaseAcceptedWaiters(channelId);
1021
- this._deactivateBuyerForChannel(buyerPeerId, channelId);
1022
1407
  }
1023
1408
  // ── In-flight request tracking ────────────────────────────────
1024
1409
  /** Mark a billable request as started for this buyer. */
@@ -1083,6 +1468,7 @@ export class SellerPaymentManager {
1083
1468
  * Called periodically and on startup for recovery.
1084
1469
  */
1085
1470
  async checkTimeouts() {
1471
+ await this._checkOneOffChannels();
1086
1472
  const nowSecs = Math.floor(Date.now() / 1000);
1087
1473
  const activeChannels = this._channelStore.getActiveChannels(CHANNEL_ROLE.SELLER);
1088
1474
  for (const channel of activeChannels) {
@@ -1504,4 +1890,7 @@ export class SellerPaymentManager {
1504
1890
  // ChannelStore is shared with BuyerPaymentManager, closed from node.ts
1505
1891
  }
1506
1892
  }
1893
+ function oneOffPlanKey(buyerPeerId, requestId) {
1894
+ return `${buyerPeerId.toLowerCase()}\n${requestId}`;
1895
+ }
1507
1896
  //# sourceMappingURL=seller-payment-manager.js.map