mppx 0.14.0 → 0.15.0

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.
@@ -1,6 +1,6 @@
1
1
  import { type Hex } from 'viem';
2
2
  import * as Chain from '../precompile/Chain.js';
3
- import { type OnSessionSettlementFailure, type SessionStoreInput, type SettlementTransactionOptions } from './Settlement.js';
3
+ import { type OnSessionSettlementFailure, type ResolvedSettlementSchedule, type SessionStoreInput, type SettlementTransactionOptions } from './Settlement.js';
4
4
  /** Channels whose escrow states one call reads, bounding its calldata and response size. */
5
5
  export declare const defaultBatchSize = 50;
6
6
  /** Widest log query `rpc.tempo.xyz` accepts; wider ones fail with "query exceeds max block range". */
@@ -15,6 +15,8 @@ export declare const defaultCloseSearchMarginBlocks = 2000n;
15
15
  export type CloseSupervisionOutcome =
16
16
  /** Open with no close request, or not yet open at the pinned block. */
17
17
  'open'
18
+ /** Open, and settled because its spend crossed `settlementSchedule`. */
19
+ | 'settled'
18
20
  /** Close requested, with nothing above the settled amount to capture; the payer's withdrawal finalizes it. */
19
21
  | 'closing'
20
22
  /** A close request recorded on the channel was cancelled on-chain, so the channel accepts charges again. */
@@ -45,6 +47,8 @@ export type CloseSupervision = {
45
47
  closed: number;
46
48
  /** Channels that failed. */
47
49
  failed: number;
50
+ /** Open channels settled on `settlementSchedule`. */
51
+ settled: number;
48
52
  /** Channels whose deposit left the escrow before their spend was captured. */
49
53
  unrecovered: number;
50
54
  /**
@@ -78,6 +82,14 @@ export type SuperviseClosesParameters = Pick<SettlementTransactionOptions, 'acco
78
82
  onSessionSettlementFailure?: OnSessionSettlementFailure | undefined;
79
83
  /** `block` of the last `complete` supervision, which bounds close-event searches. */
80
84
  previousBlock?: bigint | undefined;
85
+ /**
86
+ * Settles open channels whose spend since their last settlement crossed this schedule, in raw
87
+ * token units, with the account and fee options of closes. Servers that charge channels with
88
+ * `charge()` rather than through the session handler, whose own schedule runs on each
89
+ * charge, use it to collect spend without waiting for a close. Settlement captures only a voucher
90
+ * within spend. Without it, open channels are only read.
91
+ */
92
+ settlementSchedule?: ResolvedSettlementSchedule | undefined;
81
93
  /** Server channel store. */
82
94
  store: SessionStoreInput;
83
95
  };
@@ -4,7 +4,7 @@ import { VerificationFailedError } from '../../../Errors.js';
4
4
  import * as Chain from '../precompile/Chain.js';
5
5
  import { escrowAbi } from '../precompile/escrow.abi.js';
6
6
  import * as ChannelStore from './ChannelStore.js';
7
- import { assertSettlementSender, getClientAccount, reportSessionSettlementFailure, resolveChannelStore, } from './Settlement.js';
7
+ import { assertSettlementSender, getClientAccount, maybeSettleScheduled, reportSessionSettlementFailure, resolveChannelStore, } from './Settlement.js';
8
8
  /** Escrow event every close and withdrawal emits, with the amount paid to the payee. */
9
9
  const channelClosedEvent = getAbiItem({ abi: escrowAbi, name: 'ChannelClosed' });
10
10
  /** Channels whose escrow states one call reads, bounding its calldata and response size. */
@@ -45,6 +45,7 @@ export async function superviseCloses(parameters) {
45
45
  closed: 0,
46
46
  complete: false,
47
47
  failed: 0,
48
+ settled: 0,
48
49
  supervised: 0,
49
50
  timestamp: pinned.timestamp,
50
51
  unrecovered: 0,
@@ -54,6 +55,7 @@ export async function superviseCloses(parameters) {
54
55
  if (outcome === 'cancelled' ||
55
56
  outcome === 'closed' ||
56
57
  outcome === 'failed' ||
58
+ outcome === 'settled' ||
57
59
  outcome === 'unrecovered')
58
60
  supervision[outcome] += 1;
59
61
  supervision.channels.push({ channelId, outcome, ...details });
@@ -172,7 +174,7 @@ async function superviseChannel(parameters, store, channel, state, blocks) {
172
174
  : current);
173
175
  if (state.closeRequestedAt === 0) {
174
176
  if (channel.closeRequestedAt === 0n)
175
- return { outcome: 'open' };
177
+ return settleDue(parameters, store, channel);
176
178
  // A pinned read without a request means the payer cancelled it, unless the record holds a
177
179
  // server close that may still land: those expire before they are cleared.
178
180
  const cleared = {};
@@ -198,6 +200,25 @@ async function superviseChannel(parameters, store, channel, state, blocks) {
198
200
  return { outcome: 'finalized' };
199
201
  return closeAtSpend(parameters, store, closing, state);
200
202
  }
203
+ /** Settles an open channel when its spend crossed the settlement schedule. */
204
+ async function settleDue(parameters, store, channel) {
205
+ const { client, settlementSchedule } = parameters;
206
+ if (!settlementSchedule)
207
+ return { outcome: 'open' };
208
+ const txHash = await maybeSettleScheduled({
209
+ account: parameters.account ?? getClientAccount(client),
210
+ channel,
211
+ client,
212
+ ...(parameters.feePayer ? { feePayer: parameters.feePayer } : {}),
213
+ ...(parameters.feePayerPolicy ? { feePayerPolicy: parameters.feePayerPolicy } : {}),
214
+ ...(parameters.feeToken ? { feeToken: parameters.feeToken } : {}),
215
+ onSessionSettlement: parameters.onSessionSettlement,
216
+ onSessionSettlementFailure: parameters.onSessionSettlementFailure,
217
+ schedule: settlementSchedule,
218
+ store,
219
+ });
220
+ return txHash ? { outcome: 'settled', txHash } : { outcome: 'open' };
221
+ }
201
222
  /** Sends a payee close capturing the channel's spend and finalizes the channel. */
202
223
  async function closeAtSpend(parameters, store, channel, state) {
203
224
  const { client } = parameters;
@@ -1,3 +1,4 @@
1
+ import type * as Challenge from '../../../Challenge.js';
1
2
  import { type SessionCredentialContext } from '../precompile/Protocol.js';
2
3
  import * as ChannelStore from './ChannelStore.js';
3
4
  import type { MeteredStreamOptions, SessionController } from './MeteredStream.js';
@@ -13,6 +14,7 @@ export type SessionRouteResult = {
13
14
  challenge: Response;
14
15
  } | {
15
16
  status: 200;
17
+ challenge?: Challenge.Challenge | undefined;
16
18
  withReceipt(response?: Response): Response | Promise<Response>;
17
19
  };
18
20
  /** HTTP route used to perform a WebSocket payment probe. */
@@ -36,14 +38,15 @@ export declare function serve(options: serve.Options): Promise<void>;
36
38
  /** Type helpers for {@link serve}. */
37
39
  export declare namespace serve {
38
40
  type Options = {
39
- /** Expected per-tick amount in raw units. When set, credentials whose
41
+ /** Expected per-tick amount in raw units. Required for custom routes that
42
+ * do not return their canonical challenge. When set, credentials whose
40
43
  * challenge `request.amount` does not match are rejected. Use this to
41
44
  * pin the price when the route is backed by `Mppx.compose()` with
42
45
  * multiple offers — otherwise a client can select the cheapest offer
43
46
  * and still receive the same stream. */
44
47
  amount?: string | undefined;
45
48
  /** Application stream. A manual stream can call `charge(amount)` with a
46
- * per-message raw-unit amount; omitting it uses the challenge tick cost. */
49
+ * per-message raw-unit amount; omitting it uses the canonical route tick cost. */
47
50
  generate: AsyncIterable<string> | ((stream: SessionController) => AsyncIterable<string>);
48
51
  /** Maximum accepted inbound WebSocket frame size in bytes. @default 65536 */
49
52
  maxIncomingMessageBytes?: number | undefined;
@@ -133,6 +133,11 @@ export async function serve(options) {
133
133
  return;
134
134
  }
135
135
  const { receipt } = authorizationResult;
136
+ const tickCost = expectedAmount !== undefined ? BigInt(expectedAmount) : authorizationResult.amount;
137
+ if (tickCost === undefined) {
138
+ await close(1008, 'WebSocket route must provide canonical pricing or an explicit amount');
139
+ return;
140
+ }
136
141
  if (payload.action === 'voucher' && runtime.streamContext) {
137
142
  runtime.streamContext.challengeId = credential.challenge.id;
138
143
  }
@@ -149,7 +154,7 @@ export async function serve(options) {
149
154
  runtime.streamContext = {
150
155
  challengeId: credential.challenge.id,
151
156
  channelId: payload.channelId,
152
- tickCost: readSessionChallengeAmount(credential.challenge),
157
+ tickCost,
153
158
  };
154
159
  // Defer the first application frame until after the client receives the
155
160
  // auth receipt and has a chance to install its own message listeners.
@@ -299,6 +304,10 @@ async function authorizePaymentFrame(parameters) {
299
304
  const receiptHeader = response.headers.get(Constants.Headers.paymentReceipt);
300
305
  if (!receiptHeader)
301
306
  throw new Error('management response missing Payment-Receipt header');
302
- return { status: 'accepted', receipt: deserializeSessionReceipt(receiptHeader) };
307
+ return {
308
+ status: 'accepted',
309
+ amount: result.challenge ? readSessionChallengeAmount(result.challenge) : undefined,
310
+ receipt: deserializeSessionReceipt(receiptHeader),
311
+ };
303
312
  }
304
313
  //# sourceMappingURL=Ws.js.map
@@ -9,6 +9,8 @@ export type { ChargeOptions, CreditDeclineReason, CreditOptions, CreditResult, S
9
9
  export type { Parameters as SponsorshipLimitParameters } from './SponsorshipLimit.js';
10
10
  /** SSE helpers and types for Tempo session streams. */
11
11
  export * as Sse from './Sse.js';
12
- /** Server-side automatic settlement schedule. */
12
+ /** Server-side automatic settlement schedule, and its raw-unit form `superviseCloses` takes. */
13
+ export { resolveSettlementSchedule } from './Settlement.js';
14
+ export type { ResolvedSettlementSchedule } from './Settlement.js';
13
15
  export type { OnSessionSettlement, OnSessionSettlementFailure, ResolveSessionChannelId, ResolveSessionChannelIdParameters, SessionChannelIdRequest, SessionSettlementContext, SessionSettlementFailureContext, SettlementSchedule, } from './Session.js';
14
16
  //# sourceMappingURL=index.d.ts.map
@@ -5,4 +5,6 @@ export { superviseCloses } from './CloseSupervisor.js';
5
5
  export { IdempotencyKeyConflictError, resolveSettleableVoucher } from './ChannelStore.js';
6
6
  /** SSE helpers and types for Tempo session streams. */
7
7
  export * as Sse from './Sse.js';
8
+ /** Server-side automatic settlement schedule, and its raw-unit form `superviseCloses` takes. */
9
+ export { resolveSettlementSchedule } from './Settlement.js';
8
10
  //# sourceMappingURL=index.js.map
@@ -13,9 +13,13 @@ export type SubscriptionStore = {
13
13
  /** Looks up a generated access key for a resolved request key. */
14
14
  getAccessKey(key: string): Promise<SubscriptionAccessKeyRecord | null>;
15
15
  /** Looks up a generated access key by its public access key address. */
16
- getAccessKeyByAddress(address: string): Promise<SubscriptionAccessKeyRecord | null>;
16
+ getAccessKeyByAddress(address: string, keyToken?: `0x${string}`): Promise<SubscriptionAccessKeyRecord | null>;
17
17
  /** Looks up the active subscription for a resolved request key. */
18
18
  getByKey(key: string): Promise<SubscriptionRecord | null>;
19
+ /** Creates a key sealed in a client-carried token, without retaining per-challenge state. */
20
+ createChallengeAccessKey(): Promise<SubscriptionAccessKeyRecord>;
21
+ /** Retains a key after verified payment submission for subsequent subscription billing. */
22
+ retainAccessKey(key: string, record: SubscriptionAccessKeyRecord): Promise<void>;
19
23
  /** Gets or creates the server-owned access key for a resolved request key. */
20
24
  getOrCreateAccessKey(key: string): Promise<SubscriptionAccessKeyRecord>;
21
25
  /** Upserts a subscription record and marks it as active for its lookup key. */
@@ -85,6 +89,8 @@ export declare namespace fromStore {
85
89
  type Options = {
86
90
  /** Key prefix for server-owned subscription access keys. @default `'tempo:subscription:access-key:'` */
87
91
  accessKeyPrefix?: string | undefined;
92
+ /** Namespace for the persistent anonymous-challenge sealing key. */
93
+ pendingAccessKeyPrefix?: string | undefined;
88
94
  /** Key prefix for resolved subscription activation locks. @default `'tempo:subscription:activation:'` */
89
95
  activationPrefix?: string | undefined;
90
96
  /** Milliseconds before a stuck activation lock can be replaced. @default `900000` */
@@ -1,15 +1,16 @@
1
- import { Secp256k1 } from 'ox';
1
+ import { Hex, Secp256k1 } from 'ox';
2
2
  import { Account as TempoAccount } from 'viem/tempo';
3
3
  const defaultRecordPrefix = 'tempo:subscription:record:';
4
4
  const defaultKeyPrefix = 'tempo:subscription:key:';
5
5
  const defaultActivationPrefix = 'tempo:subscription:activation:';
6
6
  const defaultAccessKeyPrefix = 'tempo:subscription:access-key:';
7
+ const defaultPendingAccessKeyPrefix = 'tempo:subscription:pending-access-key:';
7
8
  const defaultCredentialPrefix = 'tempo:subscription:credential:';
8
9
  const defaultActivationTimeoutMs = 15 * 60 * 1_000;
9
10
  const defaultRenewalTimeoutMs = 15 * 60 * 1_000;
10
11
  /** Wraps a generic key-value {@link Store.Store} with subscription-specific accessors. */
11
12
  export function fromStore(store, options) {
12
- const { accessKeyPrefix = defaultAccessKeyPrefix, activationPrefix = defaultActivationPrefix, activationTimeoutMs = defaultActivationTimeoutMs, credentialPrefix = defaultCredentialPrefix, keyPrefix = defaultKeyPrefix, recordPrefix = defaultRecordPrefix, renewalTimeoutMs = defaultRenewalTimeoutMs, } = options ?? {};
13
+ const { accessKeyPrefix = defaultAccessKeyPrefix, activationPrefix = defaultActivationPrefix, activationTimeoutMs = defaultActivationTimeoutMs, credentialPrefix = defaultCredentialPrefix, keyPrefix = defaultKeyPrefix, recordPrefix = defaultRecordPrefix, pendingAccessKeyPrefix = defaultPendingAccessKeyPrefix, renewalTimeoutMs = defaultRenewalTimeoutMs, } = options ?? {};
13
14
  function recordKey(subscriptionId) {
14
15
  return `${recordPrefix}${subscriptionId}`;
15
16
  }
@@ -61,7 +62,35 @@ export function fromStore(store, options) {
61
62
  const marker = (await store.get(activationKey(lookupKey)));
62
63
  return marker?.challengeId === challengeId;
63
64
  }
65
+ const sealingKey = `${pendingAccessKeyPrefix}sealing-key`;
66
+ // One shared secret survives across workers; anonymous issuance never grows the store.
67
+ async function getSealingKey() {
68
+ const candidate = Hex.fromBytes(crypto.getRandomValues(new Uint8Array(32)));
69
+ const secret = await store.update(sealingKey, (current) => current
70
+ ? { op: 'noop', result: current }
71
+ : { op: 'set', value: candidate, result: candidate });
72
+ return crypto.subtle.importKey('raw', new Uint8Array(Hex.toBytes(secret)), 'AES-GCM', false, [
73
+ 'encrypt',
74
+ 'decrypt',
75
+ ]);
76
+ }
64
77
  return {
78
+ async createChallengeAccessKey() {
79
+ const privateKey = Secp256k1.randomPrivateKey();
80
+ const account = TempoAccount.fromSecp256k1(privateKey);
81
+ const iv = crypto.getRandomValues(new Uint8Array(12));
82
+ const sealed = new Uint8Array(await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, await getSealingKey(), new Uint8Array(Hex.toBytes(privateKey))));
83
+ return {
84
+ accessKeyAddress: account.address.toLowerCase(),
85
+ keyType: account.keyType,
86
+ privateKey,
87
+ keyToken: Hex.fromBytes(new Uint8Array([...iv, ...sealed])),
88
+ };
89
+ },
90
+ async retainAccessKey(key, record) {
91
+ await store.put(accessKeyKey(key), record);
92
+ await store.put(accessKeyAddressKey(record.accessKeyAddress), record);
93
+ },
65
94
  async activate({ challengeId, create, isReusable, lookupKey }) {
66
95
  const claimed = await store.update(credentialKey(challengeId), (current) => {
67
96
  if (current)
@@ -155,8 +184,31 @@ export function fromStore(store, options) {
155
184
  async getAccessKey(key) {
156
185
  return (await store.get(accessKeyKey(key)));
157
186
  },
158
- async getAccessKeyByAddress(address) {
159
- return (await store.get(accessKeyAddressKey(address)));
187
+ async getAccessKeyByAddress(address, keyToken) {
188
+ const stored = (await store.get(accessKeyAddressKey(address)));
189
+ if (stored)
190
+ return stored;
191
+ if (!keyToken)
192
+ return null;
193
+ try {
194
+ const bytes = Hex.toBytes(keyToken);
195
+ // 12-byte nonce, 32-byte private key, and 16-byte authentication tag.
196
+ if (bytes.length !== 60)
197
+ return null;
198
+ const privateKey = Hex.fromBytes(new Uint8Array(await crypto.subtle.decrypt({ name: 'AES-GCM', iv: bytes.slice(0, 12) }, await getSealingKey(), bytes.slice(12))));
199
+ const account = TempoAccount.fromSecp256k1(privateKey);
200
+ if (account.address.toLowerCase() !== address.toLowerCase())
201
+ return null;
202
+ return {
203
+ accessKeyAddress: account.address.toLowerCase(),
204
+ keyType: account.keyType,
205
+ privateKey,
206
+ keyToken,
207
+ };
208
+ }
209
+ catch {
210
+ return null;
211
+ }
160
212
  },
161
213
  async getByKey(key) {
162
214
  return getByLookupKey(key);
@@ -5,6 +5,8 @@ export type SubscriptionPeriodUnit = 'dev_second' | 'day' | 'week';
5
5
  export type SubscriptionAccessKey = {
6
6
  accessKeyAddress: Address;
7
7
  keyType: 'p256' | 'secp256k1' | 'webAuthn';
8
+ /** Opaque server-sealed key for stateless anonymous setup. */
9
+ keyToken?: `0x${string}` | undefined;
8
10
  };
9
11
  /** Server-owned subscription access key persisted for automatic billing. */
10
12
  export type SubscriptionAccessKeyRecord = SubscriptionAccessKey & {
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "mppx",
3
3
  "type": "module",
4
- "version": "0.14.0",
4
+ "version": "0.15.0",
5
5
  "main": "./dist/index.js",
6
6
  "license": "MIT",
7
7
  "homepage": "https://github.com/wevm/mppx#readme",