mppx 0.16.0 → 0.17.1
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 +36 -0
- package/dist/BodyDigest.js +13 -1
- package/dist/Challenge.d.ts +6 -0
- package/dist/Challenge.js +12 -0
- package/dist/Method.d.ts +7 -0
- package/dist/cli/cli.js +140 -134
- package/dist/cli/plugins/index.js +11 -11
- package/dist/cli/sessions/store.js +7 -1
- package/dist/cli/validate/helpers.d.ts +1 -1
- package/dist/cli/validate/helpers.js +14 -2
- package/dist/cli/validate/payment.js +39 -15
- package/dist/client/Mppx.js +11 -3
- package/dist/client/internal/Fetch.d.ts +2 -0
- package/dist/client/internal/Fetch.js +59 -3
- package/dist/client/internal/protocols/Mpp.js +4 -1
- package/dist/identity/Client.js +3 -3
- package/dist/identity/Server.d.ts +13 -0
- package/dist/identity/Server.js +50 -1
- package/dist/identity/Types.d.ts +32 -10
- package/dist/internal/AcceptPayment.d.ts +12 -2
- package/dist/internal/AcceptPayment.js +12 -3
- package/dist/internal/version.d.ts +2 -2
- package/dist/internal/version.js +1 -1
- package/dist/mcp/client/McpClient.js +1 -1
- package/dist/proxy/Proxy.d.ts +15 -0
- package/dist/proxy/Proxy.js +89 -1
- package/dist/proxy/internal/Replay.d.ts +105 -0
- package/dist/proxy/internal/Replay.js +257 -0
- package/dist/server/Mppx.d.ts +4 -2
- package/dist/server/Mppx.js +50 -24
- package/dist/server/internal/challengeMeta.d.ts +6 -0
- package/dist/server/internal/challengeMeta.js +17 -1
- package/dist/tempo/client/Charge.d.ts +20 -0
- package/dist/tempo/client/Charge.js +43 -10
- package/dist/tempo/client/Methods.d.ts +2 -0
- package/dist/tempo/internal/defaults.d.ts +9 -0
- package/dist/tempo/internal/defaults.js +9 -0
- package/dist/tempo/internal/fee-payer.js +2 -2
- package/dist/tempo/server/Charge.js +16 -2
- package/dist/tempo/server/internal/html.gen.js +1 -1
- package/dist/tempo/session/client/ChannelOps.d.ts +2 -0
- package/dist/tempo/session/client/ChannelStore.js +5 -0
- package/dist/tempo/session/client/Session.d.ts +26 -1
- package/dist/tempo/session/client/Session.js +72 -1
- package/dist/tempo/session/client/SessionManager.d.ts +1 -0
- package/dist/tempo/session/client/SessionManager.js +54 -7
- package/dist/tempo/session/client/Transports.d.ts +2 -0
- package/dist/tempo/session/client/Transports.js +31 -2
- package/dist/tempo/session/precompile/Voucher.js +20 -7
- package/dist/tempo/session/server/Session.d.ts +2 -1
- package/dist/tempo/session/server/Session.js +2 -1
- package/dist/tempo/session/server/Settlement.d.ts +4 -2
- package/dist/tempo/session/server/Settlement.js +2 -2
- package/dist/tempo/session/server/Ws.js +29 -12
- package/package.json +1 -1
package/dist/server/Mppx.js
CHANGED
|
@@ -467,7 +467,8 @@ function createMethodFn(parameters) {
|
|
|
467
467
|
]),
|
|
468
468
|
],
|
|
469
469
|
_canonicalRequest: PaymentRequest.fromMethod(method, { ...defaults, ...rest }),
|
|
470
|
-
_paymentIdentity: paymentVerifiers.length > 0,
|
|
470
|
+
_paymentIdentity: paymentVerifiers.length > 0 ? paymentVerifiers : undefined,
|
|
471
|
+
_identity: identity.length > 0,
|
|
471
472
|
_stableBinding: stableBinding,
|
|
472
473
|
_transport: transport,
|
|
473
474
|
};
|
|
@@ -500,6 +501,12 @@ function createMethodFn(parameters) {
|
|
|
500
501
|
const effectiveMeta = scope === undefined && input instanceof globalThis.Request
|
|
501
502
|
? Scope.merge({ meta: staticMeta, scope: Scope.get(input) })
|
|
502
503
|
: staticMeta;
|
|
504
|
+
// Every offer of one 402 shares the identity nonce (composed routes pass each offer the
|
|
505
|
+
// same request), so a payment-stage proof is bound to the 402, whichever offer is paid.
|
|
506
|
+
const identityNonce = paymentVerifiers.length > 0 && input instanceof globalThis.Request
|
|
507
|
+
? IdentityServer.nonceFor(input, ChallengeMeta.createNonce)
|
|
508
|
+
: undefined;
|
|
509
|
+
const challengeMeta = ChallengeMeta.withIdentity(effectiveMeta, identityNonce);
|
|
503
510
|
// Extract credential once — getCredential may have side effects (e.g. SSE transports).
|
|
504
511
|
let [credential, credentialError] = (() => {
|
|
505
512
|
try {
|
|
@@ -540,17 +547,12 @@ function createMethodFn(parameters) {
|
|
|
540
547
|
...(parameters.html && { html: parameters.html }),
|
|
541
548
|
});
|
|
542
549
|
// Payment-stage verifiers add their own challenge headers to the 402.
|
|
543
|
-
if (
|
|
550
|
+
if (identityNonce !== undefined &&
|
|
544
551
|
input instanceof globalThis.Request &&
|
|
545
552
|
response instanceof globalThis.Response) {
|
|
546
|
-
const
|
|
547
|
-
challenge: parameters.challenge,
|
|
548
|
-
});
|
|
549
|
-
const identityHeaders = new Headers();
|
|
550
|
-
if (outcome.status !== 'valid')
|
|
551
|
-
for (const [name, value] of outcome.headers)
|
|
552
|
-
identityHeaders.append(name, value);
|
|
553
|
+
const identityHeaders = await IdentityServer.challenge(input, paymentVerifiers, identityNonce);
|
|
553
554
|
if ([...identityHeaders].length > 0) {
|
|
555
|
+
const paymentChallenge = response.headers.get(paymentAuthChallengeHeader);
|
|
554
556
|
const headers = new Headers(response.headers);
|
|
555
557
|
for (const [name, value] of identityHeaders)
|
|
556
558
|
headers.append(name, value);
|
|
@@ -560,6 +562,7 @@ function createMethodFn(parameters) {
|
|
|
560
562
|
statusText: response.statusText,
|
|
561
563
|
});
|
|
562
564
|
identityChallenges.set(response, identityHeaders);
|
|
565
|
+
paymentChallenges.set(response, paymentChallenge);
|
|
563
566
|
}
|
|
564
567
|
}
|
|
565
568
|
if (isIssuedChallengeResponse(response))
|
|
@@ -594,7 +597,7 @@ function createMethodFn(parameters) {
|
|
|
594
597
|
description,
|
|
595
598
|
expires,
|
|
596
599
|
header: credentialHeader,
|
|
597
|
-
meta:
|
|
600
|
+
meta: challengeMeta,
|
|
598
601
|
method,
|
|
599
602
|
realm,
|
|
600
603
|
request: parameters.request,
|
|
@@ -609,7 +612,7 @@ function createMethodFn(parameters) {
|
|
|
609
612
|
description,
|
|
610
613
|
expires,
|
|
611
614
|
header: credentialHeader,
|
|
612
|
-
meta:
|
|
615
|
+
meta: challengeMeta,
|
|
613
616
|
method,
|
|
614
617
|
realm,
|
|
615
618
|
routeRequest: rest,
|
|
@@ -897,6 +900,7 @@ function createMethodFn(parameters) {
|
|
|
897
900
|
const outcome = await IdentityServer.verify(input, paymentVerifiers, {
|
|
898
901
|
challenge: credential.challenge,
|
|
899
902
|
credential: parsedCredential,
|
|
903
|
+
nonce: ChallengeMeta.identity(credential.challenge.meta),
|
|
900
904
|
});
|
|
901
905
|
if (outcome.status !== 'valid') {
|
|
902
906
|
const error = new Errors.VerificationFailedError({ reason: outcome.reason });
|
|
@@ -1348,6 +1352,7 @@ async function resolveRouteChallenge(parameters) {
|
|
|
1348
1352
|
? (await parameters.request({
|
|
1349
1353
|
capturedRequest: parameters.capturedRequest,
|
|
1350
1354
|
credential: parameters.credential,
|
|
1355
|
+
expires: parameters.credential?.challenge.expires ?? parameters.expires,
|
|
1351
1356
|
request: merged,
|
|
1352
1357
|
}))
|
|
1353
1358
|
: merged;
|
|
@@ -1588,17 +1593,30 @@ const paymentAuthChallengeHeader = Constants.Headers.wwwAuthenticate;
|
|
|
1588
1593
|
const identityChallenges = new WeakMap();
|
|
1589
1594
|
/** Identity rejections (401/403) returned on a route's non-success branch. @internal */
|
|
1590
1595
|
const identityRejections = new WeakSet();
|
|
1596
|
+
/**
|
|
1597
|
+
* A 402's Payment `WWW-Authenticate` value without its identity challenge, keyed by response.
|
|
1598
|
+
* Composers merge these and append the shared identity challenge once. @internal
|
|
1599
|
+
*/
|
|
1600
|
+
const paymentChallenges = new WeakMap();
|
|
1591
1601
|
/** Records a merged 402's identity challenge so an outer composer can forward it. @internal */
|
|
1592
|
-
function withIdentityChallenge(response, headers) {
|
|
1593
|
-
if (headers)
|
|
1602
|
+
function withIdentityChallenge(response, headers, paymentChallenge) {
|
|
1603
|
+
if (headers) {
|
|
1594
1604
|
identityChallenges.set(response, headers);
|
|
1605
|
+
paymentChallenges.set(response, paymentChallenge);
|
|
1606
|
+
}
|
|
1595
1607
|
return response;
|
|
1596
1608
|
}
|
|
1597
1609
|
const challengeHeaderMerges = [
|
|
1598
1610
|
{
|
|
1599
1611
|
name: paymentAuthChallengeHeader,
|
|
1600
1612
|
values: (context) => context.challengeEntries
|
|
1601
|
-
.map((entry) =>
|
|
1613
|
+
.map((entry) => {
|
|
1614
|
+
const response = entry.result.challenge;
|
|
1615
|
+
// Identity challenges are appended once by the composer, not once per offer.
|
|
1616
|
+
return paymentChallenges.has(response)
|
|
1617
|
+
? paymentChallenges.get(response)
|
|
1618
|
+
: response.headers.get(paymentAuthChallengeHeader);
|
|
1619
|
+
})
|
|
1602
1620
|
.filter((value) => value !== null),
|
|
1603
1621
|
merge: (values) => values,
|
|
1604
1622
|
},
|
|
@@ -1639,9 +1657,13 @@ function composeHandlers(handlers, composeOptions, selectOffers) {
|
|
|
1639
1657
|
});
|
|
1640
1658
|
if (handlers.length === 0)
|
|
1641
1659
|
throw new Error('compose() requires at least one handler');
|
|
1642
|
-
// A 402 carries one identity challenge,
|
|
1643
|
-
|
|
1644
|
-
|
|
1660
|
+
// A 402 carries one identity challenge, shared by its offers through the identity nonce, so
|
|
1661
|
+
// every offer that requires payment-stage identity must require the same verifiers.
|
|
1662
|
+
const identityOffers = handlers
|
|
1663
|
+
.flatMap(getConfiguredOffers)
|
|
1664
|
+
.flatMap((offer) => (offer._paymentIdentity ? [offer._paymentIdentity] : []));
|
|
1665
|
+
if (identityOffers.some((verifiers) => !sameVerifiers(verifiers, identityOffers[0])))
|
|
1666
|
+
throw new TypeError('Offers in a composed route must require the same payment-stage identity verifiers.');
|
|
1645
1667
|
const offerSelector = selectOffers;
|
|
1646
1668
|
const offerEntries = handlers.map((handler) => {
|
|
1647
1669
|
const internal = handler._internal;
|
|
@@ -1809,8 +1831,8 @@ function composeHandlers(handlers, composeOptions, selectOffers) {
|
|
|
1809
1831
|
...unnegotiatedX402Responses,
|
|
1810
1832
|
]
|
|
1811
1833
|
: challengeResponses;
|
|
1812
|
-
//
|
|
1813
|
-
//
|
|
1834
|
+
// Offers share one identity challenge (enforced when composing); forward it only if an
|
|
1835
|
+
// offer that carries it is still advertised after negotiation.
|
|
1814
1836
|
const identityHeaders = negotiatedChallengeResponses
|
|
1815
1837
|
.map((response) => identityChallenges.get(response))
|
|
1816
1838
|
.find((headers) => headers !== undefined);
|
|
@@ -1826,11 +1848,11 @@ function composeHandlers(handlers, composeOptions, selectOffers) {
|
|
|
1826
1848
|
mergedHeaders.append(header.name, value);
|
|
1827
1849
|
}
|
|
1828
1850
|
}
|
|
1829
|
-
//
|
|
1851
|
+
// The merged offer values above exclude identity challenges; append the shared one once.
|
|
1852
|
+
const paymentChallenge = mergedHeaders.get(paymentAuthChallengeHeader);
|
|
1830
1853
|
if (identityHeaders)
|
|
1831
1854
|
for (const [name, value] of identityHeaders)
|
|
1832
|
-
|
|
1833
|
-
mergedHeaders.append(name, value);
|
|
1855
|
+
mergedHeaders.append(name, value);
|
|
1834
1856
|
// Collect html-enabled handlers and their challenges
|
|
1835
1857
|
const htmlEntries = challengeEntries.filter((entry) => entry.handler._internal?.html);
|
|
1836
1858
|
const wantsHtml = input.headers.get('Accept')?.includes('text/html');
|
|
@@ -1867,7 +1889,7 @@ function composeHandlers(handlers, composeOptions, selectOffers) {
|
|
|
1867
1889
|
});
|
|
1868
1890
|
return {
|
|
1869
1891
|
status: 402,
|
|
1870
|
-
challenge: withIdentityChallenge(new Response(body, { status: 402, headers: mergedHeaders }), identityHeaders),
|
|
1892
|
+
challenge: withIdentityChallenge(new Response(body, { status: 402, headers: mergedHeaders }), identityHeaders, paymentChallenge),
|
|
1871
1893
|
};
|
|
1872
1894
|
}
|
|
1873
1895
|
// Non-HTML fallback: prefer the first Payment-auth body, otherwise use
|
|
@@ -1887,7 +1909,7 @@ function composeHandlers(handlers, composeOptions, selectOffers) {
|
|
|
1887
1909
|
}
|
|
1888
1910
|
return {
|
|
1889
1911
|
status: 402,
|
|
1890
|
-
challenge: withIdentityChallenge(new Response(body, { status: 402, headers: mergedHeaders }), identityHeaders),
|
|
1912
|
+
challenge: withIdentityChallenge(new Response(body, { status: 402, headers: mergedHeaders }), identityHeaders, paymentChallenge),
|
|
1891
1913
|
};
|
|
1892
1914
|
};
|
|
1893
1915
|
const offers = handlers.flatMap(getConfiguredOffers);
|
|
@@ -1895,6 +1917,10 @@ function composeHandlers(handlers, composeOptions, selectOffers) {
|
|
|
1895
1917
|
composed._internal = { offers, handlers: handlers, selectOffers };
|
|
1896
1918
|
return composed;
|
|
1897
1919
|
}
|
|
1920
|
+
/** Whether two verifier lists hold the same verifiers in the same order. */
|
|
1921
|
+
function sameVerifiers(a, b) {
|
|
1922
|
+
return a.length === b.length && a.every((verifier, i) => verifier === b[i]);
|
|
1923
|
+
}
|
|
1898
1924
|
/** Returns the flattened configured offers accepted by a handler. */
|
|
1899
1925
|
function getConfiguredOffers(handler) {
|
|
1900
1926
|
const internal = handler._internal;
|
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
/** Adds fresh issuance randomness while preserving application and route metadata. */
|
|
2
2
|
export declare function withNonce(meta: Record<string, string> | undefined): Record<string, string>;
|
|
3
|
+
/** Returns fresh randomness for an identity nonce. */
|
|
4
|
+
export declare function createNonce(): string;
|
|
5
|
+
/** Adds an identity nonce to challenge metadata. */
|
|
6
|
+
export declare function withIdentity(meta: Record<string, string> | undefined, nonce: string | undefined): Record<string, string> | undefined;
|
|
7
|
+
/** Reads the identity nonce from challenge metadata. */
|
|
8
|
+
export declare function identity(meta: Record<string, string> | undefined): string | undefined;
|
|
3
9
|
/** Removes issuance-only metadata when comparing stable route requirements. */
|
|
4
10
|
export declare function routeMeta(meta: Record<string, string> | undefined): Record<string, string> | undefined;
|
|
5
11
|
//# sourceMappingURL=challengeMeta.d.ts.map
|
|
@@ -6,9 +6,25 @@ export function withNonce(meta) {
|
|
|
6
6
|
// Ox uses runtime-specific secure randomness, including Node without global Web Crypto.
|
|
7
7
|
return { ...meta, [nonceKey]: Secp256k1.randomPrivateKey() };
|
|
8
8
|
}
|
|
9
|
+
/** Reserved metadata key for the HMAC-bound identity nonce shared by every offer in one 402. */
|
|
10
|
+
const identityKey = '_mppx_identity';
|
|
11
|
+
/** Returns fresh randomness for an identity nonce. */
|
|
12
|
+
export function createNonce() {
|
|
13
|
+
return Secp256k1.randomPrivateKey();
|
|
14
|
+
}
|
|
15
|
+
/** Adds an identity nonce to challenge metadata. */
|
|
16
|
+
export function withIdentity(meta, nonce) {
|
|
17
|
+
if (nonce === undefined)
|
|
18
|
+
return meta;
|
|
19
|
+
return { ...meta, [identityKey]: nonce };
|
|
20
|
+
}
|
|
21
|
+
/** Reads the identity nonce from challenge metadata. */
|
|
22
|
+
export function identity(meta) {
|
|
23
|
+
return meta?.[identityKey];
|
|
24
|
+
}
|
|
9
25
|
/** Removes issuance-only metadata when comparing stable route requirements. */
|
|
10
26
|
export function routeMeta(meta) {
|
|
11
|
-
const { [nonceKey]: _, ...rest } = meta ?? {};
|
|
27
|
+
const { [nonceKey]: _, [identityKey]: __, ...rest } = meta ?? {};
|
|
12
28
|
return Object.keys(rest).length ? rest : undefined;
|
|
13
29
|
}
|
|
14
30
|
//# sourceMappingURL=challengeMeta.js.map
|
|
@@ -6,10 +6,22 @@ import * as z from '../../zod.js';
|
|
|
6
6
|
import * as AutoSwap from '../internal/auto-swap.js';
|
|
7
7
|
import * as Methods from '../Methods.js';
|
|
8
8
|
import type * as AccountResolution from './ResolveAccount.js';
|
|
9
|
+
/**
|
|
10
|
+
* Seconds from now the client sets as the expiring-nonce `validBefore`,
|
|
11
|
+
* capped at the challenge expiry.
|
|
12
|
+
*
|
|
13
|
+
* Fee payers require at least `defaults.minRemainingValiditySeconds` (15s) of
|
|
14
|
+
* remaining validity when they co-sign, so a 25s window leaves about 10s for
|
|
15
|
+
* clock skew between client and server, signing and round trips. It stays inside
|
|
16
|
+
* the server's default 30s confirmation timeout and within sponsor policies whose
|
|
17
|
+
* `maxValidityWindowSeconds` is 25 or more.
|
|
18
|
+
*/
|
|
19
|
+
export declare const validityWindowSeconds = 25;
|
|
9
20
|
/** Runtime context accepted by the Tempo charge client method. */
|
|
10
21
|
export type ChargeContext = {
|
|
11
22
|
account?: Account.getResolver.Parameters['account'] | undefined;
|
|
12
23
|
autoSwap?: AutoSwap.resolve.Value | undefined;
|
|
24
|
+
feeToken?: Address | undefined;
|
|
13
25
|
mode?: Methods.ChargeMode | undefined;
|
|
14
26
|
};
|
|
15
27
|
/**
|
|
@@ -30,6 +42,7 @@ export declare function charge(parameters?: charge.Parameters): {
|
|
|
30
42
|
readonly context?: z.ZodMiniObject<{
|
|
31
43
|
account: z.ZodMiniOptional<z.ZodMiniCustom<`0x${string}` | Account.Account | undefined, `0x${string}` | Account.Account | undefined>>;
|
|
32
44
|
autoSwap: z.ZodMiniOptional<z.ZodMiniCustom<AutoSwap.resolve.Value | undefined, AutoSwap.resolve.Value | undefined>>;
|
|
45
|
+
feeToken: z.ZodMiniOptional<z.ZodMiniCustom<`0x${string}` | undefined, `0x${string}` | undefined>>;
|
|
33
46
|
mode: z.ZodMiniOptional<z.ZodMiniEnum<{
|
|
34
47
|
pull: "pull";
|
|
35
48
|
push: "push";
|
|
@@ -181,6 +194,7 @@ export declare function charge(parameters?: charge.Parameters): {
|
|
|
181
194
|
}, {
|
|
182
195
|
account?: `0x${string}` | Account.Account | undefined;
|
|
183
196
|
autoSwap?: AutoSwap.resolve.Value | undefined;
|
|
197
|
+
feeToken?: `0x${string}` | undefined;
|
|
184
198
|
mode?: "pull" | "push" | undefined;
|
|
185
199
|
}>;
|
|
186
200
|
};
|
|
@@ -199,6 +213,12 @@ export declare namespace charge {
|
|
|
199
213
|
autoSwap?: AutoSwap | undefined;
|
|
200
214
|
/** Client identifier used to derive the client fingerprint in attribution memos. */
|
|
201
215
|
clientId?: string | undefined;
|
|
216
|
+
/**
|
|
217
|
+
* Token the payer pays its transaction fee in, when the challenge is not fee-sponsored.
|
|
218
|
+
* Set it for an access key whose spending limits do not cover the default fee token,
|
|
219
|
+
* such as a key limited to OUSD paying a USDC.e charge.
|
|
220
|
+
*/
|
|
221
|
+
feeToken?: Address | undefined;
|
|
202
222
|
/**
|
|
203
223
|
* Chain ID this client is willing to pay on. When set, the client rejects
|
|
204
224
|
* any challenge whose `methodDetails.chainId` differs, and signs on this
|
|
@@ -17,9 +17,21 @@ import { defaultFeeTokens, resolveFeeToken } from '../internal/fee-token.js';
|
|
|
17
17
|
import * as MachineTokenCharge from '../internal/machine-token-charge.js';
|
|
18
18
|
import * as Proof from '../internal/proof.js';
|
|
19
19
|
import * as Methods from '../Methods.js';
|
|
20
|
+
/**
|
|
21
|
+
* Seconds from now the client sets as the expiring-nonce `validBefore`,
|
|
22
|
+
* capped at the challenge expiry.
|
|
23
|
+
*
|
|
24
|
+
* Fee payers require at least `defaults.minRemainingValiditySeconds` (15s) of
|
|
25
|
+
* remaining validity when they co-sign, so a 25s window leaves about 10s for
|
|
26
|
+
* clock skew between client and server, signing and round trips. It stays inside
|
|
27
|
+
* the server's default 30s confirmation timeout and within sponsor policies whose
|
|
28
|
+
* `maxValidityWindowSeconds` is 25 or more.
|
|
29
|
+
*/
|
|
30
|
+
export const validityWindowSeconds = 25;
|
|
20
31
|
const chargeContextSchema = z.object({
|
|
21
32
|
account: z.optional(z.custom()),
|
|
22
33
|
autoSwap: z.optional(z.custom()),
|
|
34
|
+
feeToken: z.optional(z.custom()),
|
|
23
35
|
mode: z.optional(z.enum(Methods.chargeModes)),
|
|
24
36
|
});
|
|
25
37
|
/**
|
|
@@ -177,15 +189,18 @@ export function charge(parameters = {}) {
|
|
|
177
189
|
}
|
|
178
190
|
const calls = machineTokenRoute?.calls ?? [...(swapCalls ?? []), ...transferCalls];
|
|
179
191
|
const allowedFeeTokens = defaultFeeTokens(chainId);
|
|
180
|
-
const
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
192
|
+
const explicitFeeToken = context?.feeToken ?? parameters.feeToken;
|
|
193
|
+
const feeToken = explicitFeeToken
|
|
194
|
+
? explicitFeeToken
|
|
195
|
+
: machineTokenRoute || isMachineTokenCurrency
|
|
196
|
+
? await resolveFeeToken({
|
|
197
|
+
account: account.address,
|
|
198
|
+
allowedTokens: allowedFeeTokens,
|
|
199
|
+
candidateTokens: allowedFeeTokens,
|
|
200
|
+
client,
|
|
201
|
+
prioritizeCandidates: true,
|
|
202
|
+
})
|
|
203
|
+
: undefined;
|
|
189
204
|
const mode = (() => {
|
|
190
205
|
const explicitMode = context?.mode ?? parameters.mode;
|
|
191
206
|
if (explicitMode) {
|
|
@@ -199,7 +214,7 @@ export function charge(parameters = {}) {
|
|
|
199
214
|
return supportedModes[0];
|
|
200
215
|
})();
|
|
201
216
|
const validBefore = (() => {
|
|
202
|
-
const defaultExpiry = Math.floor(Date.now() / 1000) +
|
|
217
|
+
const defaultExpiry = Math.floor(Date.now() / 1000) + validityWindowSeconds;
|
|
203
218
|
if (!challenge.expires)
|
|
204
219
|
return defaultExpiry;
|
|
205
220
|
// `validBefore` is an exclusive whole-second bound, so cap at the second
|
|
@@ -236,6 +251,22 @@ export function charge(parameters = {}) {
|
|
|
236
251
|
source: Proof.proofSource({ address: account.address, chainId }),
|
|
237
252
|
});
|
|
238
253
|
}
|
|
254
|
+
// The fee payer rejects a sponsored transaction with under the minimum remaining validity,
|
|
255
|
+
// so refuse before signing rather than produce a credential the server must reject.
|
|
256
|
+
// The fee payer checks the signed `validBefore`, which is fixed before preparation, so the
|
|
257
|
+
// re-check measures both it and the challenge expiry against the current time.
|
|
258
|
+
const assertSponsorable = () => {
|
|
259
|
+
if (methodDetails?.feePayer !== true)
|
|
260
|
+
return;
|
|
261
|
+
const minimumMs = defaults.minRemainingValiditySeconds * 1000;
|
|
262
|
+
const expiresMs = challenge.expires ? new Date(challenge.expires).getTime() : Infinity;
|
|
263
|
+
const now = Date.now();
|
|
264
|
+
if (!(expiresMs - now >= minimumMs))
|
|
265
|
+
throw new Error(`Sponsored challenge expires in less than ${defaults.minRemainingValiditySeconds} seconds; the fee payer would reject the transaction. Request a new challenge.`);
|
|
266
|
+
if (!(validBefore * 1000 - now >= minimumMs))
|
|
267
|
+
throw new Error(`Sponsored transaction preparation took too long: less than ${defaults.minRemainingValiditySeconds} seconds of its validity window remain, so the fee payer would reject it.`);
|
|
268
|
+
};
|
|
269
|
+
assertSponsorable();
|
|
239
270
|
const prepared = await prepareTransactionRequest(client, {
|
|
240
271
|
account,
|
|
241
272
|
calls,
|
|
@@ -252,6 +283,8 @@ export function charge(parameters = {}) {
|
|
|
252
283
|
delete sponsored.feeToken;
|
|
253
284
|
sponsored.feePayer = true;
|
|
254
285
|
}
|
|
286
|
+
// Preparation is asynchronous: check again immediately before signing.
|
|
287
|
+
assertSponsorable();
|
|
255
288
|
const signature = await signTransaction(client, prepared);
|
|
256
289
|
return Credential.serialize({
|
|
257
290
|
challenge,
|
|
@@ -24,6 +24,7 @@ export declare function tempo(parameters?: tempo.Parameters): readonly [{
|
|
|
24
24
|
readonly context?: import("zod/mini").ZodMiniObject<{
|
|
25
25
|
account: import("zod/mini").ZodMiniOptional<import("zod/mini").ZodMiniCustom<`0x${string}` | import("../../viem/Account.js").Account | undefined, `0x${string}` | import("../../viem/Account.js").Account | undefined>>;
|
|
26
26
|
autoSwap: import("zod/mini").ZodMiniOptional<import("zod/mini").ZodMiniCustom<import("../internal/auto-swap.js").resolve.Value | undefined, import("../internal/auto-swap.js").resolve.Value | undefined>>;
|
|
27
|
+
feeToken: import("zod/mini").ZodMiniOptional<import("zod/mini").ZodMiniCustom<`0x${string}` | undefined, `0x${string}` | undefined>>;
|
|
27
28
|
mode: import("zod/mini").ZodMiniOptional<import("zod/mini").ZodMiniEnum<{
|
|
28
29
|
pull: "pull";
|
|
29
30
|
push: "push";
|
|
@@ -175,6 +176,7 @@ export declare function tempo(parameters?: tempo.Parameters): readonly [{
|
|
|
175
176
|
}, {
|
|
176
177
|
account?: `0x${string}` | import("../../viem/Account.js").Account | undefined;
|
|
177
178
|
autoSwap?: import("../internal/auto-swap.js").resolve.Value | undefined;
|
|
179
|
+
feeToken?: `0x${string}` | undefined;
|
|
178
180
|
mode?: "pull" | "push" | undefined;
|
|
179
181
|
}>;
|
|
180
182
|
}, import("../../Method.js").Client<{
|
|
@@ -37,6 +37,15 @@ export declare const machineToken: Readonly<{
|
|
|
37
37
|
* runtimes should set `decimals` explicitly to match their token.
|
|
38
38
|
*/
|
|
39
39
|
export declare const decimals = 6;
|
|
40
|
+
/**
|
|
41
|
+
* Minimum remaining validity, in seconds, of a fee-sponsored transaction.
|
|
42
|
+
*
|
|
43
|
+
* Servers refuse to co-sign a sponsored transaction whose `validBefore` is
|
|
44
|
+
* closer than this, clients refuse to sign for a sponsored challenge that
|
|
45
|
+
* expires sooner, and servers refuse to issue a sponsored challenge that
|
|
46
|
+
* expires sooner after issuance.
|
|
47
|
+
*/
|
|
48
|
+
export declare const minRemainingValiditySeconds = 15;
|
|
40
49
|
/** Default payment-channel escrow contract addresses per chain. */
|
|
41
50
|
export declare const escrowContract: {
|
|
42
51
|
readonly 4217: '0x33b901018174DDabE4841042ab76ba85D4e24f25';
|
|
@@ -37,6 +37,15 @@ export const machineToken = Object.freeze({
|
|
|
37
37
|
* runtimes should set `decimals` explicitly to match their token.
|
|
38
38
|
*/
|
|
39
39
|
export const decimals = 6;
|
|
40
|
+
/**
|
|
41
|
+
* Minimum remaining validity, in seconds, of a fee-sponsored transaction.
|
|
42
|
+
*
|
|
43
|
+
* Servers refuse to co-sign a sponsored transaction whose `validBefore` is
|
|
44
|
+
* closer than this, clients refuse to sign for a sponsored challenge that
|
|
45
|
+
* expires sooner, and servers refuse to issue a sponsored challenge that
|
|
46
|
+
* expires sooner after issuance.
|
|
47
|
+
*/
|
|
48
|
+
export const minRemainingValiditySeconds = 15;
|
|
40
49
|
/** Default payment-channel escrow contract addresses per chain. */
|
|
41
50
|
export const escrowContract = {
|
|
42
51
|
[chainId.mainnet]: '0x33b901018174DDabE4841042ab76ba85D4e24f25',
|
|
@@ -445,8 +445,8 @@ export function assertRemainingValidity(parameters) {
|
|
|
445
445
|
return fail('fee-sponsored transaction must declare validBefore for the expiring nonce');
|
|
446
446
|
if (validBefore <= Math.floor(now.getTime() / 1_000))
|
|
447
447
|
fail('fee-sponsored transaction has already expired');
|
|
448
|
-
if (validBefore < Math.ceil(now.getTime() / 1_000) +
|
|
449
|
-
fail(
|
|
448
|
+
if (validBefore < Math.ceil(now.getTime() / 1_000) + defaults.minRemainingValiditySeconds)
|
|
449
|
+
fail(`fee-sponsored transaction needs at least ${defaults.minRemainingValiditySeconds} seconds of remaining validity`);
|
|
450
450
|
}
|
|
451
451
|
/** Validates sponsor fee policy limits for a fee-payer transaction. */
|
|
452
452
|
export function assertTransactionPolicy(parameters) {
|
|
@@ -350,7 +350,7 @@ export function charge(parameters = {}) {
|
|
|
350
350
|
}
|
|
351
351
|
: undefined,
|
|
352
352
|
// TODO: dedupe `{charge,session}.request`
|
|
353
|
-
async request({ credential, request }) {
|
|
353
|
+
async request({ credential, expires, request }) {
|
|
354
354
|
const chainId = await (async () => {
|
|
355
355
|
if (request.chainId)
|
|
356
356
|
return request.chainId;
|
|
@@ -385,6 +385,17 @@ export function charge(parameters = {}) {
|
|
|
385
385
|
return account ?? (remoteFeePayer ? true : undefined);
|
|
386
386
|
return account || remoteFeePayer ? true : undefined;
|
|
387
387
|
})();
|
|
388
|
+
// Issued sponsored challenges must leave room for the fee payer's
|
|
389
|
+
// minimum remaining validity, or every credential for them is rejected.
|
|
390
|
+
// Zero-amount proofs and push-only routes never use the fee payer.
|
|
391
|
+
const modes = (request.supportedModes ?? supportedModes);
|
|
392
|
+
if (!credential &&
|
|
393
|
+
resolvedFeePayer &&
|
|
394
|
+
Number(request.amount) > 0 &&
|
|
395
|
+
(modes === undefined || modes.includes('pull')) &&
|
|
396
|
+
expires !== undefined &&
|
|
397
|
+
!(Date.parse(expires) - Date.now() >= defaults.minRemainingValiditySeconds * 1000))
|
|
398
|
+
throw new Error(`Sponsored charge challenges must expire at least ${defaults.minRemainingValiditySeconds} seconds after issuance.`);
|
|
388
399
|
return {
|
|
389
400
|
...request,
|
|
390
401
|
machineTokenEnabled: request.machineTokenEnabled || undefined,
|
|
@@ -1031,6 +1042,8 @@ const ambiguousErrorNames = new Set([
|
|
|
1031
1042
|
* - JSON-RPC code `4`: the EIP-7966 timeout for a pooled, unconfirmed transaction.
|
|
1032
1043
|
* - Messages containing the words "already known" or "known transaction": already
|
|
1033
1044
|
* pooled ("unknown transaction type" does not match).
|
|
1045
|
+
* - Tempo's `ExpiringNonceReplay`: an expiring-nonce transaction with this hash was
|
|
1046
|
+
* already accepted, such as when a transport retries a send the node took.
|
|
1034
1047
|
*/
|
|
1035
1048
|
function isAmbiguousBroadcastError(error) {
|
|
1036
1049
|
for (let cause = error; cause; cause = cause.cause) {
|
|
@@ -1043,7 +1056,8 @@ function isAmbiguousBroadcastError(error) {
|
|
|
1043
1056
|
return true;
|
|
1044
1057
|
if (code === 4)
|
|
1045
1058
|
return true;
|
|
1046
|
-
if (typeof message === 'string' &&
|
|
1059
|
+
if (typeof message === 'string' &&
|
|
1060
|
+
/\balready ?known\b|\bknown transaction\b|\bExpiringNonceReplay\b/i.test(message))
|
|
1047
1061
|
return true;
|
|
1048
1062
|
}
|
|
1049
1063
|
return false;
|