mppx 0.13.4 → 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 (73) hide show
  1. package/CHANGELOG.md +48 -0
  2. package/dist/Credential.d.ts +3 -0
  3. package/dist/Credential.js +9 -0
  4. package/dist/Errors.d.ts +63 -2
  5. package/dist/Errors.js +52 -2
  6. package/dist/Method.d.ts +10 -0
  7. package/dist/cli/cli.js +92 -92
  8. package/dist/cli/plugins/evm.js +2 -2
  9. package/dist/cli/plugins/index.js +12 -12
  10. package/dist/client/Mppx.d.ts +2 -0
  11. package/dist/client/Mppx.js +8 -10
  12. package/dist/client/internal/Fetch.js +107 -8
  13. package/dist/client/internal/MethodChallenge.d.ts +2 -0
  14. package/dist/client/internal/MethodChallenge.js +4 -0
  15. package/dist/client/internal/MethodResponse.d.ts +6 -0
  16. package/dist/client/internal/MethodResponse.js +16 -0
  17. package/dist/client/internal/protocols/Mcp.js +36 -5
  18. package/dist/evm/server/Charge.js +3 -1
  19. package/dist/internal/version.d.ts +2 -2
  20. package/dist/internal/version.js +1 -1
  21. package/dist/internal/warnOnce.d.ts +3 -0
  22. package/dist/internal/warnOnce.js +9 -0
  23. package/dist/server/Mppx.d.ts +5 -0
  24. package/dist/server/Mppx.js +45 -18
  25. package/dist/server/Transport.js +10 -0
  26. package/dist/stripe/server/Methods.js +4 -1
  27. package/dist/tempo/Attribution.d.ts +24 -9
  28. package/dist/tempo/Attribution.js +25 -5
  29. package/dist/tempo/client/Charge.d.ts +116 -10
  30. package/dist/tempo/client/Charge.js +117 -22
  31. package/dist/tempo/client/Methods.d.ts +88 -9
  32. package/dist/tempo/index.d.ts +1 -0
  33. package/dist/tempo/index.js +1 -0
  34. package/dist/tempo/internal/auto-swap.d.ts +10 -0
  35. package/dist/tempo/internal/auto-swap.js +35 -8
  36. package/dist/tempo/internal/fee-payer.d.ts +8 -0
  37. package/dist/tempo/internal/fee-payer.js +20 -8
  38. package/dist/tempo/legacy/session/Chain.js +8 -2
  39. package/dist/tempo/server/Charge.d.ts +11 -0
  40. package/dist/tempo/server/Charge.js +160 -29
  41. package/dist/tempo/server/Methods.js +28 -5
  42. package/dist/tempo/server/Relay.js +6 -22
  43. package/dist/tempo/server/Subscription.js +5 -1
  44. package/dist/tempo/server/internal/fallback-store.d.ts +9 -0
  45. package/dist/tempo/server/internal/fallback-store.js +13 -0
  46. package/dist/tempo/server/internal/html.gen.js +1 -1
  47. package/dist/tempo/server/internal/transport.d.ts +3 -0
  48. package/dist/tempo/server/internal/transport.js +5 -1
  49. package/dist/tempo/session/client/ChannelOps.d.ts +2 -0
  50. package/dist/tempo/session/client/ChannelStore.d.ts +12 -1
  51. package/dist/tempo/session/client/ChannelStore.js +47 -0
  52. package/dist/tempo/session/client/CredentialState.js +8 -3
  53. package/dist/tempo/session/client/Session.js +50 -33
  54. package/dist/tempo/session/client/SessionManager.js +186 -65
  55. package/dist/tempo/session/client/Transports.d.ts +6 -0
  56. package/dist/tempo/session/client/Transports.js +52 -7
  57. package/dist/tempo/session/precompile/Chain.d.ts +2 -0
  58. package/dist/tempo/session/precompile/Chain.js +8 -3
  59. package/dist/tempo/session/server/ChannelStore.d.ts +34 -0
  60. package/dist/tempo/session/server/ChannelStore.js +69 -9
  61. package/dist/tempo/session/server/CredentialVerification.d.ts +7 -1
  62. package/dist/tempo/session/server/CredentialVerification.js +220 -48
  63. package/dist/tempo/session/server/MeteredStream.d.ts +6 -5
  64. package/dist/tempo/session/server/MeteredStream.js +10 -26
  65. package/dist/tempo/session/server/Session.d.ts +29 -2
  66. package/dist/tempo/session/server/Session.js +124 -44
  67. package/dist/tempo/session/server/Settlement.d.ts +9 -0
  68. package/dist/tempo/session/server/Settlement.js +23 -3
  69. package/dist/tempo/session/server/Sse.d.ts +4 -3
  70. package/dist/tempo/session/server/Sse.js +3 -3
  71. package/dist/tempo/session/server/Ws.d.ts +9 -1
  72. package/dist/tempo/session/server/Ws.js +1 -0
  73. package/package.json +1 -1
@@ -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,15 +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
- });
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 });
498
510
  const challengeExpirySeconds = challengeExpires
499
511
  ? Math.floor(new Date(challengeExpires).getTime() / 1_000)
500
512
  : undefined;
501
- 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);
502
514
  if (validBeforeValue > maxValidBefore)
503
515
  fail('fee-sponsored transaction validity window exceeds sponsor policy', {
504
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
  })();
@@ -55,6 +55,17 @@ export declare namespace charge {
55
55
  * checks still apply. This does not change the payment currency or sessions.
56
56
  */
57
57
  allowedFeeTokens?: readonly `0x${string}`[] | undefined;
58
+ /**
59
+ * Milliseconds to wait for a charge transaction receipt when
60
+ * `waitForConfirmation` is `true`. The synchronous broadcast may use at most
61
+ * two thirds of this budget. If it fails with an unknown outcome (e.g. a
62
+ * timeout, a lost connection, or "already known"), mppx polls for the
63
+ * receipt until the deadline, or until 2 seconds after the transaction's
64
+ * `validBefore` followed by one final lookup, before surfacing the broadcast
65
+ * error. Definitive rejections fail immediately.
66
+ * @default 30_000
67
+ */
68
+ confirmationTimeoutMs?: number | undefined;
58
69
  /** Enables first-party machine-token funding through the canonical swapper. */
59
70
  machineTokenEnabled?: boolean | undefined;
60
71
  /** Render payment page when Accept header is text/html (e.g. in browsers) */
@@ -1,9 +1,9 @@
1
1
  import * as SignatureEnvelope from 'ox/tempo/SignatureEnvelope';
2
- import { decodeFunctionData, formatUnits, keccak256, parseEventLogs, } from 'viem';
3
- import { getTransaction, getTransactionReceipt, sendRawTransaction, sendRawTransactionSync, signTransaction, verifyTypedData, call as viem_call, } from 'viem/actions';
2
+ import { decodeFunctionData, formatUnits, keccak256, parseEventLogs, WaitForTransactionReceiptTimeoutError, } from 'viem';
3
+ import { getTransaction, getTransactionReceipt, sendRawTransaction, sendRawTransactionSync, signTransaction, verifyTypedData, waitForTransactionReceipt, call as viem_call, } from 'viem/actions';
4
4
  import { Abis, Actions, Transaction } from 'viem/tempo';
5
5
  import { tempo as tempo_chain } from 'viem/tempo/chains';
6
- import { PaymentError, VerificationFailedError } from '../../Errors.js';
6
+ import { CredentialReplayedError, PaymentError, VerificationFailedError } from '../../Errors.js';
7
7
  import * as Expires from '../../Expires.js';
8
8
  import * as Method from '../../Method.js';
9
9
  import * as Store from '../../Store.js';
@@ -19,6 +19,7 @@ import * as MachineTokenCharge from '../internal/machine-token-charge.js';
19
19
  import * as Proof from '../internal/proof.js';
20
20
  import * as Selectors from '../internal/selectors.js';
21
21
  import * as Methods from '../Methods.js';
22
+ import { fallbackStore } from './internal/fallback-store.js';
22
23
  import { html as htmlContent } from './internal/html.gen.js';
23
24
  import * as Relay from './Relay.js';
24
25
  import * as SponsorBudget from './SponsorBudget.js';
@@ -33,11 +34,12 @@ import * as SponsorBudget from './SponsorBudget.js';
33
34
  * ```
34
35
  */
35
36
  export function charge(parameters = {}) {
36
- const { machineTokenEnabled, allowedFeeTokens: configuredAllowedFeeTokens, amount, currency = defaults.resolveCurrency(parameters), decimals = defaults.decimals, description, externalId, feeToken: configuredFeeToken, feePayerPolicy, html, relay, sponsorBudget, splits, supportedModes, validateSender, waitForConfirmation = true, } = parameters;
37
+ const { machineTokenEnabled, allowedFeeTokens: configuredAllowedFeeTokens, amount, confirmationTimeoutMs = 30_000, currency = defaults.resolveCurrency(parameters), decimals = defaults.decimals, description, externalId, feeToken: configuredFeeToken, feePayerPolicy, html, relay, sponsorBudget, splits, supportedModes, validateSender, waitForConfirmation = true, } = parameters;
37
38
  if (configuredAllowedFeeTokens?.length === 0)
38
39
  throw new Error('`allowedFeeTokens` must contain at least one token.');
39
40
  const storeKeyPrefix = parameters.storeKeyPrefix ?? '';
40
- const rawStore = (parameters.store ?? Store.memory());
41
+ const rawStore = (parameters.store ??
42
+ fallbackStore('tempo.charge'));
41
43
  const store = Store.from(rawStore, { keyPrefix: storeKeyPrefix });
42
44
  const { maxInFlightReservations: legacyMaxInFlightReservations, maxInFlightTotalFee: legacyMaxInFlightTotalFee, ...transactionFeePayerPolicy } = feePayerPolicy ?? {};
43
45
  const sponsorBudgetConfig = sponsorBudget === false ? undefined : (sponsorBudget ?? {});
@@ -178,7 +180,13 @@ export function charge(parameters = {}) {
178
180
  challengeId: challenge.id,
179
181
  realm: challenge.realm,
180
182
  });
181
- return { receipt, sender, transfers };
183
+ // The payer is the account the matched transfers debited, not the submitter.
184
+ // Settlement-contract transfers are funded by the transaction sender.
185
+ const payers = new Set(matchedLogs.map(({ args }) => (settlementSenders.some((settlementSender) => TempoAddress.isEqual(settlementSender, args.from))
186
+ ? receipt.from
187
+ : args.from).toLowerCase()));
188
+ const payer = payers.size === 1 ? [...payers][0] : undefined;
189
+ return { payer, receipt, sender, transfers };
182
190
  }
183
191
  async function validateProofCredential(credential, payload, context) {
184
192
  const { chainId, challenge, client, isZeroAmount } = context;
@@ -258,17 +266,22 @@ export function charge(parameters = {}) {
258
266
  async function validateCredential(credential, request, context) {
259
267
  switch (context.payload.type) {
260
268
  case 'hash': {
261
- const { receipt, sender, transfers } = await validateHashCredential(credential, context.payload, context);
269
+ const { payer, receipt, sender, transfers } = await validateHashCredential(credential, context.payload, context);
262
270
  return {
263
271
  details: { mode: 'push', sender, transfers },
264
272
  hash: context.payload.hash,
273
+ payer,
265
274
  receipt,
266
275
  type: 'hash',
267
276
  };
268
277
  }
269
278
  case 'proof': {
270
279
  const { sender } = await validateProofCredential(credential, context.payload, context);
271
- return { details: { mode: 'proof', sender }, type: 'proof' };
280
+ return {
281
+ details: { mode: 'proof', sender },
282
+ payer: sender,
283
+ type: 'proof',
284
+ };
272
285
  }
273
286
  case 'transaction': {
274
287
  const { fundingCurrency, isFeePayerTx, serializedTransaction, settlementSenders, transaction, transfers, } = await validateTransactionCredential(credential, context.payload, request, context);
@@ -281,6 +294,7 @@ export function charge(parameters = {}) {
281
294
  },
282
295
  fundingCurrency,
283
296
  isFeePayerTx,
297
+ payer: transaction.from,
284
298
  serializedTransaction,
285
299
  settlementSenders,
286
300
  transaction,
@@ -387,6 +401,7 @@ export function charge(parameters = {}) {
387
401
  details: validated.details,
388
402
  intent: 'charge',
389
403
  method: 'tempo',
404
+ payer: validated.payer,
390
405
  request: context.resolvedRequest,
391
406
  source: credential.source,
392
407
  };
@@ -407,7 +422,7 @@ export function charge(parameters = {}) {
407
422
  case 'hash': {
408
423
  const { hash, receipt } = validated;
409
424
  if (!(await tryClaimHash(store, hash, replayExpires))) {
410
- throw new VerificationFailedError({
425
+ throw new CredentialReplayedError({
411
426
  reason: 'Transaction hash has already been used',
412
427
  });
413
428
  }
@@ -427,7 +442,7 @@ export function charge(parameters = {}) {
427
442
  }
428
443
  case 'proof': {
429
444
  if (proofStore && !(await tryClaimProof(proofStore, challenge.id, replayExpires))) {
430
- throw new VerificationFailedError({
445
+ throw new CredentialReplayedError({
431
446
  reason: 'Proof credential has already been used',
432
447
  });
433
448
  }
@@ -438,7 +453,7 @@ export function charge(parameters = {}) {
438
453
  // Pre-broadcast dedup: catch exact byte-for-byte replays early.
439
454
  const hash = keccak256(serializedTransaction);
440
455
  if (!(await tryClaimHash(store, hash, replayExpires))) {
441
- throw new VerificationFailedError({
456
+ throw new CredentialReplayedError({
442
457
  reason: 'Transaction hash has already been used',
443
458
  });
444
459
  }
@@ -480,6 +495,13 @@ export function charge(parameters = {}) {
480
495
  return { feePayer: feePayerAccount.address, transaction: sponsored };
481
496
  },
482
497
  });
498
+ FeePayer.assertTransactionPolicy({
499
+ challengeExpires: expires,
500
+ chainId: chainId ?? client.chain.id,
501
+ details: { amount, currency, recipient },
502
+ policy: transactionFeePayerPolicy,
503
+ transaction: completed.transaction,
504
+ });
483
505
  return {
484
506
  serializedTransaction: await signTransaction(client, completed.transaction),
485
507
  sponsor: completed.feePayer,
@@ -515,7 +537,7 @@ export function charge(parameters = {}) {
515
537
  finalHash = keccak256(serializedTransaction_final);
516
538
  if (finalHash.toLowerCase() !== hash.toLowerCase()) {
517
539
  if (!(await tryClaimHash(store, finalHash, replayExpires)))
518
- throw new VerificationFailedError({
540
+ throw new CredentialReplayedError({
519
541
  reason: 'Transaction hash has already been used',
520
542
  });
521
543
  claimedFinalHash = finalHash;
@@ -560,11 +582,51 @@ export function charge(parameters = {}) {
560
582
  throw new VerificationFailedError({
561
583
  reason: 'Sponsor budget reservation ownership was lost before broadcast',
562
584
  });
585
+ if (isFeePayerTx)
586
+ FeePayer.assertTransactionPolicy({
587
+ challengeExpires: expires,
588
+ chainId: chainId ?? client.chain.id,
589
+ details: { amount, currency, recipient },
590
+ policy: transactionFeePayerPolicy,
591
+ transaction,
592
+ });
563
593
  broadcastAttempted = true;
564
594
  if (waitForConfirmation) {
565
- const receipt = await sendRawTransactionSync(client, {
566
- serializedTransaction: serializedTransaction_final,
567
- });
595
+ const start = Date.now();
596
+ // The sync send gets at most 2/3 of the budget, reserving the rest for recovery.
597
+ const sendTimeoutMs = Math.floor((confirmationTimeoutMs * 2) / 3);
598
+ const deadline = start + confirmationTimeoutMs;
599
+ // An expiring transaction can no longer be included after this point.
600
+ const validUntil = transaction.validBefore === undefined
601
+ ? Number.POSITIVE_INFINITY
602
+ : transaction.validBefore * 1_000 + 2_000;
603
+ let receipt;
604
+ try {
605
+ receipt = await withDeadline(sendRawTransactionSync(client, {
606
+ serializedTransaction: serializedTransaction_final,
607
+ timeout: sendTimeoutMs,
608
+ }), start + sendTimeoutMs, new WaitForTransactionReceiptTimeoutError({ hash: finalHash }));
609
+ }
610
+ catch (broadcastError) {
611
+ if (!isAmbiguousBroadcastError(broadcastError))
612
+ throw broadcastError;
613
+ // The node may have accepted the transaction, so poll for its receipt.
614
+ const timeout = Math.min(deadline, validUntil) - Date.now();
615
+ let recovered;
616
+ if (timeout > 0)
617
+ recovered = await waitForTransactionReceipt(client, {
618
+ checkReplacement: false,
619
+ hash: finalHash,
620
+ timeout,
621
+ }).catch(() => undefined);
622
+ // When the validity cap ends polling, a transaction mined just before
623
+ // `validBefore` may only now be visible, so look it up exactly once more.
624
+ if (!recovered && validUntil < deadline)
625
+ recovered = await withDeadline(getTransactionReceipt(client, { hash: finalHash }), Math.max(deadline, Date.now() + client.pollingInterval), broadcastError).catch(() => undefined);
626
+ if (!recovered)
627
+ throw broadcastError;
628
+ receipt = recovered;
629
+ }
568
630
  if (reservation)
569
631
  await SponsorBudget.release(sponsorBudgetStore, reservation);
570
632
  const matchedLogs = await assertTransferLogs(receipt, {
@@ -601,13 +663,27 @@ export function charge(parameters = {}) {
601
663
  return toReceipt(reference, fundingCurrency);
602
664
  }
603
665
  catch (error) {
604
- if (!broadcastAttempted) {
605
- if (reservation)
606
- await SponsorBudget.release(sponsorBudgetStore, reservation);
607
- if (claimedFinalHash)
608
- await releaseHashUse(store, claimedFinalHash);
609
- await releaseHashUse(store, hash);
666
+ if (reservation) {
667
+ try {
668
+ // A broadcast transaction may still land, so its fee stays reserved
669
+ // until reconciliation observes a receipt or the reservation expires.
670
+ if (broadcastAttempted)
671
+ await SponsorBudget.transition(sponsorBudgetStore, reservation, 'pending');
672
+ else
673
+ await SponsorBudget.release(sponsorBudgetStore, reservation);
674
+ }
675
+ catch (budgetError) {
676
+ // The reservation expires on its own; the settlement error and
677
+ // claim cleanup take precedence.
678
+ console.error('mppx: sponsor budget cleanup error', budgetError);
679
+ }
610
680
  }
681
+ // Only a successful settlement consumes the credential. A signed
682
+ // transaction can be mined at most once, so a retry re-broadcasts it
683
+ // and recovers the receipt instead of being rejected as a replay.
684
+ if (claimedFinalHash)
685
+ await releaseHashUse(store, claimedFinalHash);
686
+ await releaseHashUse(store, hash);
611
687
  throw error;
612
688
  }
613
689
  }
@@ -932,6 +1008,57 @@ async function isActiveAccessKey(client, parameters) {
932
1008
  return false;
933
1009
  }
934
1010
  }
1011
+ const ambiguousErrorNames = new Set([
1012
+ 'AbortError',
1013
+ 'SocketClosedError',
1014
+ 'TimeoutError',
1015
+ 'WaitForTransactionReceiptTimeoutError',
1016
+ ]);
1017
+ /**
1018
+ * Whether a failed synchronous broadcast may still have reached the mempool,
1019
+ * so its receipt is worth polling for. Every other error is a definitive
1020
+ * rejection (e.g. method not found, invalid params, insufficient funds).
1021
+ *
1022
+ * Ambiguous outcomes, matched anywhere in the `cause` chain:
1023
+ * - Timeouts and aborts: errors named `TimeoutError` or `AbortError`, or the
1024
+ * local confirmation deadline (`WaitForTransactionReceiptTimeoutError`).
1025
+ * - Lost connections: `SocketClosedError`, or an `HttpRequestError` with no
1026
+ * status (network failure) or a 5xx status.
1027
+ * - JSON-RPC code `4`: the EIP-7966 timeout for a pooled, unconfirmed transaction.
1028
+ * - Messages containing the words "already known" or "known transaction": already
1029
+ * pooled ("unknown transaction type" does not match).
1030
+ */
1031
+ function isAmbiguousBroadcastError(error) {
1032
+ for (let cause = error; cause; cause = cause.cause) {
1033
+ if (typeof cause !== 'object')
1034
+ return false;
1035
+ const { code, message, name, status } = cause;
1036
+ if (name === 'HttpRequestError')
1037
+ return status === undefined || Number(status) >= 500;
1038
+ if (ambiguousErrorNames.has(name))
1039
+ return true;
1040
+ if (code === 4)
1041
+ return true;
1042
+ if (typeof message === 'string' && /\balready ?known\b|\bknown transaction\b/i.test(message))
1043
+ return true;
1044
+ }
1045
+ return false;
1046
+ }
1047
+ /** Rejects with `error` if `promise` has not settled by `deadline` (epoch ms). */
1048
+ async function withDeadline(promise, deadline, error) {
1049
+ let timer;
1050
+ try {
1051
+ return await Promise.race([
1052
+ promise,
1053
+ new Promise((_, reject) => {
1054
+ timer = setTimeout(() => reject(error), deadline - Date.now());
1055
+ }),
1056
+ ]);
1057
+ }
1058
+ finally {
1059
+ clearTimeout(timer);
1060
+ }
1061
+ }
935
1062
  /** @internal */
936
1063
  function toReceipt(reference, fundingCurrency) {
937
1064
  return {
@@ -943,30 +1070,34 @@ function toReceipt(reference, fundingCurrency) {
943
1070
  };
944
1071
  }
945
1072
  function assertChallengeBoundMemo(matchedLogs, parameters) {
946
- assertChallengeBoundMemos(matchedLogs.map((log) => (log.kind === 'memo' ? log.args.memo : undefined)), parameters);
1073
+ const memos = matchedLogs.map((log) => (log.kind === 'memo' ? log.args.memo : undefined));
1074
+ // The transfer already settled to the recipient, so another payment would not help.
1075
+ if (!isChallengeBound(memos, parameters))
1076
+ throw new CredentialReplayedError({ reason: 'memo is not bound to this challenge' });
947
1077
  }
948
1078
  function assertChallengeBoundCallMemo(matchedCalls, parameters) {
949
- assertChallengeBoundMemos(matchedCalls.map((call) => call.memo), parameters);
1079
+ const memos = matchedCalls.map((call) => call.memo);
1080
+ if (!isChallengeBound(memos, parameters))
1081
+ throw new MismatchError('Payment verification failed: memo is not bound to this challenge.', {});
950
1082
  }
951
1083
  /**
952
- * Requires a challenge-bound attribution memo and rejects conflicting attribution
953
- * on matched payment transfers. Otherwise, a split payment could authorize another
1084
+ * Whether matched payment transfers carry a challenge-bound attribution memo and
1085
+ * no conflicting attribution. Otherwise, a split payment could authorize another
954
1086
  * challenge after the first challenge's replay marker expires. Unmatched transfers
955
1087
  * cannot establish the binding; plain transfers and non-MPP memos remain allowed.
956
1088
  * @internal
957
1089
  */
958
- function assertChallengeBoundMemos(memos, parameters) {
1090
+ function isChallengeBound(memos, parameters) {
959
1091
  let bound = false;
960
1092
  for (const memo of memos) {
961
1093
  if (!memo || !Attribution.isMppMemo(memo))
962
1094
  continue;
963
1095
  if (!Attribution.verifyServer(memo, parameters.realm) ||
964
1096
  !Attribution.verifyChallengeBinding(memo, parameters.challengeId))
965
- throw new MismatchError('Payment verification failed: memo is not bound to this challenge.', {});
1097
+ return false;
966
1098
  bound = true;
967
1099
  }
968
- if (!bound)
969
- throw new MismatchError('Payment verification failed: memo is not bound to this challenge.', {});
1100
+ return bound;
970
1101
  }
971
1102
  /** @internal */
972
1103
  class MismatchError extends PaymentError {
@@ -1,10 +1,11 @@
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';
8
+ import { fallbackStore } from './internal/fallback-store.js';
8
9
  import { renew as renewSubscription_, subscription as subscription_ } from './Subscription.js';
9
10
  const sessionServer = Object.assign(session_, {
10
11
  charge: sessionCharge_,
@@ -58,9 +59,10 @@ export function tempo(parameters) {
58
59
  }
59
60
  const { currencies: _currencies, ...shared } = parameters ?? {};
60
61
  const [first, ...rest] = Currencies.resolve(parameters ?? {});
62
+ const sponsorBudget = sharedSponsorBudget(parameters);
61
63
  const sessions = sessionOffers(parameters);
62
64
  function createMethods(currency, index) {
63
- const configured = { ...shared, ...currency };
65
+ const configured = { ...shared, ...currency, sponsorBudget };
64
66
  return [createChargeMethod(configured), sessions[index]];
65
67
  }
66
68
  return [
@@ -101,12 +103,22 @@ function resolveCurrencies(parameters) {
101
103
  ...(parameters.currency === undefined ? {} : { currencies: [parameters.currency] }),
102
104
  });
103
105
  }
106
+ /** Shares sponsor exposure across sibling offers without changing replay-store policy. */
107
+ function sharedSponsorBudget(parameters = {}) {
108
+ if (parameters.sponsorBudget === false)
109
+ return false;
110
+ return {
111
+ ...parameters.sponsorBudget,
112
+ store: parameters.sponsorBudget?.store ?? parameters.store ?? Store.memory(),
113
+ };
114
+ }
104
115
  /** Creates OUSD-first charge offers, with explicit currency lists replacing the defaults. */
105
116
  function chargeOffers(parameters) {
106
117
  const { currencies: _currencies, ...shared } = parameters ?? {};
107
118
  const [first, ...rest] = resolveCurrencies(parameters ?? {});
119
+ const sponsorBudget = sharedSponsorBudget(parameters);
108
120
  function create(currency) {
109
- const configured = { ...shared, ...currency };
121
+ const configured = { ...shared, ...currency, sponsorBudget };
110
122
  return charge_(configured);
111
123
  }
112
124
  return [create(first), ...rest.map(create)];
@@ -115,8 +127,9 @@ function chargeOffers(parameters) {
115
127
  function sessionOffers(parameters) {
116
128
  const { currencies: _currencies, ...shared } = parameters ?? {};
117
129
  const [first, ...rest] = resolveCurrencies(parameters ?? {});
118
- const store = parameters?.store ?? Store.memory();
130
+ const store = parameters?.store ?? fallbackStore('tempo.session');
119
131
  const settlements = new Map();
132
+ const verifiers = new Map();
120
133
  const key = (chainId, currency) => `${chainId}:${currency.toLowerCase()}`;
121
134
  const settleScheduled = (channel) => {
122
135
  const settle = settlements.get(key(channel.chainId, channel.token));
@@ -129,6 +142,15 @@ function sessionOffers(parameters) {
129
142
  serveWebSocket: (options) => Ws_.serve({
130
143
  ...options,
131
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
+ },
132
154
  // Failed settlements are reported by the session handler; the charged request stays served.
133
155
  onChargeCommitted: (channel) => settleScheduled(channel).catch(Settlement.ignoreRetryableSettlementFailure),
134
156
  }),
@@ -137,6 +159,7 @@ function sessionOffers(parameters) {
137
159
  const configured = { ...shared, ...currency, store };
138
160
  const method = session_(configured);
139
161
  settlements.set(key(currency.chainId, currency.currency), method.extensions.settleScheduled);
162
+ verifiers.set(key(currency.chainId, currency.currency), (channelId) => verifyStream_(method, channelId));
140
163
  return { ...method, extensions };
141
164
  }
142
165
  return [create(first), ...rest.map(create)];
@@ -145,7 +168,7 @@ function sessionOffers(parameters) {
145
168
  function subscriptionOffers(parameters) {
146
169
  const { currencies: _currencies, ...shared } = parameters;
147
170
  const [first, ...rest] = resolveCurrencies(parameters);
148
- const store = parameters.store ?? Store.memory();
171
+ const store = parameters.store ?? fallbackStore('tempo.subscription');
149
172
  function create(currency) {
150
173
  const configured = { ...shared, ...currency, store };
151
174
  return subscription_(configured);