mppx 0.17.3 → 0.17.4

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 (41) hide show
  1. package/CHANGELOG.md +37 -0
  2. package/dist/Errors.d.ts +94 -0
  3. package/dist/Errors.js +83 -0
  4. package/dist/Store.d.ts +199 -11
  5. package/dist/Store.js +527 -48
  6. package/dist/cli/cli.js +141 -137
  7. package/dist/cli/internal.js +28 -6
  8. package/dist/cli/plugins/index.js +16 -12
  9. package/dist/cli/validate/challenge.d.ts +4 -0
  10. package/dist/cli/validate/challenge.js +9 -6
  11. package/dist/cloudflare/ChannelStore.d.ts +97 -0
  12. package/dist/cloudflare/ChannelStore.js +304 -0
  13. package/dist/cloudflare/Channels.d.ts +92 -0
  14. package/dist/cloudflare/Channels.js +325 -0
  15. package/dist/cloudflare/index.d.ts +5 -0
  16. package/dist/cloudflare/index.js +5 -0
  17. package/dist/internal/sse.js +32 -15
  18. package/dist/internal/version.d.ts +2 -2
  19. package/dist/internal/version.js +1 -1
  20. package/dist/stripe/server/Methods.d.ts +2 -0
  21. package/dist/stripe/server/Methods.js +2 -1
  22. package/dist/stripe/server/internal/record-payment.d.ts +49 -0
  23. package/dist/stripe/server/internal/record-payment.js +75 -18
  24. package/dist/tempo/Methods.d.ts +8 -0
  25. package/dist/tempo/Methods.js +18 -2
  26. package/dist/tempo/client/Charge.d.ts +32 -1
  27. package/dist/tempo/client/Charge.js +201 -3
  28. package/dist/tempo/client/Methods.d.ts +16 -0
  29. package/dist/tempo/internal/fee-payer.d.ts +0 -25
  30. package/dist/tempo/internal/fee-payer.js +50 -3
  31. package/dist/tempo/internal/zone.d.ts +79 -0
  32. package/dist/tempo/internal/zone.js +133 -0
  33. package/dist/tempo/server/Charge.d.ts +7 -3
  34. package/dist/tempo/server/Charge.js +98 -4
  35. package/dist/tempo/server/Methods.d.ts +40 -0
  36. package/dist/tempo/server/internal/html.gen.js +1 -1
  37. package/dist/tempo/session/server/ChannelStore.d.ts +5 -0
  38. package/dist/tempo/session/server/ChannelStore.js +2 -2
  39. package/dist/tempo/session/server/CredentialVerification.js +1 -1
  40. package/dist/validation/core.js +2 -0
  41. package/package.json +5 -1
@@ -15,6 +15,10 @@ export declare function validateChallenge(baseUrl: string, endpoint: EndpointSpe
15
15
  export declare function validateRealmMatchesHost(challenges: Challenge.Challenge[], serverHost: string, results: CheckResult[]): void;
16
16
  export declare function validateErrorHandling(baseUrl: string, endpoint: EndpointSpec, options?: {
17
17
  body?: string | undefined;
18
+ /** The Challenge whose Credential header the probe targets. Defaults to `Authorization`. */
19
+ challenge?: Challenge.Challenge | undefined;
20
+ /** Extra `Name: value` headers (`-H`), such as an API key the endpoint requires. */
21
+ extraHeaders?: string[] | undefined;
18
22
  query?: string[] | undefined;
19
23
  }): Promise<CheckResult[]>;
20
24
  //# sourceMappingURL=challenge.d.ts.map
@@ -289,13 +289,16 @@ function validateAmount(request, tag, results, unitHint) {
289
289
  export async function validateErrorHandling(baseUrl, endpoint, options) {
290
290
  const results = [];
291
291
  const url = buildUrl(baseUrl, endpoint, options?.query);
292
- const fetchHeaders = {
293
- [Constants.Headers.authorization]: `${Constants.Schemes.payment} dGhpcyBpcyBnYXJiYWdl`,
294
- };
292
+ const fetchHeaders = parseHeaders(options?.extraHeaders);
293
+ const credentialHeader = options?.challenge
294
+ ? Challenge.credentialHeader(options.challenge)
295
+ : Constants.Headers.authorization;
296
+ fetchHeaders[credentialHeader.toLowerCase()] = `${Constants.Schemes.payment} dGhpcyBpcyBnYXJiYWdl`;
295
297
  let fetchBody;
296
298
  if (options?.body) {
297
299
  fetchBody = options.body;
298
- fetchHeaders['content-type'] = 'application/json';
300
+ if (!fetchHeaders['content-type'])
301
+ fetchHeaders['content-type'] = 'application/json';
299
302
  }
300
303
  try {
301
304
  const response = await fetchWithTimeout(url, {
@@ -314,10 +317,10 @@ export async function validateErrorHandling(baseUrl, endpoint, options) {
314
317
  }
315
318
  }
316
319
  else if (response.status >= 500) {
317
- results.push(fail('Malformed credential returns 402', `Got ${response.status} (server error)`, 'When the Authorization header contains an invalid credential, respond with 402 (not 500). Catch credential validation errors and return a fresh challenge.'));
320
+ results.push(fail('Malformed credential returns 402', `Got ${response.status} (server error)`, `When the ${credentialHeader} header contains an invalid credential, respond with 402 (not 500). Catch credential validation errors and return a fresh challenge.`));
318
321
  }
319
322
  else {
320
- results.push(warn('Malformed credential returns 402', `Got ${response.status}`, `When the Authorization header contains an invalid Payment credential, respond with 402 and a fresh WWW-Authenticate challenge. Returning ${response.status} prevents the client from retrying with a valid payment.`));
323
+ results.push(warn('Malformed credential returns 402', `Got ${response.status}`, `When the ${credentialHeader} header contains an invalid Payment credential, respond with 402 and a fresh WWW-Authenticate challenge. Returning ${response.status} prevents the client from retrying with a valid payment.`));
321
324
  }
322
325
  }
323
326
  catch (error) {
@@ -0,0 +1,97 @@
1
+ import type { Hex } from 'viem';
2
+ import * as Store from '../Store.js';
3
+ import { type State } from '../tempo/session/server/ChannelStore.js';
4
+ /**
5
+ * Durable Object class that stores Tempo session channels, as {@link Store.durableObject}
6
+ * does, and settles them on-chain without a request in flight.
7
+ *
8
+ * Every channel lives in its own object. After each write of a channel state, the object
9
+ * checks how much a settlement could capture ({@link unsettledAmount}): from
10
+ * `max(10% of deposit, minUnsettledAmount)` it sends `{ channelId }` to the settlement
11
+ * queue at once; below that it sets an alarm that sends it once the channel has had no
12
+ * write for `staleAfterMs`. The Worker's `queue()` handler settles it with
13
+ * {@link settle}. After each send, a watchdog alarm sends it again while the channel
14
+ * stays unsettled (a dropped, dead-lettered or failed job), waiting twice as long each
15
+ * time, up to `maxAttempts` sends; settling more on-chain restarts the count. A close request,
16
+ * or a threshold's worth of new vouchers since the last send, sends it again at once and
17
+ * restarts the count, so a failed job never leaves them waiting on a long watchdog.
18
+ *
19
+ * Export a subclass from your Worker, bind it with a `new_sqlite_classes` migration and
20
+ * bind the queue as `MPPX_SETTLEMENT_QUEUE`, or override
21
+ * {@link ChannelStore.settlement} to change the queue or thresholds.
22
+ *
23
+ * @example
24
+ * ```ts
25
+ * import { DurableObject } from 'cloudflare:workers'
26
+ * import { ChannelStore } from 'mppx/cloudflare'
27
+ *
28
+ * export class MppxChannels extends ChannelStore.rpc(DurableObject) {}
29
+ * ```
30
+ */
31
+ export declare class ChannelStore extends Store.DurableObjectStore {
32
+ /**
33
+ * The settlement queue and thresholds. Reads the `MPPX_SETTLEMENT_QUEUE` binding by
34
+ * default; a subclass overrides it to use another binding or other thresholds.
35
+ */
36
+ protected settlement(): ChannelStore.Settlement;
37
+ /** Schedules settlement of a written channel state, and drops it for a closed or deleted one. */
38
+ protected onWrite(key: string, _previous: unknown, next: unknown, transaction?: Store.DurableObjectStore.Storage | undefined): Promise<void>;
39
+ /** Sends a channel over the threshold or gone stale to the queue, or sends it again from the watchdog while unsettled. */
40
+ alarm(): Promise<void>;
41
+ /**
42
+ * Sends the channel to the settlement queue and sets the watchdog alarm, or gives up after
43
+ * `maxAttempts` sends. A send that fails leaves the count alone and tries again from an
44
+ * alarm, since the write that scheduled it has already committed.
45
+ */
46
+ private send;
47
+ }
48
+ export declare namespace ChannelStore {
49
+ /** The settlement queue and when to settle. */
50
+ type Settlement = {
51
+ /** Queue the object sends `{ channelId }` to; the Worker's `queue()` handler settles it. */
52
+ queue: Queue | undefined;
53
+ /** Raw amount a settlement must capture before it is sent at once, if 10% of the deposit is less. @default 100_000n */
54
+ minUnsettledAmount?: bigint | undefined;
55
+ /** Sends a channel below the threshold once it has had no write for this long. @default 300_000 */
56
+ staleAfterMs?: number | undefined;
57
+ /**
58
+ * Sends a channel again this long after a send while it stays unsettled, doubling each time.
59
+ * Keep the first resend well inside the escrow's close grace period (15 minutes), after which
60
+ * a payer who requested a close can withdraw what is unsettled; a closing channel is sent
61
+ * again after at most 2 minutes. @default 300_000
62
+ */
63
+ watchdogMs?: number | undefined;
64
+ /** Sends a channel at most this many times without on-chain progress. @default 6 */
65
+ maxAttempts?: number | undefined;
66
+ /** Tries a send that failed again this much later. @default 60_000 */
67
+ retryMs?: number | undefined;
68
+ };
69
+ /** The subset of a Cloudflare `Queue` binding the object uses. */
70
+ type Queue = {
71
+ send: (body: Message) => Promise<unknown>;
72
+ };
73
+ /** The settlement job the object sends. */
74
+ type Message = {
75
+ channelId: Hex;
76
+ };
77
+ }
78
+ /** Whether a stored value is a precompile-backed session channel state. */
79
+ export declare function isChannelState(value: unknown): value is State;
80
+ /**
81
+ * The raw amount a settlement of the channel would capture now: the largest accepted voucher
82
+ * within `spent` (as mppx's `settle` picks it) above `settledOnChain`.
83
+ */
84
+ export declare function unsettledAmount(state: State): bigint;
85
+ /**
86
+ * Whether the server's own cooperative close may still land: its marker stands in
87
+ * `closeRequestedAt` and its transaction has not expired. That close captures every voucher a
88
+ * settlement could, so settling meanwhile only spends gas.
89
+ */
90
+ export declare function pendingClose(state: State, now?: number): boolean;
91
+ /**
92
+ * When the channel needs settling: `threshold` (now) once the unsettled amount reaches
93
+ * `max(10% of deposit, minUnsettledAmount)`, `stale` (once idle) below it, or
94
+ * `undefined` when it is finalized or has nothing to settle.
95
+ */
96
+ export declare function settlementDue(state: State, settlement?: Pick<ChannelStore.Settlement, 'minUnsettledAmount'>): 'stale' | 'threshold' | undefined;
97
+ //# sourceMappingURL=ChannelStore.d.ts.map
@@ -0,0 +1,304 @@
1
+ import { Json } from 'ox';
2
+ import * as Store from '../Store.js';
3
+ import { pendingCloseClockSkewSeconds, pendingCloseValiditySeconds, resolveSettleableVoucher, } from '../tempo/session/server/ChannelStore.js';
4
+ /** Storage key of a channel object's settlement bookkeeping, beside the channel state. */
5
+ const settlementKey = 'mppx:channel-store:settlement';
6
+ /**
7
+ * Durable Object class that stores Tempo session channels, as {@link Store.durableObject}
8
+ * does, and settles them on-chain without a request in flight.
9
+ *
10
+ * Every channel lives in its own object. After each write of a channel state, the object
11
+ * checks how much a settlement could capture ({@link unsettledAmount}): from
12
+ * `max(10% of deposit, minUnsettledAmount)` it sends `{ channelId }` to the settlement
13
+ * queue at once; below that it sets an alarm that sends it once the channel has had no
14
+ * write for `staleAfterMs`. The Worker's `queue()` handler settles it with
15
+ * {@link settle}. After each send, a watchdog alarm sends it again while the channel
16
+ * stays unsettled (a dropped, dead-lettered or failed job), waiting twice as long each
17
+ * time, up to `maxAttempts` sends; settling more on-chain restarts the count. A close request,
18
+ * or a threshold's worth of new vouchers since the last send, sends it again at once and
19
+ * restarts the count, so a failed job never leaves them waiting on a long watchdog.
20
+ *
21
+ * Export a subclass from your Worker, bind it with a `new_sqlite_classes` migration and
22
+ * bind the queue as `MPPX_SETTLEMENT_QUEUE`, or override
23
+ * {@link ChannelStore.settlement} to change the queue or thresholds.
24
+ *
25
+ * @example
26
+ * ```ts
27
+ * import { DurableObject } from 'cloudflare:workers'
28
+ * import { ChannelStore } from 'mppx/cloudflare'
29
+ *
30
+ * export class MppxChannels extends ChannelStore.rpc(DurableObject) {}
31
+ * ```
32
+ */
33
+ export class ChannelStore extends Store.DurableObjectStore {
34
+ /**
35
+ * The settlement queue and thresholds. Reads the `MPPX_SETTLEMENT_QUEUE` binding by
36
+ * default; a subclass overrides it to use another binding or other thresholds.
37
+ */
38
+ settlement() {
39
+ const env = this.env;
40
+ return { queue: env?.MPPX_SETTLEMENT_QUEUE };
41
+ }
42
+ /** Schedules settlement of a written channel state, and drops it for a closed or deleted one. */
43
+ async onWrite(key, _previous, next, transaction) {
44
+ // Writing through the store's transaction, when it has one, commits or rolls back with the write.
45
+ const storage = transaction ?? this.storage;
46
+ const pending = (await storage.get(settlementKey));
47
+ const settlement = resolveSettlement(this.settlement());
48
+ const due = isChannelState(next) ? settlementDue(next, settlement) : undefined;
49
+ if (!due || !isChannelState(next)) {
50
+ if (pending?.key === key)
51
+ await storage.delete(settlementKey);
52
+ return;
53
+ }
54
+ // A job settled more on-chain since it was sent: the job is done, so restart the count.
55
+ const progressed = pending?.settledAtSend !== undefined && next.settledOnChain > pending.settledAtSend;
56
+ // Work the last send did not cover: a close request (once the escrow's grace period ends,
57
+ // the payer can withdraw what is unsettled) or a threshold's worth of new vouchers. Send it
58
+ // at once and restart the count instead of waiting for a watchdog that may be far off.
59
+ const renewed = !!pending?.sends &&
60
+ ((closing(next) && closeRequest(next) !== pending.closeRequestAtSend) ||
61
+ (pending.capturedAtSend !== undefined &&
62
+ settleableAmount(next) - pending.capturedAtSend >= threshold(next, settlement)));
63
+ const sending = progressed || renewed ? undefined : pending?.sends ? pending : undefined;
64
+ const updated = {
65
+ channelId: next.channelId,
66
+ key,
67
+ sends: sending?.sends ?? 0,
68
+ updatedAt: Date.now(),
69
+ ...(sending
70
+ ? {
71
+ capturedAtSend: sending.capturedAtSend,
72
+ closeRequestAtSend: sending.closeRequestAtSend,
73
+ settledAtSend: sending.settledAtSend,
74
+ watchdogAt: sending.watchdogAt,
75
+ }
76
+ : {}),
77
+ };
78
+ await storage.put(settlementKey, updated);
79
+ // A job is in flight: it settles the latest voucher, and its watchdog alarm stays, waking no
80
+ // later than the end of the server's close marker, after which a payer close request it hid shows.
81
+ if (sending)
82
+ return wakeBy(storage, pendingCloseEnd(next) ?? Infinity);
83
+ // Sent from an alarm that fires at once: a queue send here would let another write in before this one commits.
84
+ const at = due === 'threshold' || closing(next)
85
+ ? Date.now()
86
+ : Math.min(updated.updatedAt + settlement.staleAfterMs, pendingCloseEnd(next) ?? Infinity);
87
+ await wakeBy(storage, at);
88
+ }
89
+ /** Sends a channel over the threshold or gone stale to the queue, or sends it again from the watchdog while unsettled. */
90
+ async alarm() {
91
+ await super.alarm();
92
+ let pending = (await this.storage.get(settlementKey));
93
+ if (!pending)
94
+ return;
95
+ const raw = await this.storage.get(pending.key);
96
+ // The store writes JSON text, or native values with `encoding: 'native'`.
97
+ const state = typeof raw === 'string' ? Json.parse(raw) : (raw ?? null);
98
+ const settlement = resolveSettlement(this.settlement());
99
+ const due = isChannelState(state) ? settlementDue(state, settlement) : undefined;
100
+ if (!due) {
101
+ await this.storage.delete(settlementKey);
102
+ return;
103
+ }
104
+ // The channel had a write since this alarm was set: wait until it has been idle long enough.
105
+ const staleAt = pending.updatedAt + settlement.staleAfterMs;
106
+ if (!pending.sends && due === 'stale' && !closing(state) && Date.now() < staleAt)
107
+ return this.storage.setAlarm(Math.min(staleAt, pendingCloseEnd(state) ?? Infinity));
108
+ // A close request the last send did not cover, such as one the server's close marker hid until
109
+ // it expired, restarts the count as a write would.
110
+ if (pending.sends &&
111
+ closing(state) &&
112
+ closeRequest(state) !== pending.closeRequestAtSend) {
113
+ pending = { ...pending, sends: 0 };
114
+ await this.storage.put(settlementKey, pending);
115
+ }
116
+ // An alarm set for something that no longer needs a send, such as the end of a close marker a
117
+ // failed close then cleared: wait for the watchdog rather than spend an attempt early.
118
+ // A threshold's worth of vouchers the last send did not capture, such as ones written while
119
+ // it was in flight, still goes at once.
120
+ const uncaptured = pending.capturedAtSend !== undefined &&
121
+ settleableAmount(state) - pending.capturedAtSend >=
122
+ threshold(state, settlement);
123
+ if (pending.sends &&
124
+ !uncaptured &&
125
+ pending.watchdogAt !== undefined &&
126
+ Date.now() < pending.watchdogAt)
127
+ return this.storage.setAlarm(Math.min(pending.watchdogAt, pendingCloseEnd(state) ?? Infinity));
128
+ await this.send(pending, state, settlement, this.storage);
129
+ }
130
+ /**
131
+ * Sends the channel to the settlement queue and sets the watchdog alarm, or gives up after
132
+ * `maxAttempts` sends. A send that fails leaves the count alone and tries again from an
133
+ * alarm, since the write that scheduled it has already committed.
134
+ */
135
+ async send(pending, state, settlement, storage) {
136
+ const sends = pending.sends + 1;
137
+ if (sends > settlement.maxAttempts) {
138
+ // A live close marker may hide a payer close request merged into it: wake when it ends,
139
+ // when a close request restarts the count, rather than giving up for good now.
140
+ const markerEnd = pendingCloseEnd(state);
141
+ if (markerEnd !== undefined)
142
+ return wakeBy(storage, markerEnd);
143
+ await storage.delete(settlementKey);
144
+ return;
145
+ }
146
+ // A missing binding is a configuration error: fail the alarm so it shows in the Worker's
147
+ // logs and Cloudflare retries it with backoff, rather than waking every minute in silence.
148
+ if (!settlement.queue)
149
+ throw new Error('ChannelStore: no settlement queue (bind MPPX_SETTLEMENT_QUEUE).');
150
+ try {
151
+ await settlement.queue.send({ channelId: pending.channelId });
152
+ }
153
+ catch {
154
+ // Retry from the alarm without waiting for the last watchdog. Keep a sooner alarm that a
155
+ // write during the send set, such as for a close request.
156
+ const current = (await storage.get(settlementKey));
157
+ if (current?.key === pending.key && current.watchdogAt !== undefined)
158
+ await storage.put(settlementKey, { ...current, watchdogAt: undefined });
159
+ await wakeBy(storage, Date.now() + settlement.retryMs);
160
+ return;
161
+ }
162
+ // The send awaited the queue, so a channel write may have changed the bookkeeping since
163
+ // (such as restarting the count after progress): count this send against what is there now.
164
+ const current = (await storage.get(settlementKey));
165
+ if (current?.key !== pending.key)
166
+ return;
167
+ const counted = current.sends + 1;
168
+ const watchdog = settlement.watchdogMs * 2 ** (counted - 1);
169
+ // Once the escrow's grace period ends, the payer can withdraw what is unsettled: resend a
170
+ // closing channel often enough that every attempt fits inside it.
171
+ const watchdogAt = Date.now() + (closing(state) ? Math.min(watchdog, closingWatchdogMs) : watchdog);
172
+ await storage.put(settlementKey, {
173
+ ...current,
174
+ capturedAtSend: settleableAmount(state),
175
+ closeRequestAtSend: closeRequest(state),
176
+ sends: counted,
177
+ settledAtSend: state.settledOnChain,
178
+ watchdogAt,
179
+ });
180
+ // A write during the send (such as a close request) may have set a sooner alarm from a newer
181
+ // state: keep it.
182
+ await wakeBy(storage, Math.min(watchdogAt, pendingCloseEnd(state) ?? Infinity));
183
+ }
184
+ }
185
+ /**
186
+ * Longest wait before sending a closing channel again: its default six sends then span 10
187
+ * minutes, inside the escrow's 15-minute close grace period.
188
+ */
189
+ const closingWatchdogMs = 2 * 60_000;
190
+ /** Fills in the defaults of a {@link ChannelStore.Settlement}. */
191
+ function resolveSettlement(settlement) {
192
+ const resolved = {
193
+ maxAttempts: settlement.maxAttempts ?? 6,
194
+ minUnsettledAmount: settlement.minUnsettledAmount ?? 100000n,
195
+ queue: settlement.queue,
196
+ retryMs: settlement.retryMs ?? 60_000,
197
+ staleAfterMs: settlement.staleAfterMs ?? 300_000,
198
+ watchdogMs: settlement.watchdogMs ?? 5 * 60_000,
199
+ };
200
+ // A non-finite delay would schedule no alarm at all, silently never settling.
201
+ for (const name of ['retryMs', 'staleAfterMs', 'watchdogMs'])
202
+ if (!(Number.isFinite(resolved[name]) && resolved[name] >= 0))
203
+ throw new Error(`ChannelStore: \`${name}\` must be a finite, non-negative number.`);
204
+ if (!(Number.isSafeInteger(resolved.maxAttempts) && resolved.maxAttempts > 0))
205
+ throw new Error('ChannelStore: `maxAttempts` must be a positive integer.');
206
+ return resolved;
207
+ }
208
+ /** Whether a stored value is a precompile-backed session channel state. */
209
+ export function isChannelState(value) {
210
+ if (typeof value !== 'object' || value === null)
211
+ return false;
212
+ const state = value;
213
+ return (state.backend === 'precompile' &&
214
+ typeof state.channelId === 'string' &&
215
+ typeof state.chainId === 'number' &&
216
+ typeof state.deposit === 'bigint' &&
217
+ typeof state.settledOnChain === 'bigint' &&
218
+ typeof state.spent === 'bigint');
219
+ }
220
+ /**
221
+ * The raw amount a settlement of the channel would capture now: the largest accepted voucher
222
+ * within `spent` (as mppx's `settle` picks it) above `settledOnChain`.
223
+ */
224
+ export function unsettledAmount(state) {
225
+ return settleableAmount(state) - state.settledOnChain;
226
+ }
227
+ /** The cumulative amount a settlement of the channel would bring it to: its settleable voucher's, or `settledOnChain`. */
228
+ function settleableAmount(state) {
229
+ return resolveSettleableVoucher(state)?.cumulativeAmount ?? state.settledOnChain;
230
+ }
231
+ /** Whether the payer requested a close, after which the escrow's grace period runs. */
232
+ function closing(state) {
233
+ return closeRequest(state) !== 0n;
234
+ }
235
+ /**
236
+ * The payer's close request: `closeRequestedAt`, or `0n` without one. The server's own
237
+ * cooperative-close marker ({@link State.pendingCloseAt}) is not one while that close may still
238
+ * land ({@link pendingClose}): it captures the vouchers itself. An on-chain close request merged
239
+ * into the marker keeps its timestamp when the payer's is not later, so once the marker can no
240
+ * longer land, it counts as one.
241
+ */
242
+ function closeRequest(state) {
243
+ if (pendingClose(state))
244
+ return 0n;
245
+ return BigInt(state.closeRequestedAt ?? 0n);
246
+ }
247
+ /**
248
+ * Unix milliseconds at which the server's close marker stops counting as {@link pendingClose},
249
+ * or `undefined` when it does not count now.
250
+ */
251
+ function pendingCloseEnd(state) {
252
+ if (!pendingClose(state))
253
+ return undefined;
254
+ return ((Number(state.closeRequestedAt) +
255
+ pendingCloseValiditySeconds +
256
+ pendingCloseClockSkewSeconds +
257
+ 1) *
258
+ 1000);
259
+ }
260
+ /** Sets the alarm to `at`, unless it already fires sooner. */
261
+ async function wakeBy(storage, at) {
262
+ if (!Number.isFinite(at))
263
+ return;
264
+ const alarm = await storage.getAlarm();
265
+ if (alarm === null || at < alarm)
266
+ await storage.setAlarm(at);
267
+ }
268
+ /**
269
+ * Whether the server's own cooperative close may still land: its marker stands in
270
+ * `closeRequestedAt` and its transaction has not expired. That close captures every voucher a
271
+ * settlement could, so settling meanwhile only spends gas.
272
+ */
273
+ export function pendingClose(state, now = Date.now()) {
274
+ if (state.pendingCloseAt === undefined || state.finalized)
275
+ return false;
276
+ const requestedAt = BigInt(state.closeRequestedAt ?? 0n);
277
+ if (requestedAt === 0n || requestedAt !== BigInt(state.pendingCloseAt))
278
+ return false;
279
+ const expiresAt = Number(requestedAt) + pendingCloseValiditySeconds + pendingCloseClockSkewSeconds;
280
+ return Math.floor(now / 1000) <= expiresAt;
281
+ }
282
+ /** The unsettled amount that sends a channel at once: `max(10% of deposit, minUnsettledAmount)`. */
283
+ function threshold(state, settlement) {
284
+ const minimum = settlement.minUnsettledAmount ?? 100000n;
285
+ const tenth = state.deposit / 10n;
286
+ const amount = tenth > minimum ? tenth : minimum;
287
+ // At least 1: a zero threshold would count every write as a threshold's worth of new vouchers
288
+ // and restart the send count forever, past `maxAttempts`.
289
+ return amount > 0n ? amount : 1n;
290
+ }
291
+ /**
292
+ * When the channel needs settling: `threshold` (now) once the unsettled amount reaches
293
+ * `max(10% of deposit, minUnsettledAmount)`, `stale` (once idle) below it, or
294
+ * `undefined` when it is finalized or has nothing to settle.
295
+ */
296
+ export function settlementDue(state, settlement = {}) {
297
+ if (state.finalized)
298
+ return undefined;
299
+ const unsettled = unsettledAmount(state);
300
+ if (unsettled <= 0n)
301
+ return undefined;
302
+ return unsettled >= threshold(state, settlement) ? 'threshold' : 'stale';
303
+ }
304
+ //# sourceMappingURL=ChannelStore.js.map
@@ -0,0 +1,92 @@
1
+ import { type Address } from 'viem';
2
+ import type { MaybePromise } from '../internal/types.js';
3
+ import * as Store from '../Store.js';
4
+ import type { TransactionClient } from '../tempo/session/precompile/Chain.js';
5
+ import type { ResolveSessionChannelId } from '../tempo/session/server/RequestState.js';
6
+ import { settle as settleChannel, type SettlementTransactionOptions } from '../tempo/session/server/Settlement.js';
7
+ /**
8
+ * Tempo session channel storage on a {@link ChannelStore} Durable Object namespace, to spread
9
+ * into `tempo.session()`: `store` keeps each channel in its own object, which schedules its
10
+ * settlement, and `resolveChannelId` finds the payer's open channel to the same payee, token,
11
+ * escrow, operator and chain, so a client that lost its state resumes it instead of opening another.
12
+ * The lookup needs the payer's identity, which only a `bootstrap` HEAD proof supplies: enable
13
+ * `bootstrap` on the session and in the client's session manager, or it never resolves.
14
+ *
15
+ * The store keeps that lookup current: after a write opens, closes or finalizes a channel,
16
+ * it adds or removes the channel under its payer and authorized signer. Each lookup entry is
17
+ * its own key holding the payer's open channels there, most recent first, so closing one leaves
18
+ * the others findable. The lookup returns the first whose channel still resumes, so a delayed
19
+ * write for a channel that has since closed cannot hide an open one, and mppx validates any
20
+ * channel it resolves before reusing it.
21
+ *
22
+ * @example
23
+ * ```ts
24
+ * import { channels } from 'mppx/cloudflare'
25
+ * import { tempo } from 'mppx/server'
26
+ *
27
+ * tempo.session({ account, bootstrap: true, currency, getClient, ...channels(env.MPPX_CHANNELS) })
28
+ * ```
29
+ */
30
+ export declare function channels(namespace: channels.Namespace, options?: channels.Options): channels.ReturnType;
31
+ export declare namespace channels {
32
+ /** The Durable Object namespace binding of a {@link ChannelStore} class. */
33
+ type Namespace = Store.durableObject.Namespace;
34
+ /** Options of {@link channels}: those of {@link Store.durableObject}, and the escrow. */
35
+ type Options = Store.durableObject.Options & {
36
+ /** Escrow the lookup assumes when a challenge names none. @default the TIP20EscrowChannel precompile */
37
+ escrowContract?: Address | undefined;
38
+ };
39
+ /** What `tempo.session()` takes from {@link channels}. */
40
+ type ReturnType = {
41
+ /** Finds the payer's open channel for a challenge's payee, token, escrow and chain. */
42
+ resolveChannelId: ResolveSessionChannelId;
43
+ /** Channel store that schedules settlement and keeps the channel lookup current. */
44
+ store: Store.AtomicStore;
45
+ };
46
+ }
47
+ /**
48
+ * Settles the channels of a settlement queue batch on-chain, from the Worker's `queue()`
49
+ * handler. Each message is acknowledged once its channel settled or has nothing left to
50
+ * settle, and when settlement cannot succeed (the channel is gone or has no voucher); any
51
+ * other failure, such as a fee over the fee payer's cap, retries it after 30 s, doubling up
52
+ * to 10 minutes. The {@link ChannelStore} watchdog sends a channel again if its job never settles it.
53
+ *
54
+ * @example
55
+ * ```ts
56
+ * import { channels, settle } from 'mppx/cloudflare'
57
+ *
58
+ * export default {
59
+ * async queue(batch, env) {
60
+ * await settle(batch, { account, getClient, store: channels(env.MPPX_CHANNELS).store })
61
+ * },
62
+ * }
63
+ * ```
64
+ */
65
+ export declare function settle(batch: settle.Batch, parameters: settle.Parameters): Promise<void>;
66
+ export declare namespace settle {
67
+ /** Parameters of {@link settle}: the channel store, a client, and the settlement transaction's options. */
68
+ type Parameters = SettlementTransactionOptions & {
69
+ /** Returns an account-bearing client for the channel's chain. */
70
+ getClient: (parameters: {
71
+ chainId: number;
72
+ }) => MaybePromise<TransactionClient>;
73
+ /** Settles one channel. @default mppx's `Session.Server.settle` */
74
+ settle?: typeof settleChannel | undefined;
75
+ /** The channel store, such as {@link channels}' `store`. */
76
+ store: Store.AtomicStore;
77
+ };
78
+ /** The subset of a Cloudflare `MessageBatch` that {@link settle} uses. */
79
+ type Batch = {
80
+ readonly messages: readonly Message[];
81
+ };
82
+ /** The subset of a Cloudflare queue `Message` that {@link settle} uses. */
83
+ type Message = {
84
+ readonly attempts: number;
85
+ readonly body: unknown;
86
+ ack(): void;
87
+ retry(options?: {
88
+ delaySeconds?: number;
89
+ }): void;
90
+ };
91
+ }
92
+ //# sourceMappingURL=Channels.d.ts.map