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.
- package/CHANGELOG.md +48 -0
- package/dist/Credential.d.ts +3 -0
- package/dist/Credential.js +9 -0
- package/dist/Errors.d.ts +63 -2
- package/dist/Errors.js +52 -2
- package/dist/Method.d.ts +10 -0
- package/dist/cli/cli.js +92 -92
- package/dist/cli/plugins/evm.js +2 -2
- package/dist/cli/plugins/index.js +12 -12
- package/dist/client/Mppx.d.ts +2 -0
- package/dist/client/Mppx.js +8 -10
- package/dist/client/internal/Fetch.js +107 -8
- package/dist/client/internal/MethodChallenge.d.ts +2 -0
- package/dist/client/internal/MethodChallenge.js +4 -0
- package/dist/client/internal/MethodResponse.d.ts +6 -0
- package/dist/client/internal/MethodResponse.js +16 -0
- package/dist/client/internal/protocols/Mcp.js +36 -5
- package/dist/evm/server/Charge.js +3 -1
- package/dist/internal/version.d.ts +2 -2
- package/dist/internal/version.js +1 -1
- package/dist/internal/warnOnce.d.ts +3 -0
- package/dist/internal/warnOnce.js +9 -0
- package/dist/server/Mppx.d.ts +5 -0
- package/dist/server/Mppx.js +45 -18
- package/dist/server/Transport.js +10 -0
- package/dist/stripe/server/Methods.js +4 -1
- package/dist/tempo/Attribution.d.ts +24 -9
- package/dist/tempo/Attribution.js +25 -5
- package/dist/tempo/client/Charge.d.ts +116 -10
- package/dist/tempo/client/Charge.js +117 -22
- package/dist/tempo/client/Methods.d.ts +88 -9
- package/dist/tempo/index.d.ts +1 -0
- package/dist/tempo/index.js +1 -0
- package/dist/tempo/internal/auto-swap.d.ts +10 -0
- package/dist/tempo/internal/auto-swap.js +35 -8
- package/dist/tempo/internal/fee-payer.d.ts +8 -0
- package/dist/tempo/internal/fee-payer.js +20 -8
- package/dist/tempo/legacy/session/Chain.js +8 -2
- package/dist/tempo/server/Charge.d.ts +11 -0
- package/dist/tempo/server/Charge.js +160 -29
- package/dist/tempo/server/Methods.js +28 -5
- package/dist/tempo/server/Relay.js +6 -22
- package/dist/tempo/server/Subscription.js +5 -1
- package/dist/tempo/server/internal/fallback-store.d.ts +9 -0
- package/dist/tempo/server/internal/fallback-store.js +13 -0
- package/dist/tempo/server/internal/html.gen.js +1 -1
- package/dist/tempo/server/internal/transport.d.ts +3 -0
- package/dist/tempo/server/internal/transport.js +5 -1
- package/dist/tempo/session/client/ChannelOps.d.ts +2 -0
- package/dist/tempo/session/client/ChannelStore.d.ts +12 -1
- package/dist/tempo/session/client/ChannelStore.js +47 -0
- package/dist/tempo/session/client/CredentialState.js +8 -3
- package/dist/tempo/session/client/Session.js +50 -33
- package/dist/tempo/session/client/SessionManager.js +186 -65
- package/dist/tempo/session/client/Transports.d.ts +6 -0
- package/dist/tempo/session/client/Transports.js +52 -7
- package/dist/tempo/session/precompile/Chain.d.ts +2 -0
- package/dist/tempo/session/precompile/Chain.js +8 -3
- package/dist/tempo/session/server/ChannelStore.d.ts +34 -0
- package/dist/tempo/session/server/ChannelStore.js +69 -9
- package/dist/tempo/session/server/CredentialVerification.d.ts +7 -1
- package/dist/tempo/session/server/CredentialVerification.js +220 -48
- package/dist/tempo/session/server/MeteredStream.d.ts +6 -5
- package/dist/tempo/session/server/MeteredStream.js +10 -26
- package/dist/tempo/session/server/Session.d.ts +29 -2
- package/dist/tempo/session/server/Session.js +124 -44
- package/dist/tempo/session/server/Settlement.d.ts +9 -0
- package/dist/tempo/session/server/Settlement.js +23 -3
- package/dist/tempo/session/server/Sse.d.ts +4 -3
- package/dist/tempo/session/server/Sse.js +3 -3
- package/dist/tempo/session/server/Ws.d.ts +9 -1
- package/dist/tempo/session/server/Ws.js +1 -0
- 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,
|
|
24
|
-
|
|
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
|
|
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 (
|
|
46
|
-
|
|
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
|
-
...
|
|
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
|
|
389
|
-
throw new FeePayerValidationError('swap output
|
|
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
|
-
|
|
494
|
-
|
|
495
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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 ??
|
|
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
|
-
|
|
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 {
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
566
|
-
|
|
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 (
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
953
|
-
*
|
|
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
|
|
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
|
-
|
|
1097
|
+
return false;
|
|
966
1098
|
bound = true;
|
|
967
1099
|
}
|
|
968
|
-
|
|
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 ??
|
|
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 ??
|
|
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);
|