mppx 0.13.1 → 0.13.3

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.
@@ -183,6 +183,8 @@ export type ChannelTransactionOptions = {
183
183
  feePayerPolicy?: Partial<FeePayer.Policy> | undefined;
184
184
  /** Explicit fee token for the transaction. */
185
185
  feeToken?: Address | undefined;
186
+ /** Unix time in seconds after which the expiring-nonce transaction cannot be included. */
187
+ validBefore?: number | undefined;
186
188
  };
187
189
  /** Simulates an unsponsored client-signed session transaction without broadcasting it. */
188
190
  export declare function simulateCredentialTransaction(parameters: {
@@ -326,7 +326,7 @@ async function signTempoTransaction(client, transaction) {
326
326
  return (await signTransaction(client, transaction));
327
327
  }
328
328
  async function prepareFeePayerCallTransaction(client, parameters) {
329
- const { account, data, feeToken, to } = parameters;
329
+ const { account, data, feeToken, to, validBefore } = parameters;
330
330
  // viem's stable request type does not expose Tempo fee-payer transaction
331
331
  // fields for this call shape. Keep the cast at the boundary.
332
332
  return prepareTransactionRequest(client, {
@@ -335,10 +335,11 @@ async function prepareFeePayerCallTransaction(client, parameters) {
335
335
  feePayer: true,
336
336
  nonceKey: 'expiring',
337
337
  ...(feeToken ? { feeToken } : {}),
338
+ ...(validBefore ? { validBefore } : {}),
338
339
  });
339
340
  }
340
341
  function sendPrecompileContractCall(client, parameters) {
341
- const { account, data, feePayer, feeToken, to } = parameters;
342
+ const { account, data, feePayer, feeToken, to, validBefore } = parameters;
342
343
  // `feeToken` is Tempo-specific and not represented on viem's base
343
344
  // transaction request type.
344
345
  return sendViemTransaction(client, {
@@ -349,6 +350,7 @@ function sendPrecompileContractCall(client, parameters) {
349
350
  nonceKey: 'expiring',
350
351
  ...(feePayer ? { feePayer } : {}),
351
352
  ...(feeToken ? { feeToken } : {}),
353
+ ...(validBefore ? { validBefore } : {}),
352
354
  });
353
355
  }
354
356
  /**
@@ -674,6 +676,7 @@ async function sendPrecompileTransaction(client, to, data, label, options) {
674
676
  feePayer: true,
675
677
  feeToken: options.feeToken,
676
678
  to,
679
+ validBefore: options.validBefore,
677
680
  });
678
681
  }
679
682
  if (feePayer && !selfSponsored) {
@@ -690,6 +693,7 @@ async function sendPrecompileTransaction(client, to, data, label, options) {
690
693
  data,
691
694
  feeToken,
692
695
  to,
696
+ validBefore: options.validBefore,
693
697
  });
694
698
  assertPrecompileFeePayerPolicy({ prepared, policy: options.feePayerPolicy });
695
699
  const serialized = await signTempoTransaction(client, {
@@ -719,6 +723,7 @@ async function sendPrecompileTransaction(client, to, data, label, options) {
719
723
  to,
720
724
  data,
721
725
  feeToken,
726
+ validBefore: options?.validBefore,
722
727
  });
723
728
  }
724
729
  //# sourceMappingURL=Chain.js.map
@@ -300,6 +300,14 @@ export declare function acceptVoucherStateUpdate(parameters: AcceptVoucherStateU
300
300
  * close voucher and current on-chain deposit.
301
301
  */
302
302
  export declare function resolveCloseCaptureAmount(parameters: ResolveCloseCaptureAmountParameters): bigint;
303
+ /** Seconds a server close transaction stays valid after its pending-close marker is written. */
304
+ export declare const pendingCloseValiditySeconds = 25;
305
+ /** Clears a server pending-close marker whose close transaction expired without closing the channel. */
306
+ export declare function reconcileExpiredPendingClose(parameters: {
307
+ current: State | null;
308
+ now: number;
309
+ state: OnChainChannelState;
310
+ }): State | null;
303
311
  /** Marks local channel state as pending close and returns the bounded capture amount. */
304
312
  export declare function markPendingClose(parameters: MarkPendingCloseParameters): PendingCloseUpdate;
305
313
  /** Finalizes local channel state after a successful close transaction. */
@@ -142,6 +142,23 @@ export function resolveCloseCaptureAmount(parameters) {
142
142
  }
143
143
  return captureAmount;
144
144
  }
145
+ /** Seconds a server close transaction stays valid after its pending-close marker is written. */
146
+ export const pendingCloseValiditySeconds = 25;
147
+ /** Extra seconds allowed for clock differences between the server and the chain. */
148
+ const pendingCloseClockSkewSeconds = 30;
149
+ /** Clears a server pending-close marker whose close transaction expired without closing the channel. */
150
+ export function reconcileExpiredPendingClose(parameters) {
151
+ const { current, now, state } = parameters;
152
+ if (!current || current.finalized || current.closeRequestedAt === 0n)
153
+ return current;
154
+ // An open channel with no on-chain close request proves the expired close never executed.
155
+ if (BigInt(state.closeRequestedAt) !== 0n || state.deposit === 0n)
156
+ return current;
157
+ const expiresAt = current.closeRequestedAt + BigInt(pendingCloseValiditySeconds);
158
+ if (BigInt(now) <= expiresAt + BigInt(pendingCloseClockSkewSeconds))
159
+ return current;
160
+ return { ...current, closeRequestedAt: 0n };
161
+ }
145
162
  /** Marks local channel state as pending close and returns the bounded capture amount. */
146
163
  export function markPendingClose(parameters) {
147
164
  const { closeRequestedAt, cumulativeAmount, current, onChainSettled, onChainDeposit } = parameters;
@@ -5,7 +5,7 @@ import * as Chain from '../precompile/Chain.js';
5
5
  import * as Channel from '../precompile/Channel.js';
6
6
  import { type SessionCredentialPayload, type SessionReceipt } from '../precompile/Protocol.js';
7
7
  import * as ChannelStore from './ChannelStore.js';
8
- import { type OnSessionSettlement } from './Settlement.js';
8
+ import { type OnSessionSettlement, type OnSessionSettlementFailure } from './Settlement.js';
9
9
  /** Returns the effective voucher signer for a TIP-1034 descriptor. */
10
10
  export declare function authorizedSigner(descriptor: Channel.ChannelDescriptor): Address;
11
11
  /** Asserts that a credential payload includes a TIP-1034 descriptor. */
@@ -77,6 +77,8 @@ export type BroadcastCredentialPayloadParameters = {
77
77
  minVoucherDelta: bigint;
78
78
  /** Callback invoked after an on-chain settlement or close transaction is confirmed. */
79
79
  onSessionSettlement?: OnSessionSettlement | undefined;
80
+ /** Callback invoked when a scheduled settlement or close transaction fails. */
81
+ onSessionSettlementFailure?: OnSessionSettlementFailure | undefined;
80
82
  /** Discriminated session credential payload to verify. */
81
83
  payload: SessionCredentialPayload;
82
84
  /** Whether an open or voucher credential must add new funds for this request. */
@@ -1,4 +1,4 @@
1
- import { isAddress, isAddressEqual, zeroAddress, } from 'viem';
1
+ import { createClient, custom, isAddress, isAddressEqual, zeroAddress, } from 'viem';
2
2
  import { AmountExceedsDepositError, ChannelClosedError, ChannelNotFoundError, DeltaTooSmallError, InsufficientBalanceError, InvalidSignatureError, VerificationFailedError, } from '../../../Errors.js';
3
3
  import * as Chain from '../precompile/Chain.js';
4
4
  import { readChannelClosedReceiptFields } from '../precompile/Chain.js';
@@ -7,7 +7,7 @@ import { createSessionReceipt, uint96, } from '../precompile/Protocol.js';
7
7
  import * as Voucher from '../precompile/Voucher.js';
8
8
  import * as ChannelStore from './ChannelStore.js';
9
9
  import { getChallengePaymentFields } from './RequestState.js';
10
- import { assertSettlementSender, getClientAccount, maybeSettleScheduled, } from './Settlement.js';
10
+ import { assertSettlementSender, getClientAccount, maybeSettleScheduled, reportSessionSettlementFailure, } from './Settlement.js';
11
11
  /** Returns the effective voucher signer for a TIP-1034 descriptor. */
12
12
  export function authorizedSigner(descriptor) {
13
13
  return isAddressEqual(descriptor.authorizedSigner, zeroAddress)
@@ -622,6 +622,13 @@ async function handleVoucherCredential(parameters) {
622
622
  forceRefresh: true,
623
623
  lastOnChainVerified,
624
624
  });
625
+ const reconciled = channel.closeRequestedAt === 0n
626
+ ? channel
627
+ : ((await store.updateChannel(channelId, (current) => ChannelStore.reconcileExpiredPendingClose({
628
+ current,
629
+ now: Math.floor(Date.now() / 1000),
630
+ state: channelState,
631
+ }))) ?? channel);
625
632
  if (channelState.closeRequestedAt !== 0) {
626
633
  const closing = await store.updateChannel(channelId, (current) => current
627
634
  ? {
@@ -643,6 +650,7 @@ async function handleVoucherCredential(parameters) {
643
650
  feePayerPolicy: parameters.feePayerPolicy,
644
651
  feeToken: parameters.feeToken,
645
652
  onSessionSettlement: parameters.onSessionSettlement,
653
+ onSessionSettlementFailure: parameters.onSessionSettlementFailure,
646
654
  schedule: {},
647
655
  store,
648
656
  });
@@ -653,12 +661,36 @@ async function handleVoucherCredential(parameters) {
653
661
  minVoucherDelta,
654
662
  requireAdvance: parameters.requireVoucherAdvance,
655
663
  challenge,
656
- channel,
664
+ channel: reconciled,
657
665
  voucher,
658
666
  channelState,
659
667
  methodDetails: { chainId, escrowContract: escrow },
660
668
  });
661
669
  }
670
+ const sendMethods = new Set([
671
+ 'eth_sendRawTransaction',
672
+ 'eth_sendRawTransactionSync',
673
+ 'eth_sendTransaction',
674
+ ]);
675
+ /** Wraps a client so a failed close can tell whether its transaction was handed to the node. */
676
+ function trackSendAttempts(client) {
677
+ const tracked = { attempted: false };
678
+ const trackedClient = createClient({
679
+ account: client.account,
680
+ chain: client.chain,
681
+ pollingInterval: client.pollingInterval,
682
+ transport: custom({
683
+ request(parameters) {
684
+ if (sendMethods.has(parameters.method))
685
+ tracked.attempted = true;
686
+ return client.request(parameters);
687
+ },
688
+ },
689
+ // The wrapped client already owns retries.
690
+ { retryCount: 0 }),
691
+ });
692
+ return { client: trackedClient, tracked };
693
+ }
662
694
  async function handleCloseCredential(parameters) {
663
695
  const { store, client, challenge, payload, chainId, escrow } = parameters;
664
696
  const request = getChallengePaymentFields(challenge);
@@ -698,14 +730,14 @@ async function handleCloseCredential(parameters) {
698
730
  let captureAmount = uint96(channel.spent > state.settled ? channel.spent : state.settled);
699
731
  if (captureAmount > state.deposit)
700
732
  throw new AmountExceedsDepositError({ reason: 'close capture amount exceeds on-chain deposit' });
701
- const pendingCloseStartedAt = BigInt(Math.floor(Date.now() / 1000) || 1);
702
- const previousCloseRequestedAt = channel.closeRequestedAt;
733
+ const now = Math.floor(Date.now() / 1000) || 1;
734
+ const pendingCloseStartedAt = BigInt(now);
703
735
  let pendingCloseMarked = false;
704
736
  await store.updateChannel(channelId, (current) => {
705
737
  const next = ChannelStore.markPendingClose({
706
738
  closeRequestedAt: pendingCloseStartedAt,
707
739
  cumulativeAmount,
708
- current,
740
+ current: ChannelStore.reconcileExpiredPendingClose({ current, now, state }),
709
741
  onChainDeposit: state.deposit,
710
742
  onChainSettled: state.settled,
711
743
  });
@@ -716,8 +748,11 @@ async function handleCloseCredential(parameters) {
716
748
  return next.state;
717
749
  });
718
750
  const account = parameters.account ?? getClientAccount(client);
719
- let txHash;
751
+ // Pin the close transaction's expiry to the marker so an expired marker proves it can no longer land.
752
+ const validBefore = now + ChannelStore.pendingCloseValiditySeconds;
753
+ const send = trackSendAttempts(client);
720
754
  let receipt;
755
+ let txHash;
721
756
  try {
722
757
  assertSettlementSender({
723
758
  operation: 'close',
@@ -726,21 +761,31 @@ async function handleCloseCredential(parameters) {
726
761
  payee: channel.payee,
727
762
  sender: account?.address,
728
763
  });
729
- txHash = await Chain.closeOnChain(client, channel.descriptor, cumulativeAmount, captureAmount, payload.signature, escrow, account
764
+ txHash = await Chain.closeOnChain(send.client, channel.descriptor, cumulativeAmount, captureAmount, payload.signature, escrow, account
730
765
  ? {
731
766
  account,
732
767
  ...(parameters.feePayer ? { feePayer: parameters.feePayer } : {}),
733
768
  ...(parameters.feePayerPolicy ? { feePayerPolicy: parameters.feePayerPolicy } : {}),
734
769
  ...(parameters.feeToken ? { feeToken: parameters.feeToken } : {}),
735
770
  candidateFeeTokens: [channel.token],
771
+ validBefore,
736
772
  }
737
- : undefined);
773
+ : { validBefore });
738
774
  receipt = await Chain.waitForSuccessfulReceipt(client, txHash);
739
775
  }
740
776
  catch (error) {
741
- if (pendingCloseMarked) {
777
+ await reportSessionSettlementFailure(parameters.onSessionSettlementFailure, {
778
+ chainId,
779
+ channelId,
780
+ error,
781
+ trigger: 'close',
782
+ });
783
+ // A close whose send RPC was attempted may still land, so it keeps the marker until it expires and
784
+ // a later credential reconciles it. Verification errors prove a rejection or revert.
785
+ const closeFailed = !send.tracked.attempted || error instanceof VerificationFailedError;
786
+ if (pendingCloseMarked && closeFailed) {
742
787
  await store.updateChannel(channelId, (current) => current && current.closeRequestedAt === pendingCloseStartedAt
743
- ? { ...current, closeRequestedAt: previousCloseRequestedAt }
788
+ ? { ...current, closeRequestedAt: 0n }
744
789
  : current);
745
790
  }
746
791
  throw error;
@@ -19,12 +19,12 @@ import { deserializeSnapshot as deserializeSessionSnapshot, serializeSnapshot as
19
19
  import * as ChannelStore from './ChannelStore.js';
20
20
  import { type ResolveSessionChannelId } from './RequestState.js';
21
21
  import { type SettleChargedSessionChannel } from './Settlement.js';
22
- import { type OnSessionSettlement, type SettlementSchedule } from './Settlement.js';
22
+ import { type OnSessionSettlement, type OnSessionSettlementFailure, type SettlementSchedule } from './Settlement.js';
23
23
  import * as Ws from './Ws.js';
24
24
  /** Server-side automatic settlement schedule. */
25
25
  export type { SettlementSchedule } from './Settlement.js';
26
26
  /** Server-side settlement event hook types. */
27
- export type { OnSessionSettlement, SessionSettlementContext } from './Settlement.js';
27
+ export type { OnSessionSettlement, OnSessionSettlementFailure, SessionSettlementContext, SessionSettlementFailureContext, } from './Settlement.js';
28
28
  /** Server-side hook types for request-identity channel bootstrap. */
29
29
  export type { ResolveSessionChannelId, ResolveSessionChannelIdParameters, SessionChannelIdRequest, } from './RequestState.js';
30
30
  export { settle, settleBatch } from './Settlement.js';
@@ -175,6 +175,8 @@ export declare namespace session {
175
175
  escrowContract?: Address | undefined;
176
176
  /** Callback invoked after any on-chain settlement or close transaction is confirmed. */
177
177
  onSessionSettlement?: OnSessionSettlement | undefined;
178
+ /** Callback invoked when a scheduled settlement or close transaction fails. Observer errors are ignored. */
179
+ onSessionSettlementFailure?: OnSessionSettlementFailure | undefined;
178
180
  /** Server-owned automatic settlement cadence. Clients do not receive or control this schedule. */
179
181
  settlementSchedule?: SettlementSchedule | undefined;
180
182
  /** Optional fee token for management and server-driven settle/close transactions. */
@@ -27,7 +27,7 @@ import { requireSessionCredentialPayload } from './CredentialVerification.js';
27
27
  import { resolveCredentialVerificationContext, resolveSessionChannelId, resolveSessionSnapshot, resolveSessionPaymentRequest, } from './RequestState.js';
28
28
  import { respondToSessionCredential } from './RequestState.js';
29
29
  import { applyVerifiedHttpAccounting, chargeSessionChannel, shouldApplyVerifiedHttpAccounting, } from './Settlement.js';
30
- import { isSettlementDue, maybeSettleScheduled } from './Settlement.js';
30
+ import { ignoreRetryableSettlementFailure, isSettlementDue, maybeSettleScheduled, reportSessionSettlementFailure, } from './Settlement.js';
31
31
  import { resolveSettlementSchedule, } from './Settlement.js';
32
32
  import * as Ws from './Ws.js';
33
33
  export { settle, settleBatch } from './Settlement.js';
@@ -196,6 +196,7 @@ export function session(p) {
196
196
  const { amount, channelStateTtl = 5_000, currency = defaults.resolveCurrency(parameters), decimals = defaults.decimals, operator, store: rawStore = Store.memory(), suggestedDeposit, unitType, } = parameters;
197
197
  const settlementSchedule = resolveSettlementSchedule(parameters.settlementSchedule, decimals);
198
198
  const onSessionSettlement = parameters.onSessionSettlement;
199
+ const onSessionSettlementFailure = parameters.onSessionSettlementFailure;
199
200
  const store = ChannelStore.fromStore(rawStore);
200
201
  const lastOnChainVerified = new Map();
201
202
  const { account, feePayer, remoteFeePayer, recipient } = Account.resolve(parameters);
@@ -209,21 +210,34 @@ export function session(p) {
209
210
  const settleScheduled = async (channel) => {
210
211
  if (!isSettlementDue(channel, settlementSchedule))
211
212
  return undefined;
213
+ const client = await (async () => getClient({ chainId: channel.chainId }))().catch(async (error) => {
214
+ await reportSessionSettlementFailure(onSessionSettlementFailure, {
215
+ chainId: channel.chainId,
216
+ channelId: channel.channelId,
217
+ error,
218
+ trigger: 'scheduled',
219
+ });
220
+ throw error;
221
+ });
212
222
  return maybeSettleScheduled({
213
223
  account,
214
224
  channel,
215
- client: await getClient({ chainId: channel.chainId }),
225
+ client,
216
226
  ...(configuredFeePayer ? { feePayer: configuredFeePayer } : {}),
217
227
  feePayerPolicy: parameters.feePayerPolicy,
218
228
  feeToken: parameters.feeToken,
219
229
  onSessionSettlement,
230
+ onSessionSettlementFailure,
220
231
  schedule: settlementSchedule,
221
232
  store,
222
233
  });
223
234
  };
235
+ // A failed scheduled settlement is reported and retried by the next one; the charged request
236
+ // stays served because the payer's voucher already covers the charge.
237
+ const settleCharged = (channel) => settleScheduled(channel).catch(ignoreRetryableSettlementFailure);
224
238
  const serveWebSocket = (options) => Ws.serve({
225
239
  ...options,
226
- onChargeCommitted: settleScheduled,
240
+ onChargeCommitted: settleCharged,
227
241
  store,
228
242
  });
229
243
  const bootstrapCharge = ChargeServer.charge({
@@ -238,7 +252,7 @@ export function session(p) {
238
252
  });
239
253
  const transport = parameters.sse
240
254
  ? Transport.sse({
241
- settleCharged: settleScheduled,
255
+ settleCharged,
242
256
  store,
243
257
  ...(typeof parameters.sse === 'object' ? parameters.sse : undefined),
244
258
  })
@@ -305,6 +319,7 @@ export function session(p) {
305
319
  lastOnChainVerified,
306
320
  minVoucherDelta: context.minVoucherDelta,
307
321
  onSessionSettlement,
322
+ onSessionSettlementFailure,
308
323
  payload,
309
324
  requireVoucherAdvance: shouldApplyVerifiedHttpAccounting({
310
325
  capturedRequest: envelope?.capturedRequest,
@@ -328,10 +343,11 @@ export function session(p) {
328
343
  feePayerPolicy: parameters.feePayerPolicy,
329
344
  feeToken: parameters.feeToken,
330
345
  onSessionSettlement,
346
+ onSessionSettlementFailure,
331
347
  schedule: settlementSchedule,
332
348
  store,
333
349
  channel,
334
- }),
350
+ }).catch(ignoreRetryableSettlementFailure),
335
351
  });
336
352
  };
337
353
  const method = Method.toServer(Methods.session, {
@@ -86,6 +86,19 @@ export type SessionSettlementContext = Readonly<{
86
86
  }>;
87
87
  /** Callback invoked after an on-chain settlement or close transaction is confirmed. */
88
88
  export type OnSessionSettlement = (context: SessionSettlementContext) => MaybePromise<void>;
89
+ /** Context emitted when a scheduled settlement or close transaction fails. */
90
+ export type SessionSettlementFailureContext = Readonly<{
91
+ /** Chain ID of the channel. */
92
+ chainId: number;
93
+ /** Channel ID whose settlement or close failed. */
94
+ channelId: Hex;
95
+ /** Error thrown by the settlement or close. */
96
+ error: unknown;
97
+ /** `close` for a failed close transaction; `scheduled` for a failed scheduled settlement. */
98
+ trigger: 'close' | 'scheduled';
99
+ }>;
100
+ /** Callback invoked when a scheduled settlement or close transaction fails. */
101
+ export type OnSessionSettlementFailure = (context: SessionSettlementFailureContext) => MaybePromise<void>;
89
102
  /** Inputs used to mark a channel after automatic scheduled settlement succeeds. */
90
103
  export type MarkSettlementCompleteParameters = {
91
104
  channelId: ChannelStore.State['channelId'];
@@ -105,7 +118,7 @@ export declare function resolveSettlementSchedule(schedule: SettlementSchedule |
105
118
  export declare function resolveSettlementProgress(channel: ChannelStore.State): SettlementProgress | undefined;
106
119
  /** Returns whether the precompile channel has crossed any configured settlement threshold. */
107
120
  export declare function isSettlementDue(channel: ChannelStore.State, schedule: ResolvedSettlementSchedule | undefined): boolean;
108
- /** Records the channel spend/unit counters that a scheduled settlement captured. */
121
+ /** Releases a completed scheduled settlement lease; {@link settle} records the settled counters. */
109
122
  export declare function markSettlementComplete(parameters: MarkSettlementCompleteParameters): Promise<void>;
110
123
  /** Atomically claims one due scheduled settlement across server workers. */
111
124
  export declare function claimScheduledSettlement(parameters: {
@@ -208,6 +221,8 @@ export type MaybeSettleScheduledParameters = {
208
221
  feeToken?: Address | undefined;
209
222
  /** Callback invoked after the scheduled settlement transaction is confirmed. */
210
223
  onSessionSettlement?: OnSessionSettlement | undefined;
224
+ /** Callback invoked when the scheduled settlement fails. */
225
+ onSessionSettlementFailure?: OnSessionSettlementFailure | undefined;
211
226
  /** Resolved server-owned settlement cadence. */
212
227
  schedule: ResolvedSettlementSchedule | undefined;
213
228
  /** Server-side channel store. */
@@ -225,6 +240,23 @@ export declare function assertSettlementSender(parameters: SettlementSenderParam
225
240
  export declare function maybeSettleScheduled(parameters: MaybeSettleScheduledParameters): Promise<Hex | undefined>;
226
241
  /** Settles the highest accepted voucher for a precompile-backed session channel. */
227
242
  export declare function settle(store_: SessionStoreInput, client: Chain.TransactionClient, channelId_: Hex, options?: SettlementTransactionOptions): Promise<Hex>;
243
+ /** Raised when a settlement transaction confirmed but the channel store could not record it. */
244
+ export declare class SettlementCheckpointError extends Error {
245
+ readonly name = "SettlementCheckpointError";
246
+ /** Hash of the confirmed settlement transaction. */
247
+ readonly txHash: Hex;
248
+ constructor(options: {
249
+ cause: unknown;
250
+ txHash: Hex;
251
+ });
252
+ }
228
253
  /** Settles multiple precompile-backed session channels with the same validation as {@link settle}. */
229
254
  export declare function settleBatch(store: SessionStoreInput, client: Chain.TransactionClient, channelIds: readonly Hex[], options?: SettlementTransactionOptions): Promise<Hex[]>;
255
+ /**
256
+ * @internal Keeps a charged request served when its scheduled settlement hit a transport or RPC
257
+ * failure; the next settlement retries it. Reverts and configuration errors fail the request.
258
+ */
259
+ export declare function ignoreRetryableSettlementFailure(error: unknown): undefined;
260
+ /** @internal Reports a settlement failure without letting observer errors replace it. */
261
+ export declare function reportSessionSettlementFailure(onSessionSettlementFailure: OnSessionSettlementFailure | undefined, context: SessionSettlementFailureContext): Promise<void>;
230
262
  //# sourceMappingURL=Settlement.d.ts.map
@@ -1,4 +1,4 @@
1
- import { isAddress, isAddressEqual, parseUnits, zeroAddress, } from 'viem';
1
+ import { BaseError as viem_BaseError, HttpRequestError, isAddress, isAddressEqual, LimitExceededRpcError, parseUnits, ResourceUnavailableRpcError, SocketClosedError, TimeoutError, WebSocketRequestError, zeroAddress, } from 'viem';
2
2
  import { BadRequestError, ChannelClosedError, ChannelNotFoundError, InsufficientBalanceError, VerificationFailedError, } from '../../../Errors.js';
3
3
  import { isSessionContentRequest } from '../../server/internal/request-body.js';
4
4
  import * as Chain from '../precompile/Chain.js';
@@ -105,7 +105,7 @@ export function isSettlementDue(channel, schedule) {
105
105
  return true;
106
106
  return false;
107
107
  }
108
- /** Records the channel spend/unit counters that a scheduled settlement captured. */
108
+ /** Releases a completed scheduled settlement lease; {@link settle} records the settled counters. */
109
109
  export async function markSettlementComplete(parameters) {
110
110
  const { channelId, leaseOwner, store, settledAt = new Date().toISOString() } = parameters;
111
111
  await store.updateChannel(channelId, (current) => {
@@ -114,12 +114,7 @@ export async function markSettlementComplete(parameters) {
114
114
  if (current.scheduledSettlementLease?.owner !== leaseOwner)
115
115
  return current;
116
116
  const { scheduledSettlementLease: _, ...channel } = current;
117
- return {
118
- ...channel,
119
- lastSettlementAt: settledAt,
120
- lastSettlementSpent: current.spent,
121
- lastSettlementUnits: current.units,
122
- };
117
+ return { ...channel, lastSettlementAt: settledAt };
123
118
  });
124
119
  }
125
120
  /** Atomically claims one due scheduled settlement across server workers. */
@@ -244,10 +239,19 @@ export async function maybeSettleScheduled(parameters) {
244
239
  const { channel, schedule, store } = parameters;
245
240
  if (!schedule || !isSettlementDue(channel, schedule))
246
241
  return undefined;
242
+ const report = (error) => reportSessionSettlementFailure(parameters.onSessionSettlementFailure, {
243
+ chainId: channel.chainId,
244
+ channelId: channel.channelId,
245
+ error,
246
+ trigger: 'scheduled',
247
+ });
247
248
  const leaseOwner = await claimScheduledSettlement({
248
249
  channelId: channel.channelId,
249
250
  schedule,
250
251
  store,
252
+ }).catch(async (error) => {
253
+ await report(error);
254
+ throw error;
251
255
  });
252
256
  if (!leaseOwner)
253
257
  return undefined;
@@ -258,26 +262,28 @@ export async function maybeSettleScheduled(parameters) {
258
262
  store,
259
263
  }).catch(() => undefined);
260
264
  }, scheduledSettlementLeaseMs / 2);
261
- try {
262
- const txHash = await settle(store, parameters.client, channel.channelId, {
263
- account: parameters.account,
264
- ...(parameters.feePayer ? { feePayer: parameters.feePayer } : {}),
265
- ...(parameters.feePayerPolicy ? { feePayerPolicy: parameters.feePayerPolicy } : {}),
266
- ...(parameters.feeToken ? { feeToken: parameters.feeToken } : {}),
267
- onSessionSettlement: parameters.onSessionSettlement
268
- ? (ctx) => parameters.onSessionSettlement({ ...ctx, trigger: 'scheduled' })
269
- : undefined,
270
- });
271
- await markSettlementComplete({ channelId: channel.channelId, leaseOwner, store });
272
- return txHash;
273
- }
274
- catch (error) {
275
- await releaseScheduledSettlement({ channelId: channel.channelId, leaseOwner, store }).catch(() => undefined);
265
+ const release = () => releaseScheduledSettlement({ channelId: channel.channelId, leaseOwner, store }).catch(() => undefined);
266
+ const txHash = await settle(store, parameters.client, channel.channelId, {
267
+ account: parameters.account,
268
+ ...(parameters.feePayer ? { feePayer: parameters.feePayer } : {}),
269
+ ...(parameters.feePayerPolicy ? { feePayerPolicy: parameters.feePayerPolicy } : {}),
270
+ ...(parameters.feeToken ? { feeToken: parameters.feeToken } : {}),
271
+ onSessionSettlement: parameters.onSessionSettlement
272
+ ? (ctx) => parameters.onSessionSettlement({ ...ctx, trigger: 'scheduled' })
273
+ : undefined,
274
+ })
275
+ .catch(async (error) => {
276
+ await release();
277
+ // The transaction confirmed and collected the charge; only the local record failed.
278
+ if (error instanceof SettlementCheckpointError)
279
+ return error.txHash;
280
+ await report(error);
276
281
  throw error;
277
- }
278
- finally {
279
- clearInterval(renewal);
280
- }
282
+ })
283
+ .finally(() => clearInterval(renewal));
284
+ // Bookkeeping after a confirmed settlement must not fail the charged request.
285
+ await markSettlementComplete({ channelId: channel.channelId, leaseOwner, store }).catch(release);
286
+ return txHash;
281
287
  }
282
288
  /** Settles the highest accepted voucher for a precompile-backed session channel. */
283
289
  export async function settle(store_, client, channelId_, options) {
@@ -319,15 +325,21 @@ export async function settle(store_, client, channelId_, options) {
319
325
  throw new VerificationFailedError({
320
326
  reason: 'on-chain channel state does not match settle receipt',
321
327
  });
322
- await store.updateChannel(channelId, (current) => current
328
+ let checkpointError;
329
+ await store
330
+ .updateChannel(channelId, (current) => current
323
331
  ? {
324
332
  ...current,
325
333
  settledOnChain: newSettled > current.settledOnChain ? newSettled : current.settledOnChain,
326
334
  lastSettlementAt: new Date().toISOString(),
327
- lastSettlementSpent: current.spent,
328
- lastSettlementUnits: current.units,
335
+ // Charges accepted after the voucher was read are not covered by this transaction.
336
+ lastSettlementSpent: ChannelStore.keepGreater(current.lastSettlementSpent ?? 0n, channel.spent),
337
+ lastSettlementUnits: Math.max(current.lastSettlementUnits ?? 0, channel.units),
329
338
  }
330
- : current);
339
+ : current)
340
+ .catch((cause) => {
341
+ checkpointError = { cause };
342
+ });
331
343
  if (options?.onSessionSettlement) {
332
344
  await emitSessionSettlement(options.onSessionSettlement, {
333
345
  txHash,
@@ -337,8 +349,20 @@ export async function settle(store_, client, channelId_, options) {
337
349
  delta: newSettled - channel.settledOnChain,
338
350
  });
339
351
  }
352
+ if (checkpointError)
353
+ throw new SettlementCheckpointError({ cause: checkpointError.cause, txHash });
340
354
  return txHash;
341
355
  }
356
+ /** Raised when a settlement transaction confirmed but the channel store could not record it. */
357
+ export class SettlementCheckpointError extends Error {
358
+ name = 'SettlementCheckpointError';
359
+ /** Hash of the confirmed settlement transaction. */
360
+ txHash;
361
+ constructor(options) {
362
+ super(`Settlement ${options.txHash} confirmed but was not recorded.`, { cause: options.cause });
363
+ this.txHash = options.txHash;
364
+ }
365
+ }
342
366
  /** Settles multiple precompile-backed session channels with the same validation as {@link settle}. */
343
367
  export async function settleBatch(store, client, channelIds, options) {
344
368
  const hashes = [];
@@ -346,6 +370,36 @@ export async function settleBatch(store, client, channelIds, options) {
346
370
  hashes.push(await settle(store, client, channelId, options));
347
371
  return hashes;
348
372
  }
373
+ /**
374
+ * @internal Keeps a charged request served when its scheduled settlement hit a transport or RPC
375
+ * failure; the next settlement retries it. Reverts and configuration errors fail the request.
376
+ */
377
+ export function ignoreRetryableSettlementFailure(error) {
378
+ if (isRetryableSettlementFailure(error))
379
+ return undefined;
380
+ throw error;
381
+ }
382
+ function isRetryableSettlementFailure(error) {
383
+ if (!(error instanceof viem_BaseError))
384
+ return false;
385
+ // Only unavailable or overloaded upstreams clear on retry; node-rejected transactions repeat.
386
+ return Boolean(error.walk((cause) => (cause instanceof HttpRequestError &&
387
+ (cause.status === undefined || cause.status === 429 || cause.status >= 500)) ||
388
+ cause instanceof LimitExceededRpcError ||
389
+ cause instanceof ResourceUnavailableRpcError ||
390
+ cause instanceof SocketClosedError ||
391
+ cause instanceof TimeoutError ||
392
+ cause instanceof WebSocketRequestError));
393
+ }
394
+ /** @internal Reports a settlement failure without letting observer errors replace it. */
395
+ export async function reportSessionSettlementFailure(onSessionSettlementFailure, context) {
396
+ try {
397
+ await onSessionSettlementFailure?.(Object.freeze(context));
398
+ }
399
+ catch {
400
+ // Errors are isolated: observers cannot replace the settlement failure.
401
+ }
402
+ }
349
403
  async function emitSessionSettlement(onSessionSettlement, context) {
350
404
  try {
351
405
  await onSessionSettlement(Object.freeze(context));
@@ -2,5 +2,5 @@ export { charge, session, settle, settleBatch } from './Session.js';
2
2
  /** SSE helpers and types for Tempo session streams. */
3
3
  export * as Sse from './Sse.js';
4
4
  /** Server-side automatic settlement schedule. */
5
- export type { OnSessionSettlement, ResolveSessionChannelId, ResolveSessionChannelIdParameters, SessionChannelIdRequest, SessionSettlementContext, SettlementSchedule, } from './Session.js';
5
+ export type { OnSessionSettlement, OnSessionSettlementFailure, ResolveSessionChannelId, ResolveSessionChannelIdParameters, SessionChannelIdRequest, SessionSettlementContext, SessionSettlementFailureContext, SettlementSchedule, } from './Session.js';
6
6
  //# sourceMappingURL=index.d.ts.map
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "mppx",
3
3
  "type": "module",
4
- "version": "0.13.1",
4
+ "version": "0.13.3",
5
5
  "main": "./dist/index.js",
6
6
  "license": "MIT",
7
7
  "homepage": "https://github.com/wevm/mppx#readme",