@antseed/node 0.2.129 → 0.2.130
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/billing/index.d.ts +1 -1
- package/dist/billing/index.d.ts.map +1 -1
- package/dist/billing/index.js +1 -1
- package/dist/billing/index.js.map +1 -1
- package/dist/discovery/metadata-codec.d.ts.map +1 -1
- package/dist/discovery/metadata-codec.js +95 -4
- package/dist/discovery/metadata-codec.js.map +1 -1
- package/dist/discovery/metadata-validator.d.ts.map +1 -1
- package/dist/discovery/metadata-validator.js +6 -4
- package/dist/discovery/metadata-validator.js.map +1 -1
- package/dist/discovery/peer-metadata.d.ts +1 -1
- package/dist/discovery/peer-metadata.d.ts.map +1 -1
- package/dist/discovery/peer-metadata.js +1 -1
- package/dist/discovery/peer-metadata.js.map +1 -1
- package/dist/discovery/service-catalog.d.ts +6 -2
- package/dist/discovery/service-catalog.d.ts.map +1 -1
- package/dist/discovery/service-catalog.js +13 -8
- package/dist/discovery/service-catalog.js.map +1 -1
- package/dist/health/model-health-checker.d.ts +1 -1
- package/dist/health/model-health-checker.d.ts.map +1 -1
- package/dist/health/model-health-checker.js +4 -3
- package/dist/health/model-health-checker.js.map +1 -1
- package/dist/index.d.ts +3 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -1
- package/dist/index.js.map +1 -1
- package/dist/interfaces/seller-provider.d.ts +4 -2
- package/dist/interfaces/seller-provider.d.ts.map +1 -1
- package/dist/node.d.ts +7 -4
- package/dist/node.d.ts.map +1 -1
- package/dist/node.js +34 -7
- package/dist/node.js.map +1 -1
- package/dist/payments/channel-store.d.ts +4 -0
- package/dist/payments/channel-store.d.ts.map +1 -1
- package/dist/payments/channel-store.js +53 -6
- package/dist/payments/channel-store.js.map +1 -1
- package/dist/payments/seller-payment-manager.d.ts +60 -1
- package/dist/payments/seller-payment-manager.d.ts.map +1 -1
- package/dist/payments/seller-payment-manager.js +399 -10
- package/dist/payments/seller-payment-manager.js.map +1 -1
- package/dist/resources/resource-ownership-store.d.ts +30 -0
- package/dist/resources/resource-ownership-store.d.ts.map +1 -0
- package/dist/resources/resource-ownership-store.js +72 -0
- package/dist/resources/resource-ownership-store.js.map +1 -0
- package/dist/seller-request-handler.d.ts +34 -0
- package/dist/seller-request-handler.d.ts.map +1 -1
- package/dist/seller-request-handler.js +322 -56
- package/dist/seller-request-handler.js.map +1 -1
- package/dist/storage/migrations/channels/006_add_video_payments.d.ts +3 -0
- package/dist/storage/migrations/channels/006_add_video_payments.d.ts.map +1 -0
- package/dist/storage/migrations/channels/006_add_video_payments.js +24 -0
- package/dist/storage/migrations/channels/006_add_video_payments.js.map +1 -0
- package/dist/storage/migrations/channels/index.d.ts.map +1 -1
- package/dist/storage/migrations/channels/index.js +2 -1
- package/dist/storage/migrations/channels/index.js.map +1 -1
- package/dist/storage/migrations/metering/003_create_resource_tables.d.ts +3 -0
- package/dist/storage/migrations/metering/003_create_resource_tables.d.ts.map +1 -0
- package/dist/storage/migrations/metering/003_create_resource_tables.js +34 -0
- package/dist/storage/migrations/metering/003_create_resource_tables.js.map +1 -0
- package/dist/storage/migrations/metering/index.d.ts.map +1 -1
- package/dist/storage/migrations/metering/index.js +2 -1
- package/dist/storage/migrations/metering/index.js.map +1 -1
- package/dist/verification/samples.d.ts.map +1 -1
- package/dist/verification/samples.js +2 -0
- package/dist/verification/samples.js.map +1 -1
- 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.
|
|
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
|
-
|
|
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 =
|
|
406
|
+
result = isOneOff
|
|
407
|
+
? await this._handleOneOffSpendingAuth(buyerPeerId, payload, paymentMux)
|
|
408
|
+
: await this._handleSpendingAuthInner(buyerPeerId, payload, paymentMux, disconnectMarker);
|
|
370
409
|
});
|
|
371
|
-
|
|
372
|
-
|
|
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
|
-
|
|
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
|