mppx 0.3.12 → 0.3.14
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/dist/Challenge.d.ts +1 -1
- package/dist/Challenge.d.ts.map +1 -1
- package/dist/Challenge.js +107 -15
- package/dist/Challenge.js.map +1 -1
- package/dist/client/internal/Fetch.d.ts.map +1 -1
- package/dist/client/internal/Fetch.js +55 -9
- package/dist/client/internal/Fetch.js.map +1 -1
- package/dist/internal/constantTimeEqual.d.ts.map +1 -1
- package/dist/internal/constantTimeEqual.js +7 -4
- package/dist/internal/constantTimeEqual.js.map +1 -1
- package/dist/server/Mppx.d.ts.map +1 -1
- package/dist/server/Mppx.js +2 -1
- package/dist/server/Mppx.js.map +1 -1
- package/dist/stripe/Methods.d.ts +0 -3
- package/dist/stripe/Methods.d.ts.map +1 -1
- package/dist/stripe/Methods.js +0 -2
- package/dist/stripe/Methods.js.map +1 -1
- package/dist/stripe/client/Charge.d.ts +0 -3
- package/dist/stripe/client/Charge.d.ts.map +1 -1
- package/dist/stripe/client/Charge.js +2 -2
- package/dist/stripe/client/Charge.js.map +1 -1
- package/dist/stripe/client/Methods.d.ts +0 -3
- package/dist/stripe/client/Methods.d.ts.map +1 -1
- package/dist/stripe/server/Charge.d.ts +0 -3
- package/dist/stripe/server/Charge.d.ts.map +1 -1
- package/dist/stripe/server/Charge.js +2 -2
- package/dist/stripe/server/Charge.js.map +1 -1
- package/dist/stripe/server/Methods.d.ts +0 -3
- package/dist/stripe/server/Methods.d.ts.map +1 -1
- package/dist/tempo/Methods.d.ts +0 -3
- package/dist/tempo/Methods.d.ts.map +1 -1
- package/dist/tempo/Methods.js +3 -3
- package/dist/tempo/Methods.js.map +1 -1
- package/dist/tempo/client/Charge.d.ts +13 -3
- package/dist/tempo/client/Charge.d.ts.map +1 -1
- package/dist/tempo/client/Charge.js +18 -1
- package/dist/tempo/client/Charge.js.map +1 -1
- package/dist/tempo/client/Methods.d.ts +4 -3
- package/dist/tempo/client/Methods.d.ts.map +1 -1
- package/dist/tempo/server/Charge.d.ts +0 -3
- package/dist/tempo/server/Charge.d.ts.map +1 -1
- package/dist/tempo/server/Charge.js +2 -1
- package/dist/tempo/server/Charge.js.map +1 -1
- package/dist/tempo/server/Methods.d.ts +0 -3
- package/dist/tempo/server/Methods.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/Challenge.test.ts +94 -18
- package/src/Challenge.ts +118 -15
- package/src/PaymentRequest.test.ts +0 -5
- package/src/client/Mppx.test.ts +5 -5
- package/src/client/Transport.test.ts +5 -8
- package/src/client/internal/Fetch.browser.test.ts +135 -0
- package/src/client/internal/Fetch.test.ts +16 -60
- package/src/client/internal/Fetch.ts +66 -9
- package/src/internal/constantTimeEqual.ts +6 -4
- package/src/mcp-sdk/client/McpClient.test.ts +1 -1
- package/src/server/Mppx.ts +3 -1
- package/src/server/Transport.test.ts +6 -9
- package/src/stripe/Methods.ts +0 -2
- package/src/stripe/client/Charge.ts +2 -2
- package/src/stripe/server/Charge.ts +2 -2
- package/src/tempo/Methods.test.ts +22 -0
- package/src/tempo/Methods.ts +3 -3
- package/src/tempo/client/Charge.ts +29 -1
- package/src/tempo/server/Charge.test.ts +34 -72
- package/src/tempo/server/Charge.ts +2 -1
|
@@ -552,66 +552,6 @@ describe('Fetch.from: 402 retry path', () => {
|
|
|
552
552
|
})
|
|
553
553
|
})
|
|
554
554
|
|
|
555
|
-
describe('Fetch.from: 402 retry headers normalization', () => {
|
|
556
|
-
test('preserves headers when passed as a Headers instance', async () => {
|
|
557
|
-
let callCount = 0
|
|
558
|
-
const calls: { init: RequestInit | undefined }[] = []
|
|
559
|
-
const mockFetch: typeof globalThis.fetch = async (_input, init) => {
|
|
560
|
-
calls.push({ init })
|
|
561
|
-
callCount++
|
|
562
|
-
if (callCount === 1) return make402()
|
|
563
|
-
return new Response('OK', { status: 200 })
|
|
564
|
-
}
|
|
565
|
-
|
|
566
|
-
const fetch = Fetch.from({
|
|
567
|
-
fetch: mockFetch,
|
|
568
|
-
methods: [noopMethod],
|
|
569
|
-
})
|
|
570
|
-
|
|
571
|
-
const headers = new Headers({ 'X-Custom': 'value', 'Content-Type': 'application/json' })
|
|
572
|
-
await fetch('https://example.com/api', { headers })
|
|
573
|
-
|
|
574
|
-
const retryHeaders = (calls[1]!.init as Record<string, unknown>).headers as Record<
|
|
575
|
-
string,
|
|
576
|
-
string
|
|
577
|
-
>
|
|
578
|
-
expect(retryHeaders['x-custom']).toBe('value')
|
|
579
|
-
expect(retryHeaders['content-type']).toBe('application/json')
|
|
580
|
-
expect(retryHeaders.Authorization).toBe('credential')
|
|
581
|
-
})
|
|
582
|
-
|
|
583
|
-
test('preserves headers when passed as array of tuples', async () => {
|
|
584
|
-
let callCount = 0
|
|
585
|
-
const calls: { init: RequestInit | undefined }[] = []
|
|
586
|
-
const mockFetch: typeof globalThis.fetch = async (_input, init) => {
|
|
587
|
-
calls.push({ init })
|
|
588
|
-
callCount++
|
|
589
|
-
if (callCount === 1) return make402()
|
|
590
|
-
return new Response('OK', { status: 200 })
|
|
591
|
-
}
|
|
592
|
-
|
|
593
|
-
const fetch = Fetch.from({
|
|
594
|
-
fetch: mockFetch,
|
|
595
|
-
methods: [noopMethod],
|
|
596
|
-
})
|
|
597
|
-
|
|
598
|
-
await fetch('https://example.com/api', {
|
|
599
|
-
headers: [
|
|
600
|
-
['X-Custom', 'value'],
|
|
601
|
-
['Accept', 'application/json'],
|
|
602
|
-
],
|
|
603
|
-
})
|
|
604
|
-
|
|
605
|
-
const retryHeaders = (calls[1]!.init as Record<string, unknown>).headers as Record<
|
|
606
|
-
string,
|
|
607
|
-
string
|
|
608
|
-
>
|
|
609
|
-
expect(retryHeaders['X-Custom']).toBe('value')
|
|
610
|
-
expect(retryHeaders.Accept).toBe('application/json')
|
|
611
|
-
expect(retryHeaders.Authorization).toBe('credential')
|
|
612
|
-
})
|
|
613
|
-
})
|
|
614
|
-
|
|
615
555
|
describe('Fetch.from: input passthrough', () => {
|
|
616
556
|
test('passes URL input through on both initial and retry calls', async () => {
|
|
617
557
|
let callCount = 0
|
|
@@ -722,4 +662,20 @@ describe('Fetch.polyfill / restore', () => {
|
|
|
722
662
|
Fetch.restore()
|
|
723
663
|
expect(globalThis.fetch).toBe(originalFetch)
|
|
724
664
|
})
|
|
665
|
+
|
|
666
|
+
test('restore is a no-op when fetch was replaced externally after polyfill', () => {
|
|
667
|
+
const originalFetch = globalThis.fetch
|
|
668
|
+
const externalFetch = vi.fn(
|
|
669
|
+
async (_input: RequestInfo | URL, _init?: RequestInit) =>
|
|
670
|
+
new Response('external', { status: 200 }),
|
|
671
|
+
) as unknown as typeof globalThis.fetch
|
|
672
|
+
|
|
673
|
+
Fetch.polyfill({ methods: [noopMethod] })
|
|
674
|
+
globalThis.fetch = externalFetch
|
|
675
|
+
|
|
676
|
+
Fetch.restore()
|
|
677
|
+
expect(globalThis.fetch).toBe(externalFetch)
|
|
678
|
+
|
|
679
|
+
globalThis.fetch = originalFetch
|
|
680
|
+
})
|
|
725
681
|
})
|
|
@@ -2,6 +2,15 @@ import * as Challenge from '../../Challenge.js'
|
|
|
2
2
|
import type * as Method from '../../Method.js'
|
|
3
3
|
import type * as z from '../../zod.js'
|
|
4
4
|
|
|
5
|
+
// We tag wrappers with a global symbol so we can recognize wrappers created by mppx,
|
|
6
|
+
// even across multiple module instances/bundles. This lets restore() avoid clobbering
|
|
7
|
+
// an unrelated fetch installed by user code or another library.
|
|
8
|
+
const MPPX_FETCH_WRAPPER = Symbol.for('mppx.fetch.wrapper')
|
|
9
|
+
|
|
10
|
+
type WrappedFetch = typeof globalThis.fetch & {
|
|
11
|
+
[MPPX_FETCH_WRAPPER]?: typeof globalThis.fetch
|
|
12
|
+
}
|
|
13
|
+
|
|
5
14
|
let originalFetch: typeof globalThis.fetch | undefined
|
|
6
15
|
|
|
7
16
|
/**
|
|
@@ -29,10 +38,13 @@ export function from<const methods extends readonly Method.AnyClient[]>(
|
|
|
29
38
|
config: from.Config<methods>,
|
|
30
39
|
): from.Fetch<methods> {
|
|
31
40
|
const { fetch = globalThis.fetch, methods, onChallenge } = config
|
|
41
|
+
// Always operate on the true underlying fetch to avoid wrapper-on-wrapper stacking,
|
|
42
|
+
// which can duplicate retries and make restore semantics fragile.
|
|
43
|
+
const baseFetch = unwrapFetch(fetch)
|
|
32
44
|
|
|
33
|
-
|
|
45
|
+
const wrappedFetch = async (input: RequestInfo | URL, init?: from.RequestInit<methods>) => {
|
|
34
46
|
// Pass init through untouched to preserve object identity for non-402 responses.
|
|
35
|
-
const response = await
|
|
47
|
+
const response = await baseFetch(input, init)
|
|
36
48
|
|
|
37
49
|
if (response.status !== 402) return response
|
|
38
50
|
|
|
@@ -55,15 +67,18 @@ export function from<const methods extends readonly Method.AnyClient[]>(
|
|
|
55
67
|
})
|
|
56
68
|
: undefined
|
|
57
69
|
const credential = onChallengeCredential ?? (await resolveCredential(challenge, mi, context))
|
|
70
|
+
validateCredentialHeaderValue(credential)
|
|
58
71
|
|
|
59
|
-
return
|
|
72
|
+
return baseFetch(input, {
|
|
60
73
|
...fetchInit,
|
|
61
|
-
headers:
|
|
62
|
-
...normalizeHeaders(fetchInit.headers),
|
|
63
|
-
Authorization: credential,
|
|
64
|
-
},
|
|
74
|
+
headers: withAuthorizationHeader(fetchInit.headers, credential),
|
|
65
75
|
})
|
|
66
76
|
}
|
|
77
|
+
|
|
78
|
+
// Record the wrapped target so future polyfill() / restore() calls can detect origin
|
|
79
|
+
// and safely unwrap only mppx-installed wrappers.
|
|
80
|
+
;(wrappedFetch as WrappedFetch)[MPPX_FETCH_WRAPPER] = baseFetch
|
|
81
|
+
return wrappedFetch as from.Fetch<methods>
|
|
67
82
|
}
|
|
68
83
|
|
|
69
84
|
/** Union of all context types from all methods that have context schemas. */
|
|
@@ -127,8 +142,14 @@ export declare namespace from {
|
|
|
127
142
|
export function polyfill<const methods extends readonly Method.AnyClient[]>(
|
|
128
143
|
config: polyfill.Config<methods>,
|
|
129
144
|
): void {
|
|
145
|
+
// Defensive guard for runtimes/tests where fetch might be non-configurable.
|
|
146
|
+
const descriptor = Object.getOwnPropertyDescriptor(globalThis, 'fetch')
|
|
147
|
+
if (!descriptor || (!descriptor.writable && !descriptor.set)) {
|
|
148
|
+
throw new Error('globalThis.fetch is not writable')
|
|
149
|
+
}
|
|
150
|
+
|
|
130
151
|
if (!originalFetch) originalFetch = globalThis.fetch
|
|
131
|
-
globalThis.fetch = from(config) as typeof globalThis.fetch
|
|
152
|
+
globalThis.fetch = from({ ...config, fetch: globalThis.fetch }) as typeof globalThis.fetch
|
|
132
153
|
}
|
|
133
154
|
|
|
134
155
|
export declare namespace polyfill {
|
|
@@ -151,7 +172,9 @@ export declare namespace polyfill {
|
|
|
151
172
|
* ```
|
|
152
173
|
*/
|
|
153
174
|
export function restore(): void {
|
|
154
|
-
if
|
|
175
|
+
// Only restore if the current fetch is still an mppx wrapper.
|
|
176
|
+
// If app code replaced fetch after polyfill(), we must not overwrite it.
|
|
177
|
+
if (originalFetch && isWrappedFetch(globalThis.fetch)) {
|
|
155
178
|
globalThis.fetch = originalFetch
|
|
156
179
|
originalFetch = undefined
|
|
157
180
|
}
|
|
@@ -171,6 +194,40 @@ function normalizeHeaders(headers: unknown): Record<string, string> {
|
|
|
171
194
|
return headers as Record<string, string>
|
|
172
195
|
}
|
|
173
196
|
|
|
197
|
+
/** @internal */
|
|
198
|
+
function withAuthorizationHeader(headers: unknown, credential: string): Record<string, string> {
|
|
199
|
+
const normalized = normalizeHeaders(headers)
|
|
200
|
+
// Remove any existing Authorization header regardless of casing to avoid
|
|
201
|
+
// duplicate/conflicting credentials on retry.
|
|
202
|
+
for (const key of Object.keys(normalized)) {
|
|
203
|
+
if (key.toLowerCase() === 'authorization') delete normalized[key]
|
|
204
|
+
}
|
|
205
|
+
normalized.Authorization = credential
|
|
206
|
+
return normalized
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/** @internal */
|
|
210
|
+
function unwrapFetch(fetch: typeof globalThis.fetch): typeof globalThis.fetch {
|
|
211
|
+
let current = fetch as WrappedFetch
|
|
212
|
+
while (current[MPPX_FETCH_WRAPPER]) {
|
|
213
|
+
current = current[MPPX_FETCH_WRAPPER] as WrappedFetch
|
|
214
|
+
}
|
|
215
|
+
return current as typeof globalThis.fetch
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/** @internal */
|
|
219
|
+
function isWrappedFetch(fetch: typeof globalThis.fetch): fetch is WrappedFetch {
|
|
220
|
+
return Boolean((fetch as WrappedFetch)[MPPX_FETCH_WRAPPER])
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/** @internal */
|
|
224
|
+
function validateCredentialHeaderValue(credential: string): void {
|
|
225
|
+
if (!credential.trim()) throw new Error('Credential header value must be non-empty')
|
|
226
|
+
if (credential.includes('\r') || credential.includes('\n')) {
|
|
227
|
+
throw new Error('Credential header value contains illegal newline characters')
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
174
231
|
/** @internal */
|
|
175
232
|
async function resolveCredential(
|
|
176
233
|
challenge: Challenge.Challenge,
|
|
@@ -1,8 +1,10 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { Hash, Hex } from 'ox'
|
|
2
2
|
|
|
3
3
|
/** Constant-time string comparison to prevent timing attacks. */
|
|
4
4
|
export function constantTimeEqual(a: string, b: string): boolean {
|
|
5
|
-
const hashA =
|
|
6
|
-
const hashB =
|
|
7
|
-
|
|
5
|
+
const hashA = Hash.sha256(Hex.fromString(a))
|
|
6
|
+
const hashB = Hash.sha256(Hex.fromString(b))
|
|
7
|
+
let result = 0
|
|
8
|
+
for (let i = 0; i < hashA.length; i++) result |= hashA.charCodeAt(i) ^ hashB.charCodeAt(i)
|
|
9
|
+
return result === 0
|
|
8
10
|
}
|
|
@@ -157,11 +157,11 @@ describe('McpClient.wrap', () => {
|
|
|
157
157
|
const challenge = Challenge.fromMethod(tempo_server.charge({ getClient: () => testClient }), {
|
|
158
158
|
realm,
|
|
159
159
|
secretKey,
|
|
160
|
+
expires: new Date(Date.now() + 60_000).toISOString(),
|
|
160
161
|
request: {
|
|
161
162
|
amount: '1',
|
|
162
163
|
currency: asset,
|
|
163
164
|
decimals: 6,
|
|
164
|
-
expires: new Date(Date.now() + 60_000).toISOString(),
|
|
165
165
|
recipient: accounts[0].address,
|
|
166
166
|
},
|
|
167
167
|
})
|
package/src/server/Mppx.ts
CHANGED
|
@@ -2,6 +2,7 @@ import type { IncomingMessage, ServerResponse } from 'node:http'
|
|
|
2
2
|
import * as Challenge from '../Challenge.js'
|
|
3
3
|
import type * as Credential from '../Credential.js'
|
|
4
4
|
import * as Errors from '../Errors.js'
|
|
5
|
+
import * as Expires from '../Expires.js'
|
|
5
6
|
import * as Env from '../internal/env.js'
|
|
6
7
|
import type * as Method from '../Method.js'
|
|
7
8
|
import type * as Receipt from '../Receipt.js'
|
|
@@ -173,7 +174,8 @@ function createMethodFn(parameters: createMethodFn.Parameters): createMethodFn.R
|
|
|
173
174
|
return Object.assign(
|
|
174
175
|
async (input: Transport.InputOf): Promise<MethodFn.Response> => {
|
|
175
176
|
const { description, meta, ...rest } = options
|
|
176
|
-
const expires =
|
|
177
|
+
const expires =
|
|
178
|
+
'expires' in options ? (options.expires as string | undefined) : Expires.minutes(5)
|
|
177
179
|
|
|
178
180
|
// Merge defaults with per-request options
|
|
179
181
|
const merged = { ...defaults, ...rest }
|
|
@@ -10,12 +10,12 @@ const secretKey = 'test-secret-key'
|
|
|
10
10
|
const challenge = Challenge.fromMethod(Methods.charge, {
|
|
11
11
|
realm,
|
|
12
12
|
secretKey,
|
|
13
|
+
expires: '2025-01-01T00:00:00.000Z',
|
|
13
14
|
request: {
|
|
14
15
|
amount: '1000',
|
|
15
16
|
currency: '0x20c0000000000000000000000000000000000001',
|
|
16
17
|
decimals: 6,
|
|
17
18
|
recipient: '0x742d35Cc6634C0532925a3b844Bc9e7595f8fE00',
|
|
18
|
-
expires: '2025-01-01T00:00:00.000Z',
|
|
19
19
|
},
|
|
20
20
|
})
|
|
21
21
|
|
|
@@ -43,14 +43,13 @@ describe('http', () => {
|
|
|
43
43
|
{
|
|
44
44
|
"challenge": {
|
|
45
45
|
"expires": "2025-01-01T00:00:00.000Z",
|
|
46
|
-
"id": "
|
|
46
|
+
"id": "QNLtjAvrKKR0VlEGSIowhULqcGlCDU4fjrP-O7js8XE",
|
|
47
47
|
"intent": "charge",
|
|
48
48
|
"method": "tempo",
|
|
49
49
|
"realm": "api.example.com",
|
|
50
50
|
"request": {
|
|
51
51
|
"amount": "1000000000",
|
|
52
52
|
"currency": "0x20c0000000000000000000000000000000000001",
|
|
53
|
-
"expires": "2025-01-01T00:00:00.000Z",
|
|
54
53
|
"recipient": "0x742d35Cc6634C0532925a3b844Bc9e7595f8fE00",
|
|
55
54
|
},
|
|
56
55
|
},
|
|
@@ -93,7 +92,7 @@ describe('http', () => {
|
|
|
93
92
|
{
|
|
94
93
|
"headers": {
|
|
95
94
|
"cache-control": "no-store",
|
|
96
|
-
"www-authenticate": "Payment id="
|
|
95
|
+
"www-authenticate": "Payment id="QNLtjAvrKKR0VlEGSIowhULqcGlCDU4fjrP-O7js8XE", realm="api.example.com", method="tempo", intent="charge", request="eyJhbW91bnQiOiIxMDAwMDAwMDAwIiwiY3VycmVuY3kiOiIweDIwYzAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDEiLCJyZWNpcGllbnQiOiIweDc0MmQzNUNjNjYzNEMwNTMyOTI1YTNiODQ0QmM5ZTc1OTVmOGZFMDAifQ", expires="2025-01-01T00:00:00.000Z"",
|
|
97
96
|
},
|
|
98
97
|
"status": 402,
|
|
99
98
|
}
|
|
@@ -183,14 +182,13 @@ describe('mcp', () => {
|
|
|
183
182
|
{
|
|
184
183
|
"challenge": {
|
|
185
184
|
"expires": "2025-01-01T00:00:00.000Z",
|
|
186
|
-
"id": "
|
|
185
|
+
"id": "QNLtjAvrKKR0VlEGSIowhULqcGlCDU4fjrP-O7js8XE",
|
|
187
186
|
"intent": "charge",
|
|
188
187
|
"method": "tempo",
|
|
189
188
|
"realm": "api.example.com",
|
|
190
189
|
"request": {
|
|
191
190
|
"amount": "1000000000",
|
|
192
191
|
"currency": "0x20c0000000000000000000000000000000000001",
|
|
193
|
-
"expires": "2025-01-01T00:00:00.000Z",
|
|
194
192
|
"recipient": "0x742d35Cc6634C0532925a3b844Bc9e7595f8fE00",
|
|
195
193
|
},
|
|
196
194
|
},
|
|
@@ -221,14 +219,13 @@ describe('mcp', () => {
|
|
|
221
219
|
"challenges": [
|
|
222
220
|
{
|
|
223
221
|
"expires": "2025-01-01T00:00:00.000Z",
|
|
224
|
-
"id": "
|
|
222
|
+
"id": "QNLtjAvrKKR0VlEGSIowhULqcGlCDU4fjrP-O7js8XE",
|
|
225
223
|
"intent": "charge",
|
|
226
224
|
"method": "tempo",
|
|
227
225
|
"realm": "api.example.com",
|
|
228
226
|
"request": {
|
|
229
227
|
"amount": "1000000000",
|
|
230
228
|
"currency": "0x20c0000000000000000000000000000000000001",
|
|
231
|
-
"expires": "2025-01-01T00:00:00.000Z",
|
|
232
229
|
"recipient": "0x742d35Cc6634C0532925a3b844Bc9e7595f8fE00",
|
|
233
230
|
},
|
|
234
231
|
},
|
|
@@ -262,7 +259,7 @@ describe('mcp', () => {
|
|
|
262
259
|
"result": {
|
|
263
260
|
"_meta": {
|
|
264
261
|
"org.paymentauth/receipt": {
|
|
265
|
-
"challengeId": "
|
|
262
|
+
"challengeId": "QNLtjAvrKKR0VlEGSIowhULqcGlCDU4fjrP-O7js8XE",
|
|
266
263
|
"method": "tempo",
|
|
267
264
|
"reference": "0xtxhash",
|
|
268
265
|
"status": "success",
|
package/src/stripe/Methods.ts
CHANGED
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import { parseUnits } from 'viem'
|
|
2
|
-
import * as Expires from '../Expires.js'
|
|
3
2
|
import * as Method from '../Method.js'
|
|
4
3
|
import * as z from '../zod.js'
|
|
5
4
|
|
|
@@ -24,7 +23,6 @@ export const charge = Method.from({
|
|
|
24
23
|
currency: z.string(),
|
|
25
24
|
decimals: z.number(),
|
|
26
25
|
description: z.optional(z.string()),
|
|
27
|
-
expires: z._default(z.datetime(), () => Expires.minutes(5)),
|
|
28
26
|
externalId: z.optional(z.string()),
|
|
29
27
|
metadata: z.optional(z.record(z.string(), z.string())),
|
|
30
28
|
networkId: z.string(),
|
|
@@ -66,8 +66,8 @@ export function charge(parameters: charge.Parameters) {
|
|
|
66
66
|
)
|
|
67
67
|
}
|
|
68
68
|
|
|
69
|
-
const expiresAt = challenge.
|
|
70
|
-
? Math.floor(new Date(challenge.
|
|
69
|
+
const expiresAt = challenge.expires
|
|
70
|
+
? Math.floor(new Date(challenge.expires).getTime() / 1000)
|
|
71
71
|
: Math.floor(Date.now() / 1000) + 3600
|
|
72
72
|
|
|
73
73
|
const spt = await createToken({
|
|
@@ -66,8 +66,8 @@ export function charge<const parameters extends charge.Parameters>(parameters: p
|
|
|
66
66
|
const { challenge } = credential
|
|
67
67
|
const { request } = challenge
|
|
68
68
|
|
|
69
|
-
if (
|
|
70
|
-
throw new PaymentExpiredError({ expires:
|
|
69
|
+
if (challenge.expires && new Date(challenge.expires) < new Date())
|
|
70
|
+
throw new PaymentExpiredError({ expires: challenge.expires })
|
|
71
71
|
|
|
72
72
|
const parsed = Methods.charge.schema.credential.payload.safeParse(credential.payload)
|
|
73
73
|
if (!parsed.success) throw new Error('Invalid credential payload: missing or malformed spt')
|
|
@@ -82,3 +82,25 @@ describe('charge', () => {
|
|
|
82
82
|
expect(result.success).toBe(false)
|
|
83
83
|
})
|
|
84
84
|
})
|
|
85
|
+
|
|
86
|
+
describe('session', () => {
|
|
87
|
+
test('has correct name and intent', () => {
|
|
88
|
+
expect(Methods.session.intent).toBe('session')
|
|
89
|
+
expect(Methods.session.name).toBe('tempo')
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
test('schema: encodes minVoucherDelta in base units', () => {
|
|
93
|
+
const request = Methods.session.schema.request.parse({
|
|
94
|
+
amount: '1',
|
|
95
|
+
currency: '0x20c0000000000000000000000000000000000001',
|
|
96
|
+
decimals: 6,
|
|
97
|
+
escrowContract: '0x1234567890abcdef1234567890abcdef12345678',
|
|
98
|
+
minVoucherDelta: '0.1',
|
|
99
|
+
recipient: '0x1234567890abcdef1234567890abcdef12345678',
|
|
100
|
+
unitType: 'token',
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
expect(request.amount).toBe('1000000')
|
|
104
|
+
expect(request.methodDetails?.minVoucherDelta).toBe('100000')
|
|
105
|
+
})
|
|
106
|
+
})
|
package/src/tempo/Methods.ts
CHANGED
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import type { Account } from 'viem'
|
|
2
2
|
import { parseUnits } from 'viem'
|
|
3
|
-
import * as Expires from '../Expires.js'
|
|
4
3
|
import * as Method from '../Method.js'
|
|
5
4
|
import * as z from '../zod.js'
|
|
6
5
|
|
|
@@ -26,7 +25,6 @@ export const charge = Method.from({
|
|
|
26
25
|
currency: z.string(),
|
|
27
26
|
decimals: z.number(),
|
|
28
27
|
description: z.optional(z.string()),
|
|
29
|
-
expires: z._default(z.datetime(), () => Expires.minutes(5)),
|
|
30
28
|
externalId: z.optional(z.string()),
|
|
31
29
|
feePayer: z.optional(
|
|
32
30
|
z.pipe(
|
|
@@ -137,7 +135,9 @@ export const session = Method.from({
|
|
|
137
135
|
methodDetails: {
|
|
138
136
|
escrowContract,
|
|
139
137
|
...(channelId !== undefined && { channelId }),
|
|
140
|
-
...(minVoucherDelta !== undefined && {
|
|
138
|
+
...(minVoucherDelta !== undefined && {
|
|
139
|
+
minVoucherDelta: parseUnits(minVoucherDelta, decimals).toString(),
|
|
140
|
+
}),
|
|
141
141
|
...(chainId !== undefined && { chainId }),
|
|
142
142
|
...(feePayer !== undefined && { feePayer }),
|
|
143
143
|
},
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type * as Hex from 'ox/Hex'
|
|
2
2
|
import type { Address } from 'viem'
|
|
3
|
-
import { prepareTransactionRequest, signTransaction } from 'viem/actions'
|
|
3
|
+
import { prepareTransactionRequest, sendCallsSync, signTransaction } from 'viem/actions'
|
|
4
4
|
import { tempo as tempo_chain } from 'viem/chains'
|
|
5
5
|
import { Actions } from 'viem/tempo'
|
|
6
6
|
import * as Credential from '../../Credential.js'
|
|
@@ -39,6 +39,7 @@ export function charge(parameters: charge.Parameters = {}) {
|
|
|
39
39
|
context: z.object({
|
|
40
40
|
account: z.optional(z.custom<Account.getResolver.Parameters['account']>()),
|
|
41
41
|
autoSwap: z.optional(z.custom<charge.AutoSwap>()),
|
|
42
|
+
mode: z.optional(z.enum(['push', 'pull'])),
|
|
42
43
|
}),
|
|
43
44
|
|
|
44
45
|
async createCredential({ challenge, context }) {
|
|
@@ -46,6 +47,9 @@ export function charge(parameters: charge.Parameters = {}) {
|
|
|
46
47
|
const client = await getClient({ chainId })
|
|
47
48
|
const account = getAccount(client, context)
|
|
48
49
|
|
|
50
|
+
const mode =
|
|
51
|
+
context?.mode ?? parameters.mode ?? (account.type === 'json-rpc' ? 'push' : 'pull')
|
|
52
|
+
|
|
49
53
|
const { request } = challenge
|
|
50
54
|
const { amount, methodDetails } = request
|
|
51
55
|
const currency = request.currency as Address
|
|
@@ -79,6 +83,21 @@ export function charge(parameters: charge.Parameters = {}) {
|
|
|
79
83
|
|
|
80
84
|
const calls = [...(swapCalls ?? []), transferCall]
|
|
81
85
|
|
|
86
|
+
if (mode === 'push') {
|
|
87
|
+
const { receipts } = await sendCallsSync(client, {
|
|
88
|
+
account,
|
|
89
|
+
calls: calls as never,
|
|
90
|
+
experimental_fallback: true,
|
|
91
|
+
})
|
|
92
|
+
const hash = receipts?.[0]?.transactionHash
|
|
93
|
+
if (!hash) throw new Error('No transaction receipt returned.')
|
|
94
|
+
return Credential.serialize({
|
|
95
|
+
challenge,
|
|
96
|
+
payload: { hash, type: 'hash' },
|
|
97
|
+
source: `did:pkh:eip155:${chainId}:${account.address}`,
|
|
98
|
+
})
|
|
99
|
+
}
|
|
100
|
+
|
|
82
101
|
const prepared = await prepareTransactionRequest(client, {
|
|
83
102
|
account,
|
|
84
103
|
calls,
|
|
@@ -111,6 +130,15 @@ export declare namespace charge {
|
|
|
111
130
|
autoSwap?: AutoSwap | undefined
|
|
112
131
|
/** Client identifier used to derive the client fingerprint in attribution memos. */
|
|
113
132
|
clientId?: string | undefined
|
|
133
|
+
/**
|
|
134
|
+
* Controls how the charge transaction is submitted.
|
|
135
|
+
*
|
|
136
|
+
* - `'push'`: Client broadcasts the transaction and sends the tx hash to the server.
|
|
137
|
+
* - `'pull'`: Client signs the transaction and sends the serialized tx to the server for broadcast.
|
|
138
|
+
*
|
|
139
|
+
* @default `'push'` for JSON-RPC accounts, `'pull'` for local accounts.
|
|
140
|
+
*/
|
|
141
|
+
mode?: 'push' | 'pull' | undefined
|
|
114
142
|
} & Account.getResolver.Parameters &
|
|
115
143
|
Client.getResolver.Parameters
|
|
116
144
|
}
|
|
@@ -30,6 +30,17 @@ const server = Mppx_server.create({
|
|
|
30
30
|
describe('tempo', () => {
|
|
31
31
|
describe('intent: charge; type: hash', () => {
|
|
32
32
|
test('default', async () => {
|
|
33
|
+
const mppx = Mppx_client.create({
|
|
34
|
+
polyfill: false,
|
|
35
|
+
methods: [
|
|
36
|
+
tempo_client({
|
|
37
|
+
account: accounts[1],
|
|
38
|
+
mode: 'push',
|
|
39
|
+
getClient: () => client,
|
|
40
|
+
}),
|
|
41
|
+
],
|
|
42
|
+
})
|
|
43
|
+
|
|
33
44
|
const httpServer = await Http.createServer(async (req, res) => {
|
|
34
45
|
const result = await Mppx_server.toNodeListener(
|
|
35
46
|
server.charge({ amount: '1', decimals: 6 }),
|
|
@@ -38,43 +49,15 @@ describe('tempo', () => {
|
|
|
38
49
|
res.end('OK')
|
|
39
50
|
})
|
|
40
51
|
|
|
41
|
-
const response = await fetch(httpServer.url)
|
|
42
|
-
expect(response.status).toBe(
|
|
43
|
-
|
|
44
|
-
const challenge = Challenge.fromResponse(response, {
|
|
45
|
-
methods: [tempo_client.charge()],
|
|
46
|
-
})
|
|
47
|
-
const request = challenge.request
|
|
48
|
-
expect(request.methodDetails?.chainId).toBe(chain.id)
|
|
49
|
-
|
|
50
|
-
const memo = Attribution.encode({ serverId: challenge.realm })
|
|
51
|
-
|
|
52
|
-
const { receipt } = await Actions.token.transferSync(client, {
|
|
53
|
-
account: accounts[1],
|
|
54
|
-
amount: BigInt(request.amount),
|
|
55
|
-
memo,
|
|
56
|
-
to: request.recipient as Hex.Hex,
|
|
57
|
-
token: request.currency as Hex.Hex,
|
|
58
|
-
})
|
|
59
|
-
const hash = receipt.transactionHash
|
|
60
|
-
|
|
61
|
-
const credential = Credential.from({
|
|
62
|
-
challenge,
|
|
63
|
-
payload: { hash, type: 'hash' as const },
|
|
64
|
-
})
|
|
65
|
-
|
|
66
|
-
{
|
|
67
|
-
const response = await fetch(httpServer.url, {
|
|
68
|
-
headers: { Authorization: Credential.serialize(credential) },
|
|
69
|
-
})
|
|
70
|
-
expect(response.status).toBe(200)
|
|
52
|
+
const response = await mppx.fetch(httpServer.url)
|
|
53
|
+
expect(response.status).toBe(200)
|
|
71
54
|
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
55
|
+
const receipt = Receipt.fromResponse(response)
|
|
56
|
+
expect({
|
|
57
|
+
...receipt,
|
|
58
|
+
reference: '[reference]',
|
|
59
|
+
timestamp: '[timestamp]',
|
|
60
|
+
}).toMatchInlineSnapshot(`
|
|
78
61
|
{
|
|
79
62
|
"method": "tempo",
|
|
80
63
|
"reference": "[reference]",
|
|
@@ -82,7 +65,6 @@ describe('tempo', () => {
|
|
|
82
65
|
"timestamp": "[timestamp]",
|
|
83
66
|
}
|
|
84
67
|
`)
|
|
85
|
-
}
|
|
86
68
|
|
|
87
69
|
httpServer.close()
|
|
88
70
|
})
|
|
@@ -92,6 +74,17 @@ describe('tempo', () => {
|
|
|
92
74
|
const overrideCurrency = asset
|
|
93
75
|
const overrideExpires = new Date(Date.now() + 60_000).toISOString()
|
|
94
76
|
|
|
77
|
+
const mppx = Mppx_client.create({
|
|
78
|
+
polyfill: false,
|
|
79
|
+
methods: [
|
|
80
|
+
tempo_client({
|
|
81
|
+
account: accounts[1],
|
|
82
|
+
mode: 'push',
|
|
83
|
+
getClient: () => client,
|
|
84
|
+
}),
|
|
85
|
+
],
|
|
86
|
+
})
|
|
87
|
+
|
|
95
88
|
const httpServer = await Http.createServer(async (req, res) => {
|
|
96
89
|
const result = await Mppx_server.toNodeListener(
|
|
97
90
|
server.charge({
|
|
@@ -105,42 +98,11 @@ describe('tempo', () => {
|
|
|
105
98
|
res.end('OK')
|
|
106
99
|
})
|
|
107
100
|
|
|
108
|
-
const response = await fetch(httpServer.url)
|
|
109
|
-
expect(response.status).toBe(
|
|
110
|
-
|
|
111
|
-
const challenge = Challenge.fromResponse(response, {
|
|
112
|
-
methods: [tempo_client.charge()],
|
|
113
|
-
})
|
|
114
|
-
const request = challenge.request
|
|
115
|
-
expect(request.recipient).toBe(overrideRecipient)
|
|
116
|
-
expect(request.currency).toBe(overrideCurrency)
|
|
117
|
-
expect(request.expires).toBe(overrideExpires)
|
|
118
|
-
|
|
119
|
-
const memo = Attribution.encode({ serverId: challenge.realm })
|
|
120
|
-
|
|
121
|
-
const { receipt } = await Actions.token.transferSync(client, {
|
|
122
|
-
account: accounts[1],
|
|
123
|
-
amount: BigInt(request.amount),
|
|
124
|
-
memo,
|
|
125
|
-
to: request.recipient as Hex.Hex,
|
|
126
|
-
token: request.currency as Hex.Hex,
|
|
127
|
-
})
|
|
128
|
-
const hash = receipt.transactionHash
|
|
129
|
-
|
|
130
|
-
const credential = Credential.from({
|
|
131
|
-
challenge,
|
|
132
|
-
payload: { hash, type: 'hash' as const },
|
|
133
|
-
})
|
|
134
|
-
|
|
135
|
-
{
|
|
136
|
-
const response = await fetch(httpServer.url, {
|
|
137
|
-
headers: { Authorization: Credential.serialize(credential) },
|
|
138
|
-
})
|
|
139
|
-
expect(response.status).toBe(200)
|
|
101
|
+
const response = await mppx.fetch(httpServer.url)
|
|
102
|
+
expect(response.status).toBe(200)
|
|
140
103
|
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
}
|
|
104
|
+
const receipt = Receipt.fromResponse(response)
|
|
105
|
+
expect(receipt.status).toBe('success')
|
|
144
106
|
|
|
145
107
|
httpServer.close()
|
|
146
108
|
})
|
|
@@ -103,7 +103,8 @@ export function charge<const parameters extends charge.Parameters>(
|
|
|
103
103
|
const client = await getClient({ chainId })
|
|
104
104
|
|
|
105
105
|
const { request: challengeRequest } = challenge
|
|
106
|
-
const { amount,
|
|
106
|
+
const { amount, methodDetails } = challengeRequest
|
|
107
|
+
const expires = challenge.expires
|
|
107
108
|
|
|
108
109
|
const currency = challengeRequest.currency as `0x${string}`
|
|
109
110
|
const recipient = challengeRequest.recipient as `0x${string}`
|