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
@@ -17,4 +17,6 @@ export declare function register<const method extends Method.AnyClient>(method:
17
17
  export declare function has(method: Method.AnyClient): boolean;
18
18
  /** Runs method-specific work before creating a challenge credential. */
19
19
  export declare function handle(method: Method.AnyClient, parameters: HandlerParameters): Promise<void>;
20
+ /** Returns the registered internal hook so a manager can coordinate its work. */
21
+ export declare function get(method: Method.AnyClient): Handler | undefined;
20
22
  //# sourceMappingURL=MethodChallenge.d.ts.map
@@ -12,4 +12,8 @@ export function has(method) {
12
12
  export function handle(method, parameters) {
13
13
  return Promise.resolve(handlers.get(method)?.(parameters));
14
14
  }
15
+ /** Returns the registered internal hook so a manager can coordinate its work. */
16
+ export function get(method) {
17
+ return handlers.get(method);
18
+ }
15
19
  //# sourceMappingURL=MethodChallenge.js.map
@@ -1,5 +1,5 @@
1
1
  /** Current mppx package version. */
2
- export declare const version = "0.13.5";
2
+ export declare const version = "0.13.6";
3
3
  /** Canonical SDK identifier for this mppx release. */
4
- export declare const sdkIdentifier = "mppx/0.13.5";
4
+ export declare const sdkIdentifier = "mppx/0.13.6";
5
5
  //# sourceMappingURL=version.d.ts.map
@@ -1,6 +1,6 @@
1
1
  // Generated by scripts/sync-version.ts.
2
2
  /** Current mppx package version. */
3
- export const version = '0.13.5';
3
+ export const version = '0.13.6';
4
4
  /** Canonical SDK identifier for this mppx release. */
5
5
  export const sdkIdentifier = `mppx/${version}`;
6
6
  //# sourceMappingURL=version.js.map
@@ -1417,6 +1417,19 @@ function getPinnedRequestBindingMismatch(expectedRequest, actualRequest) {
1417
1417
  return (getCoreBindingMismatch(expected.coreBinding, actual.coreBinding) ??
1418
1418
  getMethodBindingMismatch(expected.methodBinding, actual.methodBinding));
1419
1419
  }
1420
+ /**
1421
+ * Compares a credential with a composed handler's route request when dispatching. The route
1422
+ * request lacks method details that the handler's `request()` hook adds (for example a session's
1423
+ * `chainId` and `sessionProtocol`), so only the method fields it defines are compared. The
1424
+ * selected handler still checks every pinned field against its full challenge.
1425
+ */
1426
+ function getConfiguredRequestBindingMismatch(configuredRequest, actualRequest) {
1427
+ const expected = getPinnedRequestBinding(configuredRequest);
1428
+ const actual = getPinnedRequestBinding(actualRequest);
1429
+ return (getCoreBindingMismatch(expected.coreBinding, actual.coreBinding) ??
1430
+ methodBindingFields.find((field) => expected.methodBinding[field] !== undefined &&
1431
+ !isDeepStrictEqual(expected.methodBinding[field], actual.methodBinding[field])));
1432
+ }
1420
1433
  function getCoreBindingMismatch(expected, actual) {
1421
1434
  return coreBindingFields.find((field) => !isDeepStrictEqual(expected[field], actual[field]));
1422
1435
  }
@@ -1448,6 +1461,15 @@ function getPinnedRequestBinding(request) {
1448
1461
  },
1449
1462
  };
1450
1463
  }
1464
+ /**
1465
+ * Compares a credential with a composed handler's stable route binding when dispatching. The route
1466
+ * request lacks fields its `request()` hook adds, so only the fields it defines are compared; the
1467
+ * selected handler still checks the full binding against its challenge.
1468
+ */
1469
+ function getConfiguredStableBindingMismatch(expected, actual) {
1470
+ return Object.keys(expected).find((field) => expected[field] !== undefined &&
1471
+ !isDeepStrictEqual(normalizeComparable(expected[field]), normalizeComparable(actual[field])));
1472
+ }
1451
1473
  function getRequestBindingMismatch(expected, actual) {
1452
1474
  const fields = [
1453
1475
  ...Object.keys(expected),
@@ -1602,9 +1624,13 @@ function composeHandlers(handlers, composeOptions, selectOffers) {
1602
1624
  if (internal.name !== credMethod || internal.intent !== credIntent)
1603
1625
  return false;
1604
1626
  const mismatch = internal._stableBinding
1605
- ? getRequestBindingMismatch(getStableBinding(internal._canonicalRequest, internal._stableBinding), getStableBinding(credReq, internal._stableBinding))
1606
- : getPinnedRequestBindingMismatch(internal._canonicalRequest, credReq);
1607
- return !mismatch && opaqueValuesMatch(internal.meta, credential.challenge.meta);
1627
+ ? getConfiguredStableBindingMismatch(getStableBinding(internal._canonicalRequest, internal._stableBinding), getStableBinding(credReq, internal._stableBinding))
1628
+ : getConfiguredRequestBindingMismatch(internal._canonicalRequest, credReq);
1629
+ // A handler without a static scope issues challenges scoped to the request's route.
1630
+ const effectiveMeta = internal.scope === undefined
1631
+ ? Scope.merge({ meta: internal.meta, scope: Scope.get(input) })
1632
+ : internal.meta;
1633
+ return !mismatch && opaqueValuesMatch(effectiveMeta, credential.challenge.meta);
1608
1634
  }
1609
1635
  catch {
1610
1636
  return false;
@@ -1806,8 +1832,8 @@ function composeMcpHandlers(handlers, transport) {
1806
1832
  const match = candidates.find((handler) => getConfiguredOffers(handler).some((internal) => {
1807
1833
  try {
1808
1834
  const mismatch = internal._stableBinding
1809
- ? getRequestBindingMismatch(getStableBinding(internal._canonicalRequest, internal._stableBinding), getStableBinding(credential.challenge.request, internal._stableBinding))
1810
- : getPinnedRequestBindingMismatch(internal._canonicalRequest, credential.challenge.request);
1835
+ ? getConfiguredStableBindingMismatch(getStableBinding(internal._canonicalRequest, internal._stableBinding), getStableBinding(credential.challenge.request, internal._stableBinding))
1836
+ : getConfiguredRequestBindingMismatch(internal._canonicalRequest, credential.challenge.request);
1811
1837
  return !mismatch && opaqueValuesMatch(internal.meta, credential.challenge.meta);
1812
1838
  }
1813
1839
  catch {
@@ -133,7 +133,7 @@ export function stripe(parameters) {
133
133
  const suppliedStore = params.store;
134
134
  if (suppliedStore !== undefined && suppliedStore !== store)
135
135
  throw new Error('stripe.create() Tempo sessions must use the factory-level shared atomic `store`.');
136
- const { recipient, onSessionSettlement, ...rest } = params;
136
+ const { recipient, canOffer, onSessionSettlement, ...rest } = params;
137
137
  return tempoSession({
138
138
  currency: tempoCurrency,
139
139
  recipient,
@@ -141,6 +141,9 @@ export function stripe(parameters) {
141
141
  ...(hostedTempoFeePayer && { feePayer: hostedTempoFeePayer }),
142
142
  ...rest,
143
143
  store,
144
+ canOffer: canOffer
145
+ ? async (context) => cryptoCanOffer(context) && (await canOffer(context))
146
+ : cryptoCanOffer,
144
147
  async onSessionSettlement(context) {
145
148
  await tempoPaymentHandler({
146
149
  intent: 'session',
@@ -171,6 +171,7 @@ export function charge(parameters = {}) {
171
171
  tokenOut: currency,
172
172
  tokenIn: autoSwap.tokenIn,
173
173
  slippage: autoSwap.slippage,
174
+ maxAmountIn: autoSwap.maxAmountIn,
174
175
  })
175
176
  : undefined;
176
177
  }
@@ -17,6 +17,8 @@ export declare namespace findCalls {
17
17
  amountOut: bigint;
18
18
  /** Candidate input tokens to swap from, in priority order. */
19
19
  tokenIn: readonly Address[];
20
+ /** Maximum input-token units to spend. Defaults to the missing output amount plus slippage (1:1 units). */
21
+ maxAmountIn?: bigint | undefined;
20
22
  /** Max slippage tolerance as a percentage (e.g. `1` = 1%). */
21
23
  slippage: number;
22
24
  /** Address of the target token to acquire. */
@@ -29,8 +31,15 @@ export declare namespace findCalls {
29
31
  export declare function resolve(value: resolve.Value | undefined, defaultCurrencies: readonly Address[]): resolve.Resolved | false;
30
32
  export declare namespace resolve {
31
33
  type Options = {
34
+ /** Maximum input-token units per swap. Defaults to parity with the missing output amount plus slippage; set explicitly for non-parity pairs. */
35
+ maxAmountIn?: bigint | undefined;
32
36
  /** Fallback tokens to try swapping from, in priority order. */
33
37
  tokenIn?: Address[] | undefined;
38
+ /**
39
+ * Whether to also try the default currencies after `tokenIn`. Set `false` to swap only from
40
+ * `tokenIn`, as an allowlist. Ignored without `tokenIn`. @default true
41
+ */
42
+ fallback?: boolean | undefined;
34
43
  /** Max slippage tolerance as a percentage (e.g. `1` = 1%). @default 1 */
35
44
  slippage?: number | undefined;
36
45
  };
@@ -38,6 +47,7 @@ export declare namespace resolve {
38
47
  type Resolved = {
39
48
  tokenIn: readonly Address[];
40
49
  slippage: number;
50
+ maxAmountIn?: bigint | undefined;
41
51
  };
42
52
  }
43
53
  export declare class InsufficientFundsError extends Error {
@@ -8,6 +8,7 @@ const bps = 10000n;
8
8
  export const defaultCurrencies = [
9
9
  defaults.tokens.pathUsd,
10
10
  defaults.tokens.usdc,
11
+ defaults.tokens.ousd,
11
12
  ];
12
13
  /**
13
14
  * Finds the optimal swap calls to acquire `amountOut` of `tokenOut`,
@@ -18,15 +19,34 @@ export const defaultCurrencies = [
18
19
  */
19
20
  export async function findCalls(client, parameters) {
20
21
  const { account, amountOut, tokenOut, tokenIn, slippage } = parameters;
22
+ if (!Number.isFinite(slippage) || slippage < 0 || slippage > 50)
23
+ throw new Error('Auto-swap slippage must be between 0 and 50 percent.');
24
+ if (parameters.maxAmountIn !== undefined && parameters.maxAmountIn <= 0n)
25
+ throw new Error('Auto-swap maxAmountIn must be positive.');
21
26
  const candidates = tokenIn.filter((t) => !TempoAddress.isEqual(t, tokenOut));
27
+ // Reads never carry the client's account: a node refuses every call from an access key whose
28
+ // authorization is not yet published, which would hide every route from a new key.
22
29
  const balanceResults = await Promise.allSettled([
23
- readContract(client, Actions.token.getBalance.call(client, { account, token: tokenOut })),
24
- ...candidates.map((t) => readContract(client, Actions.token.getBalance.call(client, { account, token: t }))),
30
+ readContract(client, {
31
+ ...Actions.token.getBalance.call(client, { account, token: tokenOut }),
32
+ account: null,
33
+ }),
34
+ ...candidates.map((t) => readContract(client, {
35
+ ...Actions.token.getBalance.call(client, { account, token: t }),
36
+ account: null,
37
+ })),
25
38
  ]);
26
39
  // If the account already has enough of the target token, no swap needed.
27
40
  const targetBalance = balanceResults[0];
28
- if (targetBalance.status === 'fulfilled' && targetBalance.value >= amountOut)
41
+ if (targetBalance.status !== 'fulfilled')
42
+ throw targetBalance.reason;
43
+ const held = targetBalance.value;
44
+ if (held >= amountOut)
29
45
  return undefined;
46
+ const deficit = amountOut - held;
47
+ const slippageBps = BigInt(Math.round(slippage * 100));
48
+ // Bound price independently of the DEX quote. Stablecoin units default to parity.
49
+ const spendLimit = parameters.maxAmountIn ?? deficit + (deficit * slippageBps) / bps;
30
50
  // Find first candidate with enough balance to cover a swap.
31
51
  for (let i = 0; i < candidates.length; i++) {
32
52
  const result = balanceResults[i + 1];
@@ -38,12 +58,16 @@ export async function findCalls(client, parameters) {
38
58
  const tokenIn = candidates[i];
39
59
  try {
40
60
  const quotedAmountIn = await Actions.dex.getBuyQuote(client, {
61
+ account: null,
41
62
  tokenIn,
42
63
  tokenOut,
43
- amountOut,
64
+ amountOut: deficit,
44
65
  });
45
- if (balance >= quotedAmountIn) {
46
- const maxAmountIn = quotedAmountIn + (quotedAmountIn * BigInt(Math.round(slippage * 100))) / bps;
66
+ if (quotedAmountIn <= 0n || quotedAmountIn > spendLimit)
67
+ continue;
68
+ const quotedLimit = quotedAmountIn + (quotedAmountIn * slippageBps) / bps;
69
+ const maxAmountIn = quotedLimit < spendLimit ? quotedLimit : spendLimit;
70
+ if (balance >= maxAmountIn) {
47
71
  return [
48
72
  Actions.token.approve.call(client, {
49
73
  amount: maxAmountIn,
@@ -53,7 +77,7 @@ export async function findCalls(client, parameters) {
53
77
  Actions.dex.buy.call({
54
78
  tokenIn,
55
79
  tokenOut,
56
- amountOut,
80
+ amountOut: deficit,
57
81
  maxAmountIn,
58
82
  }),
59
83
  ];
@@ -72,12 +96,15 @@ export function resolve(value, defaultCurrencies) {
72
96
  const tokenIn = value.tokenIn
73
97
  ? [
74
98
  ...value.tokenIn,
75
- ...defaultCurrencies.filter((d) => !value.tokenIn.some((c) => TempoAddress.isEqual(c, d))),
99
+ ...(value.fallback === false
100
+ ? []
101
+ : defaultCurrencies.filter((d) => !value.tokenIn.some((c) => TempoAddress.isEqual(c, d)))),
76
102
  ]
77
103
  : defaultCurrencies;
78
104
  return {
79
105
  tokenIn,
80
106
  slippage: value.slippage ?? 1,
107
+ ...(value.maxAmountIn !== undefined ? { maxAmountIn: value.maxAmountIn } : {}),
81
108
  };
82
109
  }
83
110
  export class InsufficientFundsError extends Error {
@@ -124,6 +124,14 @@ export declare function validateCalls(calls: readonly {
124
124
  currency?: TempoAddress.Address | undefined;
125
125
  expectedTransfers?: readonly ExpectedTransfer[] | undefined;
126
126
  }): TempoAddress.Address | undefined;
127
+ /** Rejects transactions that can expire before sponsorship and submission finish. */
128
+ export declare function assertRemainingValidity(parameters: {
129
+ details?: Record<string, string> | undefined;
130
+ now?: Date | undefined;
131
+ transaction: {
132
+ validBefore?: number | undefined;
133
+ };
134
+ }): undefined;
127
135
  /** Validates sponsor fee policy limits for a fee-payer transaction. */
128
136
  export declare function assertTransactionPolicy(parameters: {
129
137
  challengeExpires?: string | undefined;
@@ -385,8 +385,8 @@ export function validateCalls(calls, details, options) {
385
385
  const expectedAmountOut = expectedTransfers.reduce((sum, transfer) => sum + BigInt(transfer.amount), 0n);
386
386
  if (!TempoAddress_internal.isEqual(tokenOut, currency))
387
387
  throw new FeePayerValidationError('swap tokenOut does not match payment currency', details);
388
- if (amountOut !== expectedAmountOut)
389
- throw new FeePayerValidationError('swap output does not match payment amount', details);
388
+ if (amountOut <= 0n || amountOut > expectedAmountOut)
389
+ throw new FeePayerValidationError('swap output must be positive and no greater than payment amount', details);
390
390
  }
391
391
  const transferCalls = calls.slice(hasSwapPrefix ? 2 : 0);
392
392
  const sorted = [...expectedTransfers].sort((a, b) => {
@@ -436,6 +436,20 @@ export function validateCalls(calls, details, options) {
436
436
  }
437
437
  return approveCall?.to ?? currency;
438
438
  }
439
+ /** Rejects transactions that can expire before sponsorship and submission finish. */
440
+ export function assertRemainingValidity(parameters) {
441
+ const { details = {}, now = new Date(), transaction } = parameters;
442
+ const { validBefore } = transaction;
443
+ const fail = (reason) => {
444
+ throw new FeePayerValidationError(reason, { ...details, validBefore: String(validBefore) });
445
+ };
446
+ if (validBefore === undefined)
447
+ return fail('fee-sponsored transaction must declare validBefore for the expiring nonce');
448
+ if (validBefore <= Math.floor(now.getTime() / 1_000))
449
+ fail('fee-sponsored transaction has already expired');
450
+ if (validBefore < Math.ceil(now.getTime() / 1_000) + 15)
451
+ fail('fee-sponsored transaction needs at least 15 seconds of remaining validity');
452
+ }
439
453
  /** Validates sponsor fee policy limits for a fee-payer transaction. */
440
454
  export function assertTransactionPolicy(parameters) {
441
455
  const { challengeExpires, chainId, details, now = new Date(), policy: policyOverrides, transaction, } = parameters;
@@ -490,19 +504,13 @@ export function assertTransactionPolicy(parameters) {
490
504
  if (validBefore === undefined)
491
505
  fail('fee-sponsored transaction must declare validBefore for the expiring nonce');
492
506
  const validBeforeValue = validBefore;
493
- const nowSeconds = Math.floor(now.getTime() / 1_000);
494
- if (validBeforeValue <= nowSeconds)
495
- fail('fee-sponsored transaction has already expired', {
496
- validBefore: String(validBeforeValue),
497
- });
498
- if (validBeforeValue < Math.ceil(now.getTime() / 1_000) + 15)
499
- fail('fee-sponsored transaction needs at least 15 seconds of remaining validity', {
500
- validBefore: String(validBeforeValue),
501
- });
507
+ if (!Number.isInteger(policy.maxValidityWindowSeconds) || policy.maxValidityWindowSeconds < 15)
508
+ fail('sponsor maxValidityWindowSeconds must be an integer of at least 15 seconds');
509
+ assertRemainingValidity({ transaction, details, now });
502
510
  const challengeExpirySeconds = challengeExpires
503
511
  ? Math.floor(new Date(challengeExpires).getTime() / 1_000)
504
512
  : undefined;
505
- const maxValidBefore = Math.min(nowSeconds + policy.maxValidityWindowSeconds, challengeExpirySeconds ? challengeExpirySeconds + 60 : Number.MAX_SAFE_INTEGER);
513
+ const maxValidBefore = Math.min(Math.ceil(now.getTime() / 1_000) + policy.maxValidityWindowSeconds, challengeExpirySeconds ? challengeExpirySeconds + 60 : Number.MAX_SAFE_INTEGER);
506
514
  if (validBeforeValue > maxValidBefore)
507
515
  fail('fee-sponsored transaction validity window exceeds sponsor policy', {
508
516
  validBefore: String(validBeforeValue),
@@ -373,7 +373,10 @@ export async function broadcastOpenTransaction(parameters) {
373
373
  return { feePayer: feePayer.address, transaction: sponsored };
374
374
  },
375
375
  });
376
- return signTransaction(client, completed.transaction);
376
+ FeePayer.assertRemainingValidity({ transaction: completed.transaction });
377
+ const serialized = await signTransaction(client, completed.transaction);
378
+ FeePayer.assertRemainingValidity({ transaction: completed.transaction });
379
+ return serialized;
377
380
  }
378
381
  return serializedTransaction;
379
382
  };
@@ -491,7 +494,10 @@ export async function broadcastTopUpTransaction(parameters) {
491
494
  return { feePayer: feePayer.address, transaction: sponsored };
492
495
  },
493
496
  });
494
- return signTransaction(client, completed.transaction);
497
+ FeePayer.assertRemainingValidity({ transaction: completed.transaction });
498
+ const serialized = await signTransaction(client, completed.transaction);
499
+ FeePayer.assertRemainingValidity({ transaction: completed.transaction });
500
+ return serialized;
495
501
  }
496
502
  return serializedTransaction;
497
503
  })();
@@ -1,7 +1,7 @@
1
1
  import * as Store from '../../Store.js';
2
2
  import * as Currencies from '../internal/currencies.js';
3
3
  import * as ChannelStore from '../session/server/ChannelStore.js';
4
- import { charge as sessionCharge_, session as session_, settle as settle_, settleBatch as settleBatch_, } from '../session/server/Session.js';
4
+ import { charge as sessionCharge_, session as session_, settle as settle_, settleBatch as settleBatch_, verifyStream as verifyStream_, } from '../session/server/Session.js';
5
5
  import * as Settlement from '../session/server/Settlement.js';
6
6
  import * as Ws_ from '../session/server/Ws.js';
7
7
  import { charge as charge_ } from './Charge.js';
@@ -129,6 +129,7 @@ function sessionOffers(parameters) {
129
129
  const [first, ...rest] = resolveCurrencies(parameters ?? {});
130
130
  const store = parameters?.store ?? fallbackStore('tempo.session');
131
131
  const settlements = new Map();
132
+ const verifiers = new Map();
132
133
  const key = (chainId, currency) => `${chainId}:${currency.toLowerCase()}`;
133
134
  const settleScheduled = (channel) => {
134
135
  const settle = settlements.get(key(channel.chainId, channel.token));
@@ -141,6 +142,15 @@ function sessionOffers(parameters) {
141
142
  serveWebSocket: (options) => Ws_.serve({
142
143
  ...options,
143
144
  store: ChannelStore.fromStore(store),
145
+ beforeCharge: async (channelId) => {
146
+ const channel = await ChannelStore.fromStore(store).getChannel(channelId);
147
+ if (!channel)
148
+ throw new Error('stream channel not found');
149
+ const verify = verifiers.get(key(channel.chainId, channel.token));
150
+ if (!verify)
151
+ throw new Error('Channel currency is not configured for this session handler.');
152
+ await verify(channelId);
153
+ },
144
154
  // Failed settlements are reported by the session handler; the charged request stays served.
145
155
  onChargeCommitted: (channel) => settleScheduled(channel).catch(Settlement.ignoreRetryableSettlementFailure),
146
156
  }),
@@ -149,6 +159,7 @@ function sessionOffers(parameters) {
149
159
  const configured = { ...shared, ...currency, store };
150
160
  const method = session_(configured);
151
161
  settlements.set(key(currency.chainId, currency.currency), method.extensions.settleScheduled);
162
+ verifiers.set(key(currency.chainId, currency.currency), (channelId) => verifyStream_(method, channelId));
152
163
  return { ...method, extensions };
153
164
  }
154
165
  return [create(first), ...rest.map(create)];
@@ -1,4 +1,3 @@
1
- import { Bytes, Hash, Hex, Json } from 'ox';
2
1
  import { PaymentExpiredError, VerificationFailedError } from '../../Errors.js';
3
2
  import * as Receipt from '../../Receipt.js';
4
3
  const defaultApiBaseUrl = 'https://api.tempo.xyz';
@@ -43,9 +42,7 @@ export function configure(method, options) {
43
42
  };
44
43
  const broadcast = async (parameters) => {
45
44
  const input = toRelayInput(parameters.credential);
46
- const receipt = await request.broadcast(input, {
47
- idempotencyKey: idempotencyKey(input),
48
- });
45
+ const receipt = await request.broadcast(input);
49
46
  if (receipt.method !== method.name)
50
47
  throw failure();
51
48
  try {
@@ -72,7 +69,7 @@ function createRequest(options) {
72
69
  const apiBaseUrl = new URL(options.apiBaseUrl ?? defaultApiBaseUrl);
73
70
  if (!apiBaseUrl.pathname.endsWith('/'))
74
71
  apiBaseUrl.pathname += '/';
75
- async function post(path, input, headers) {
72
+ async function post(path, input) {
76
73
  let response;
77
74
  try {
78
75
  response = await fetch(new URL(path, apiBaseUrl), {
@@ -81,7 +78,6 @@ function createRequest(options) {
81
78
  Accept: 'application/json',
82
79
  'content-type': 'application/json',
83
80
  'tempo-api-key': options.apiKey,
84
- ...headers,
85
81
  },
86
82
  method: 'POST',
87
83
  });
@@ -98,10 +94,10 @@ function createRequest(options) {
98
94
  if (!isValidateSuccess(response))
99
95
  throw failure(response);
100
96
  };
101
- const broadcast = async (input, broadcastOptions) => {
102
- const response = await post('v1/mpp/broadcast', input, {
103
- 'idempotency-key': broadcastOptions.idempotencyKey,
104
- });
97
+ const broadcast = async (input) => {
98
+ // A cached settlement receipt must not authorize another protected operation.
99
+ // Omitting the idempotency key leaves credential consumption to the relay.
100
+ const response = await post('v1/mpp/broadcast', input);
105
101
  if (!isBroadcastSuccess(response))
106
102
  throw failure(response);
107
103
  return response.receipt;
@@ -118,18 +114,6 @@ function toRelayInput(credential) {
118
114
  ...(credential.source ? { source: credential.source } : {}),
119
115
  };
120
116
  }
121
- function idempotencyKey(input) {
122
- const payload = input.payload;
123
- if (isRecord(payload) &&
124
- payload.type === 'transaction' &&
125
- typeof payload.signature === 'string' &&
126
- Hex.validate(payload.signature)) {
127
- const transactionHash = Hash.keccak256(Hex.toBytes(payload.signature), { as: 'Hex' });
128
- return `mpp_${transactionHash}`;
129
- }
130
- const hash = Hash.sha256(Bytes.fromString(Json.canonicalize(input)), { as: 'Hex' });
131
- return `mpp_${hash}`;
132
- }
133
117
  function failure(value) {
134
118
  const code = relayErrorCodeFrom(value);
135
119
  if (code === 'expired')
@@ -738,6 +738,7 @@ async function submitSubscriptionPayment(parameters) {
738
738
  };
739
739
  },
740
740
  });
741
+ FeePayer.assertRemainingValidity({ transaction: completed.transaction });
741
742
  return await signTransaction(client, completed.transaction);
742
743
  })();
743
744
  const transaction = Transaction.deserialize(serializedTransaction);
@@ -748,6 +749,8 @@ async function submitSubscriptionPayment(parameters) {
748
749
  calls: transaction.calls,
749
750
  feePayerSignature: undefined,
750
751
  });
752
+ if (feePayer)
753
+ FeePayer.assertRemainingValidity({ transaction });
751
754
  if (!waitForConfirmation) {
752
755
  // Optimistic mode has no receipt to inspect, so it cannot detect a T6
753
756
  // (TIP-1028) held transfer. Use `waitForConfirmation: true` for T6-safe proof.