mppx 0.13.5 → 0.13.6

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 (48) hide show
  1. package/CHANGELOG.md +27 -0
  2. package/dist/Credential.d.ts +3 -0
  3. package/dist/Credential.js +9 -0
  4. package/dist/cli/cli.js +131 -131
  5. package/dist/cli/plugins/evm.js +2 -2
  6. package/dist/cli/plugins/index.js +11 -11
  7. package/dist/client/internal/MethodChallenge.d.ts +2 -0
  8. package/dist/client/internal/MethodChallenge.js +4 -0
  9. package/dist/internal/version.d.ts +2 -2
  10. package/dist/internal/version.js +1 -1
  11. package/dist/server/Mppx.js +31 -5
  12. package/dist/stripe/server/Methods.js +4 -1
  13. package/dist/tempo/client/Charge.js +1 -0
  14. package/dist/tempo/internal/auto-swap.d.ts +10 -0
  15. package/dist/tempo/internal/auto-swap.js +35 -8
  16. package/dist/tempo/internal/fee-payer.d.ts +8 -0
  17. package/dist/tempo/internal/fee-payer.js +20 -12
  18. package/dist/tempo/legacy/session/Chain.js +8 -2
  19. package/dist/tempo/server/Methods.js +12 -1
  20. package/dist/tempo/server/Relay.js +6 -22
  21. package/dist/tempo/server/Subscription.js +3 -0
  22. package/dist/tempo/server/internal/html.gen.js +1 -1
  23. package/dist/tempo/server/internal/transport.d.ts +3 -0
  24. package/dist/tempo/server/internal/transport.js +5 -1
  25. package/dist/tempo/session/client/ChannelOps.d.ts +2 -0
  26. package/dist/tempo/session/client/ChannelStore.d.ts +12 -1
  27. package/dist/tempo/session/client/ChannelStore.js +47 -0
  28. package/dist/tempo/session/client/CredentialState.js +8 -3
  29. package/dist/tempo/session/client/Session.js +50 -33
  30. package/dist/tempo/session/client/SessionManager.js +186 -65
  31. package/dist/tempo/session/client/Transports.d.ts +6 -0
  32. package/dist/tempo/session/client/Transports.js +52 -7
  33. package/dist/tempo/session/precompile/Chain.js +5 -2
  34. package/dist/tempo/session/server/ChannelStore.d.ts +34 -0
  35. package/dist/tempo/session/server/ChannelStore.js +69 -9
  36. package/dist/tempo/session/server/CredentialVerification.d.ts +7 -1
  37. package/dist/tempo/session/server/CredentialVerification.js +220 -48
  38. package/dist/tempo/session/server/MeteredStream.d.ts +6 -5
  39. package/dist/tempo/session/server/MeteredStream.js +10 -26
  40. package/dist/tempo/session/server/Session.d.ts +29 -2
  41. package/dist/tempo/session/server/Session.js +114 -33
  42. package/dist/tempo/session/server/Settlement.d.ts +9 -0
  43. package/dist/tempo/session/server/Settlement.js +12 -0
  44. package/dist/tempo/session/server/Sse.d.ts +4 -3
  45. package/dist/tempo/session/server/Sse.js +3 -3
  46. package/dist/tempo/session/server/Ws.d.ts +9 -1
  47. package/dist/tempo/session/server/Ws.js +1 -0
  48. package/package.json +1 -1
@@ -22,6 +22,9 @@ export declare function markPrepaidSessionTick(receipt: SessionReceipt): Session
22
22
  * - Fallback to standard HTTP receipt handling for plain Response
23
23
  */
24
24
  export declare function sse(options: sse.Options & {
25
+ beforeCharge?: ((channelId: `0x${string}`) => Promise<void>) | undefined;
26
+ /** Charges the verified route's price: reusable challenges are not bound to their quoted amount. */
27
+ reusableChallenges?: boolean | undefined;
25
28
  settleCharged?: SettleChargedSessionChannel | undefined;
26
29
  store: ChannelStore.ChannelStore;
27
30
  }): Sse;
@@ -70,7 +70,10 @@ export function sse(options) {
70
70
  const verifiedRequest = envelope?.request ?? verifiedCredential.challenge.request;
71
71
  const payload = requireSessionCredentialContext(verifiedCredential.payload, 'No SSE context available');
72
72
  const channelId = payload.channelId;
73
- const tickCost = BigInt(verifiedCredential.challenge.request.amount);
73
+ // A reusable challenge may have quoted another request's price, so charge the verified route's
74
+ // canonical price. Otherwise the challenge amount is canonical and bound to the route.
75
+ const tickCost = BigInt((options.reusableChallenges ? verifiedRequest : verifiedCredential.challenge.request)
76
+ .amount);
74
77
  const unitType = typeof verifiedRequest.unitType === 'string' ? verifiedRequest.unitType : undefined;
75
78
  // Auto-detect upstream SSE responses and parse them into an
76
79
  // AsyncIterable so they flow through the metered pipeline.
@@ -91,6 +94,7 @@ export function sse(options) {
91
94
  tickCost,
92
95
  pollIntervalMs: pollingInterval,
93
96
  generate,
97
+ beforeCharge: () => options.beforeCharge?.(channelId),
94
98
  onChargeCommitted: options.settleCharged,
95
99
  prepaidUnits: hasPrepaidSessionTick(receipt) ? 1 : 0,
96
100
  signal: input.signal,
@@ -23,6 +23,8 @@ export type ChannelEntry = {
23
23
  channelId: Hex.Hex;
24
24
  /** Highest cumulative amount this client has locally authorized. */
25
25
  cumulativeAmount: bigint;
26
+ /** Blocks new deposits until an uncertain top-up has been reconciled against trusted chain state. */
27
+ uncertainTopUp?: boolean | undefined;
26
28
  /** Latest channel deposit known by the client. */
27
29
  deposit: bigint;
28
30
  /** TIP-1034 descriptor required for vouchers, top-ups, and close credentials. */
@@ -1,7 +1,10 @@
1
1
  import type { Address } from 'viem';
2
2
  import type { MaybePromise } from '../../../internal/types.js';
3
3
  import type { ChannelEntry } from './ChannelOps.js';
4
- /** Store of reusable payer session channels keyed by payment scope. */
4
+ /**
5
+ * Store of reusable payer session channels keyed by payment scope.
6
+ * Callers sharing a backend across processes must coordinate funding and writes externally.
7
+ */
5
8
  export type ChannelStore = {
6
9
  /** Returns the channel cached for `key`, when present. */
7
10
  get(key: string): MaybePromise<ChannelEntry | undefined>;
@@ -48,4 +51,12 @@ export type JsonChannelKv = {
48
51
  };
49
52
  /** Wraps a string KV backend as a bigint-safe channel store. */
50
53
  export declare function createJsonChannelStore(kv: JsonChannelKv): ChannelStore;
54
+ /** @internal Shares funding coordination between a manager's store view and its backing store. */
55
+ export declare function registerFundingStore(view: ChannelStore, backing: ChannelStore): void;
56
+ /** @internal Serializes funding decisions for callers sharing one backing store in this process. */
57
+ export declare function withFundingLock<T>(store: ChannelStore, key: string, operation: () => Promise<T>): Promise<T>;
58
+ /** @internal Serializes writes and preserves unresolved funding across stale channel copies. */
59
+ export declare function persistChannel(store: ChannelStore, entry: ChannelEntry): Promise<void>;
60
+ /** @internal Retains unresolved funding records through close and stale-entry cleanup. */
61
+ export declare function deleteChannel(store: ChannelStore, key: string): Promise<void>;
51
62
  //# sourceMappingURL=ChannelStore.d.ts.map
@@ -60,4 +60,51 @@ export function createJsonChannelStore(kv) {
60
60
  },
61
61
  };
62
62
  }
63
+ const pendingOperations = new WeakMap();
64
+ const fundingStores = new WeakMap();
65
+ /** @internal Shares funding coordination between a manager's store view and its backing store. */
66
+ export function registerFundingStore(view, backing) {
67
+ fundingStores.set(view, fundingStores.get(backing) ?? backing);
68
+ }
69
+ async function exclusive(store, key, operation) {
70
+ let pending = pendingOperations.get(store);
71
+ if (!pending) {
72
+ pending = new Map();
73
+ pendingOperations.set(store, pending);
74
+ }
75
+ const previous = pending.get(key) ?? Promise.resolve();
76
+ const result = previous.catch(() => { }).then(operation);
77
+ pending.set(key, result);
78
+ try {
79
+ return await result;
80
+ }
81
+ finally {
82
+ if (pending.get(key) === result)
83
+ pending.delete(key);
84
+ }
85
+ }
86
+ /** @internal Serializes funding decisions for callers sharing one backing store in this process. */
87
+ export function withFundingLock(store, key, operation) {
88
+ return exclusive(fundingStores.get(store) ?? store, `funding:${key}`, operation);
89
+ }
90
+ /** @internal Serializes writes and preserves unresolved funding across stale channel copies. */
91
+ export async function persistChannel(store, entry) {
92
+ await exclusive(store, `write:${entryKey(entry)}`, async () => {
93
+ const current = await store.get(entryKey(entry));
94
+ if (current?.uncertainTopUp) {
95
+ if (current.channelId !== entry.channelId)
96
+ throw new Error('Session funding outcome is unknown; reconcile the pending payment before replacing the channel.');
97
+ entry.uncertainTopUp = true;
98
+ }
99
+ await store.set(entry);
100
+ });
101
+ }
102
+ /** @internal Retains unresolved funding records through close and stale-entry cleanup. */
103
+ export async function deleteChannel(store, key) {
104
+ await exclusive(store, `write:${key}`, async () => {
105
+ if ((await store.get(key))?.uncertainTopUp)
106
+ return;
107
+ await store.delete(key);
108
+ });
109
+ }
63
110
  //# sourceMappingURL=ChannelStore.js.map
@@ -7,7 +7,7 @@ import * as Chain from '../precompile/Chain.js';
7
7
  import * as Channel from '../precompile/Channel.js';
8
8
  import * as Voucher from '../precompile/Voucher.js';
9
9
  import { createClosePayload, createOpenPayload, createTopUpPayload, createVoucherPayload, isSameAddress, resolveAuthorizedSigner, resolveEscrow, } from './ChannelOps.js';
10
- import { channelKey, entryKey } from './ChannelStore.js';
10
+ import { channelKey, entryKey, persistChannel, deleteChannel, } from './ChannelStore.js';
11
11
  import { assertWithinMaxDeposit, resolveOpeningDeposit } from './Runtime.js';
12
12
  /** Returns whether a credential payload carries cumulative voucher authorization. */
13
13
  export function hasCredentialCumulativeAmount(payload) {
@@ -26,9 +26,9 @@ export function readCredentialCumulativeAmount(payload) {
26
26
  */
27
27
  async function storeChannelEntry(sink, entry) {
28
28
  if (entry.opened)
29
- await sink.store.set(entry);
29
+ await persistChannel(sink.store, entry);
30
30
  else
31
- await sink.store.delete(entryKey(entry));
31
+ await deleteChannel(sink.store, entryKey(entry));
32
32
  sink.notifyUpdate(entry);
33
33
  }
34
34
  /** Applies a credential payload's cumulative amount to the stored channel at `key`. */
@@ -338,6 +338,7 @@ async function resolveAutoSwapCalls(autoSwap, parameters) {
338
338
  tokenOut: parameters.tokenOut,
339
339
  tokenIn: autoSwap.tokenIn,
340
340
  slippage: autoSwap.slippage,
341
+ maxAmountIn: autoSwap.maxAmountIn,
341
342
  }));
342
343
  }
343
344
  async function open(plan, sink, autoSwap) {
@@ -403,7 +404,11 @@ async function recover(plan, sink) {
403
404
  throw new Error('recovered voucher amount exceeds on-chain channel deposit');
404
405
  assertWithinMaxDeposit(cumulativeAmount, maxDeposit);
405
406
  const payload = await createVoucherPayload(resolved.client, account, descriptor, cumulativeAmount, resolved.chainId, resolved.escrow);
407
+ const current = await sink.store.get(resolved.key);
406
408
  await storeChannelEntry(sink, {
409
+ ...(current?.channelId === reusable.channelId && current.uncertainTopUp
410
+ ? { uncertainTopUp: true }
411
+ : {}),
407
412
  channelId: reusable.channelId,
408
413
  cumulativeAmount,
409
414
  deposit: reusable.state.deposit,
@@ -13,7 +13,7 @@ import * as Methods from '../../Methods.js';
13
13
  import * as Channel from '../precompile/Channel.js';
14
14
  import { deserializeSessionReceipt, isEventStream, readSessionChallengeAmount, requireSessionCredentialContext, } from '../precompile/Protocol.js';
15
15
  import { serializeCredential } from './ChannelOps.js';
16
- import { createChannelStore, entryKey } from './ChannelStore.js';
16
+ import { createChannelStore, entryKey, persistChannel, withFundingLock, } from './ChannelStore.js';
17
17
  import { canSignDescriptor, executeCredentialPlan, hasSessionAction, planCredential, resolveChallengeContext, resolveRecoverContext, sessionContextSchema, } from './CredentialState.js';
18
18
  import { assertWithinMaxDeposit, resolveAutomaticTopUp } from './Runtime.js';
19
19
  import { getSessionSnapshot, handleSseNeedVoucher, isTip1034SessionChallenge, postTopUp, wrapSseResponse, } from './Transports.js';
@@ -174,7 +174,7 @@ export function session(parameters = {}) {
174
174
  const current = await store.get(resolved.key);
175
175
  if (!current?.opened ||
176
176
  current.channelId.toLowerCase() !== opened.channelId.toLowerCase()) {
177
- await store.set(opened);
177
+ await persistChannel(store, opened);
178
178
  sink.notifyUpdate(opened);
179
179
  }
180
180
  }
@@ -196,38 +196,55 @@ export function session(parameters = {}) {
196
196
  },
197
197
  });
198
198
  const topUpChannelIfNeeded = async ({ challenge, channel, deposit, fetch, input, requiredCumulative, signal, }) => {
199
- const knownDeposit = channel.deposit > deposit ? channel.deposit : deposit;
200
- const additionalDeposit = resolveAutomaticTopUp({
201
- deposit: knownDeposit,
202
- maxDeposit,
203
- requiredCumulative,
204
- suggestedDeposit: challenge.request.suggestedDeposit === undefined
205
- ? undefined
206
- : BigInt(challenge.request.suggestedDeposit),
207
- topUpAmount,
208
- });
209
- if (additionalDeposit > 0n)
210
- await postTopUp({
211
- additionalDeposit,
212
- challenge,
213
- channel,
214
- channelId: channel.channelId,
215
- createSessionCredential: (challenge, context) => method.createCredential({ challenge, context }),
216
- fetch,
217
- input,
218
- signal,
199
+ return withFundingLock(store, entryKey(channel), async () => {
200
+ signal?.throwIfAborted();
201
+ const fundingChannel = await store.get(entryKey(channel));
202
+ if (fundingChannel?.channelId === channel.channelId)
203
+ channel = fundingChannel;
204
+ if (channel.uncertainTopUp)
205
+ throw new Error('Session funding outcome is unknown; reconcile the pending payment before another top-up.');
206
+ const knownDeposit = channel.deposit > deposit ? channel.deposit : deposit;
207
+ const additionalDeposit = resolveAutomaticTopUp({
208
+ deposit: knownDeposit,
209
+ maxDeposit,
210
+ requiredCumulative,
211
+ suggestedDeposit: challenge.request.suggestedDeposit === undefined
212
+ ? undefined
213
+ : BigInt(challenge.request.suggestedDeposit),
214
+ topUpAmount,
219
215
  });
220
- const nextDeposit = knownDeposit + additionalDeposit;
221
- if (nextDeposit === channel.deposit)
222
- return;
223
- const current = await store.get(entryKey(channel));
224
- const latest = current?.channelId.toLowerCase() === channel.channelId.toLowerCase() ? current : channel;
225
- const updated = {
226
- ...latest,
227
- deposit: latest.deposit > nextDeposit ? latest.deposit : nextDeposit,
228
- };
229
- await store.set(updated);
230
- sink.notifyUpdate(updated);
216
+ if (additionalDeposit > 0n)
217
+ await postTopUp({
218
+ additionalDeposit,
219
+ challenge,
220
+ channel,
221
+ channelId: channel.channelId,
222
+ createSessionCredential: (challenge, context) => method.createCredential({ challenge, context }),
223
+ onUncertainFunding: async (entry) => {
224
+ const current = await store.get(entryKey(entry));
225
+ const unresolved = {
226
+ ...(current?.channelId === entry.channelId ? current : entry),
227
+ uncertainTopUp: true,
228
+ };
229
+ await persistChannel(store, unresolved);
230
+ sink.notifyUpdate(unresolved);
231
+ },
232
+ fetch,
233
+ input,
234
+ signal,
235
+ });
236
+ const nextDeposit = knownDeposit + additionalDeposit;
237
+ if (nextDeposit === channel.deposit)
238
+ return;
239
+ const current = await store.get(entryKey(channel));
240
+ const latest = current?.channelId.toLowerCase() === channel.channelId.toLowerCase() ? current : channel;
241
+ const updated = {
242
+ ...latest,
243
+ deposit: latest.deposit > nextDeposit ? latest.deposit : nextDeposit,
244
+ };
245
+ await persistChannel(store, updated);
246
+ sink.notifyUpdate(updated);
247
+ });
231
248
  };
232
249
  MethodChallenge.register(method, async ({ challenge, context, fetch, input, signal }) => {
233
250
  if (!isTip1034SessionChallenge(challenge))
@@ -9,7 +9,7 @@ import * as Account from '../../../viem/Account.js';
9
9
  import * as Client from '../../../viem/Client.js';
10
10
  import { charge as chargePlugin } from '../../client/Charge.js';
11
11
  import * as defaults from '../../internal/defaults.js';
12
- import { createChannelStore, entryKey } from '../client/ChannelStore.js';
12
+ import { createChannelStore, deleteChannel, entryKey, persistChannel as persistStoredChannel, registerFundingStore, withFundingLock, } from '../client/ChannelStore.js';
13
13
  import { hydrateSessionSnapshot, sessionContextSchema, } from '../client/CredentialState.js';
14
14
  import { session as sessionPlugin } from '../client/Session.js';
15
15
  import * as Channel from '../precompile/Channel.js';
@@ -24,7 +24,7 @@ import { closeSocketSession } from './Runtime.js';
24
24
  import { closeHttpSession, getSessionSnapshot, isTempoSessionChallenge, postTopUp, retryHttpPaymentRequired, webSocketProbeUrl, } from './Transports.js';
25
25
  import { WebSocketReadyState, } from './Transports.js';
26
26
  import { consumeSseSessionResponse, openSseSession } from './Transports.js';
27
- import { applyTopUpResult, resolveManualTopUp } from './Transports.js';
27
+ import { UncertainFundingError, applyTopUpResult, resolveManualTopUp, } from './Transports.js';
28
28
  import { openWebSocketSession, prepareWebSocketSession, probeWebSocketSession, } from './Transports.js';
29
29
  export { computeFallbackCloseAmount } from './Runtime.js';
30
30
  function isTempoChargeChallenge(challenge) {
@@ -117,6 +117,7 @@ export function sessionManager(parameters) {
117
117
  return entry;
118
118
  return undefined;
119
119
  }
120
+ const persistChannel = (entry) => persistStoredChannel(backing, entry);
120
121
  const store = {
121
122
  async get(key) {
122
123
  const entry = await getReusable(key);
@@ -133,26 +134,34 @@ export function sessionManager(parameters) {
133
134
  ignoredChannelIds.delete(entry.channelId);
134
135
  if (channelUse?.trackCreates && !channelUse.seenExisting.has(key))
135
136
  channelUse.created.set(key, entry);
136
- await backing.set(entry);
137
+ await persistChannel(entry);
137
138
  },
138
- delete: (key) => backing.delete(key),
139
+ delete: (key) => deleteChannel(backing, key),
139
140
  };
141
+ registerFundingStore(store, backing);
140
142
  /** Removes a failed channel from candidacy for the rest of this manager's life. */
141
143
  async function ignoreChannel(entry) {
142
144
  const key = entryKey(entry);
145
+ const current = await backing.get(key);
146
+ if (entry.uncertainTopUp || current?.uncertainTopUp)
147
+ throw new Error('Session funding outcome is unknown; reconcile the pending payment before replacing the channel.');
143
148
  ignoredChannelIds.add(entry.channelId);
144
149
  channelUse?.seenExisting.delete(key);
145
150
  if (channelUse?.resumed === entry)
146
151
  channelUse.resumed = undefined;
147
- await Promise.resolve(backing.delete(key)).catch(() => undefined);
152
+ await Promise.resolve(deleteChannel(backing, key)).catch(() => undefined);
148
153
  }
149
154
  async function rollbackCreatedChannels() {
150
155
  const use = channelUse;
151
156
  if (!use?.created.size)
152
157
  return;
153
158
  for (const [key, entry] of use.created) {
159
+ const current = await backing.get(key);
160
+ if (entry.uncertainTopUp ||
161
+ (current?.channelId === entry.channelId && current.uncertainTopUp))
162
+ throw new Error('Session funding outcome is unknown; reconcile the pending payment before discarding the channel.');
154
163
  ignoredChannelIds.add(entry.channelId);
155
- await Promise.resolve(backing.delete(key)).catch(() => undefined);
164
+ await Promise.resolve(deleteChannel(backing, key)).catch(() => undefined);
156
165
  }
157
166
  use.created.clear();
158
167
  await restoreRuntime(use.committed ?? use.previous);
@@ -193,7 +202,7 @@ export function sessionManager(parameters) {
193
202
  const baseline = use?.committed?.channel?.entry ?? use?.resumed;
194
203
  if (!use ||
195
204
  baseline?.channelId.toLowerCase() !== entry.channelId.toLowerCase() ||
196
- entry.deposit <= baseline.deposit)
205
+ (entry.deposit <= baseline.deposit && !entry.uncertainTopUp))
197
206
  return;
198
207
  use.resumed = undefined;
199
208
  use.committed = captureRuntimeStateSnapshot({
@@ -230,6 +239,9 @@ export function sessionManager(parameters) {
230
239
  commitDurableTopUp(entry);
231
240
  },
232
241
  });
242
+ const handleMethodChallenge = MethodChallenge.get(method);
243
+ if (handleMethodChallenge)
244
+ MethodChallenge.register(method, (parameters) => withTopUpLock(() => Promise.resolve(handleMethodChallenge(parameters)), parameters.signal));
233
245
  MethodResponse.unregister(method);
234
246
  const chargeMethod = chargePlugin({
235
247
  allowedChainIds: parameters.allowedChainIds,
@@ -435,60 +447,155 @@ export function sessionManager(parameters) {
435
447
  maxVoucherCumulative: config.maxVoucherCumulative,
436
448
  });
437
449
  }
450
+ let pendingTopUp;
451
+ let uncertainFunding;
452
+ // Keep funding decisions and their deposit updates atomic within this manager.
453
+ // Waiters share failures instead of immediately retrying an uncertain payment.
454
+ async function withTopUpLock(operation, signal) {
455
+ if (uncertainFunding)
456
+ throw uncertainFunding;
457
+ for (;;) {
458
+ const pending = pendingTopUp;
459
+ if (!pending)
460
+ break;
461
+ if (!signal)
462
+ await pending;
463
+ else {
464
+ signal.throwIfAborted();
465
+ let abort;
466
+ try {
467
+ await Promise.race([
468
+ pending,
469
+ new Promise((_, reject) => {
470
+ abort = () => reject(signal.reason);
471
+ signal.addEventListener('abort', abort, { once: true });
472
+ }),
473
+ ]);
474
+ }
475
+ finally {
476
+ signal.removeEventListener('abort', abort);
477
+ }
478
+ }
479
+ }
480
+ signal?.throwIfAborted();
481
+ if (uncertainFunding)
482
+ throw uncertainFunding;
483
+ const pending = operation();
484
+ pendingTopUp = pending;
485
+ try {
486
+ return await pending;
487
+ }
488
+ catch (error) {
489
+ if (error instanceof UncertainFundingError)
490
+ uncertainFunding = new Error('Session funding outcome is unknown; reconcile the pending payment before creating a new session manager.');
491
+ throw error;
492
+ }
493
+ finally {
494
+ if (pendingTopUp === pending)
495
+ pendingTopUp = undefined;
496
+ }
497
+ }
438
498
  async function postTopUpAndApply(parameters) {
439
- const { knownDeposit, ...topUp } = parameters;
440
- const receipt = await postTopUp({
441
- ...topUp,
442
- channel: runtime.channel,
443
- createSessionCredential,
444
- fetch: config.fetch,
445
- });
446
- updateSpentFromReceipt(receipt);
447
- const applied = applyTopUpResult({
448
- additionalDeposit: parameters.additionalDeposit,
449
- channel: runtime.channel,
450
- channelId: parameters.channelId,
451
- challengeId: runtime.lastChallenge?.id,
452
- currentState: runtime.state,
453
- knownDeposit,
454
- receipt,
455
- spent: runtime.spent,
456
- });
457
- if (applied?.channel && runtime.lastChallenge) {
458
- await store.set(applied.channel);
459
- dispatch({
460
- type: 'activated',
461
- challengeId: runtime.lastChallenge.id,
462
- entry: applied.channel,
463
- spent: runtime.spent.toString(),
464
- units: activeUnits(),
499
+ if (!runtime.channel)
500
+ throw new Error('Cannot top up session: no active channel');
501
+ return withFundingLock(backing, entryKey(runtime.channel), async () => {
502
+ const { knownDeposit, ...topUp } = parameters;
503
+ const stored = runtime.channel ? await store.get(entryKey(runtime.channel)) : undefined;
504
+ if (stored?.channelId === parameters.channelId && stored.uncertainTopUp)
505
+ throw new Error('Session funding outcome is unknown; reconcile the pending payment before another top-up.');
506
+ const receipt = await postTopUp({
507
+ ...topUp,
508
+ channel: runtime.channel,
509
+ createSessionCredential,
510
+ fetch: config.fetch,
511
+ onUncertainFunding: async (channel) => {
512
+ const current = await store.get(entryKey(channel));
513
+ const unresolved = {
514
+ ...(current?.channelId === channel.channelId ? current : channel),
515
+ uncertainTopUp: true,
516
+ };
517
+ if (runtime.channel?.channelId === channel.channelId)
518
+ runtime.channel = unresolved;
519
+ await store.set(unresolved);
520
+ commitDurableTopUp(unresolved);
521
+ },
465
522
  });
466
- commitDurableTopUp(applied.channel);
467
- }
468
- return receipt;
523
+ updateSpentFromReceipt(receipt);
524
+ let baseline = runtime.channel;
525
+ const applied = applyTopUpResult({
526
+ additionalDeposit: parameters.additionalDeposit,
527
+ channel: runtime.channel,
528
+ channelId: parameters.channelId,
529
+ challengeId: runtime.lastChallenge?.id,
530
+ currentState: runtime.state,
531
+ knownDeposit,
532
+ receipt,
533
+ spent: runtime.spent,
534
+ });
535
+ if (applied?.channel && runtime.lastChallenge) {
536
+ let entry = applied.channel;
537
+ for (;;) {
538
+ await store.set(entry);
539
+ const current = runtime.channel;
540
+ if (current === baseline)
541
+ break;
542
+ // A completed deposit must not restore a replaced session. For the same
543
+ // channel retain newer vouchers while recording the confirmed funding.
544
+ if (!current)
545
+ return receipt;
546
+ const funded = applied.channel;
547
+ entry =
548
+ current.channelId !== funded.channelId
549
+ ? current
550
+ : {
551
+ ...current,
552
+ deposit: current.deposit > funded.deposit ? current.deposit : funded.deposit,
553
+ cumulativeAmount: current.cumulativeAmount > funded.cumulativeAmount
554
+ ? current.cumulativeAmount
555
+ : funded.cumulativeAmount,
556
+ };
557
+ baseline = current;
558
+ }
559
+ if (entry.channelId !== applied.channel.channelId)
560
+ return receipt;
561
+ runtime.channel = entry;
562
+ dispatch({
563
+ type: 'activated',
564
+ challengeId: runtime.lastChallenge.id,
565
+ entry,
566
+ spent: runtime.spent.toString(),
567
+ units: activeUnits(),
568
+ });
569
+ commitDurableTopUp(entry);
570
+ }
571
+ return receipt;
572
+ });
469
573
  }
470
574
  async function topUpIfNeeded(parameters) {
471
- const channelDeposit = runtime.channel?.channelId === parameters.channelId ? runtime.channel.deposit : 0n;
472
- const deposit = channelDeposit > parameters.deposit ? channelDeposit : parameters.deposit;
473
- const additionalDeposit = resolveAutomaticTopUp({
474
- deposit,
475
- maxDeposit: config.maxVoucherCumulative,
476
- requiredCumulative: parameters.requiredCumulative,
477
- suggestedDeposit: parameters.challenge.request.suggestedDeposit === undefined
478
- ? undefined
479
- : BigInt(parameters.challenge.request.suggestedDeposit),
480
- topUpAmount: config.topUpAmount,
481
- });
482
- if (additionalDeposit === 0n)
483
- return;
484
- await postTopUpAndApply({
485
- challenge: parameters.challenge,
486
- input: parameters.input,
487
- channelId: parameters.channelId,
488
- additionalDeposit,
489
- knownDeposit: deposit,
490
- signal: parameters.signal,
491
- });
575
+ return withTopUpLock(async () => {
576
+ parameters.signal?.throwIfAborted();
577
+ const channelDeposit = runtime.channel?.channelId === parameters.channelId ? runtime.channel.deposit : 0n;
578
+ const deposit = channelDeposit > parameters.deposit ? channelDeposit : parameters.deposit;
579
+ const additionalDeposit = resolveAutomaticTopUp({
580
+ deposit,
581
+ maxDeposit: config.maxVoucherCumulative,
582
+ requiredCumulative: parameters.requiredCumulative,
583
+ suggestedDeposit: parameters.challenge.request.suggestedDeposit === undefined
584
+ ? undefined
585
+ : BigInt(parameters.challenge.request.suggestedDeposit),
586
+ topUpAmount: config.topUpAmount,
587
+ });
588
+ if (additionalDeposit === 0n)
589
+ return;
590
+ await postTopUpAndApply({
591
+ challenge: parameters.challenge,
592
+ input: parameters.input,
593
+ channelId: parameters.channelId,
594
+ additionalDeposit,
595
+ knownDeposit: deposit,
596
+ signal: parameters.signal,
597
+ });
598
+ }, parameters.signal);
492
599
  }
493
600
  async function restoreCumulative(channelId, cumulativeAmount) {
494
601
  const restored = restoreCumulativeAuthorization({
@@ -507,7 +614,7 @@ export function sessionManager(parameters) {
507
614
  spent: restored.spent,
508
615
  units: restored.units,
509
616
  });
510
- await backing.set(runtime.channel);
617
+ await persistChannel(runtime.channel);
511
618
  }
512
619
  }
513
620
  async function restoreRuntime(snapshot) {
@@ -516,7 +623,7 @@ export function sessionManager(parameters) {
516
623
  runtime.spent = restored.spent;
517
624
  runtime.state = restored.state;
518
625
  if (runtime.channel?.opened)
519
- await backing.set(runtime.channel);
626
+ await persistChannel(runtime.channel);
520
627
  }
521
628
  function toPaymentResponse(response) {
522
629
  const receiptHeader = response.headers.get(Constants.Headers.paymentReceipt);
@@ -667,7 +774,7 @@ export function sessionManager(parameters) {
667
774
  },
668
775
  fetch: doFetch,
669
776
  async topUp(amount) {
670
- const target = resolveManualTopUp({
777
+ const intended = resolveManualTopUp({
671
778
  amount,
672
779
  assertVoucherWithinLocalLimit,
673
780
  channel: runtime.channel,
@@ -675,11 +782,25 @@ export function sessionManager(parameters) {
675
782
  lastChallenge: runtime.lastChallenge,
676
783
  lastUrl: runtime.lastUrl,
677
784
  });
678
- return postTopUpAndApply({
679
- additionalDeposit: target.additionalDeposit,
680
- challenge: target.challenge,
681
- channelId: target.channelId,
682
- input: target.input,
785
+ return withTopUpLock(async () => {
786
+ if (runtime.channel?.channelId !== intended.channelId ||
787
+ runtime.lastChallenge !== intended.challenge ||
788
+ runtime.lastUrl !== intended.input)
789
+ throw new Error('Session changed while waiting to top up');
790
+ const target = resolveManualTopUp({
791
+ amount,
792
+ assertVoucherWithinLocalLimit,
793
+ channel: runtime.channel,
794
+ decimals: config.decimals,
795
+ lastChallenge: intended.challenge,
796
+ lastUrl: intended.input,
797
+ });
798
+ return postTopUpAndApply({
799
+ additionalDeposit: target.additionalDeposit,
800
+ challenge: target.challenge,
801
+ channelId: target.channelId,
802
+ input: target.input,
803
+ });
683
804
  });
684
805
  },
685
806
  async sse(input, init) {
@@ -213,6 +213,8 @@ export type PostTopUpParameters = {
213
213
  input: RequestInfo | URL;
214
214
  /** Cancels the top-up management request. */
215
215
  signal?: AbortSignal | undefined;
216
+ /** Persists an unresolved funding outcome before another caller can reuse the channel. */
217
+ onUncertainFunding?: ((channel: ChannelEntry) => Promise<void>) | undefined;
216
218
  };
217
219
  /** Inputs for retrying an HTTP 402 with a top-up/voucher management round trip. */
218
220
  export type RetryHttpPaymentRequiredParameters = {
@@ -277,6 +279,10 @@ export declare function managementInput(input: RequestInfo | URL): RequestInfo |
277
279
  export declare function webSocketProbeUrl(input: string | URL): URL;
278
280
  /** Reads an HTTP problem detail body, falling back to raw text for non-problem responses. */
279
281
  export declare function readProblemDetail(response: Response): Promise<string>;
282
+ /** A dispatched deposit may have executed despite a failed or cancelled response. */
283
+ export declare class UncertainFundingError extends Error {
284
+ constructor(reason: unknown);
285
+ }
280
286
  /** Posts a top-up management credential and returns its receipt, when present. */
281
287
  export declare function postTopUp(parameters: PostTopUpParameters): Promise<SessionReceipt | undefined>;
282
288
  /** Retries an HTTP 402 when the server snapshot asks for more voucher headroom. */