mppx 0.3.9 → 0.3.11
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/README.md +3 -3
- package/dist/Challenge.d.ts.map +1 -1
- package/dist/Challenge.js +2 -0
- package/dist/Challenge.js.map +1 -1
- package/dist/Errors.d.ts +0 -2
- package/dist/Errors.d.ts.map +1 -1
- package/dist/Errors.js +1 -3
- package/dist/Errors.js.map +1 -1
- package/dist/internal/constantTimeEqual.d.ts.map +1 -1
- package/dist/internal/constantTimeEqual.js +4 -6
- package/dist/internal/constantTimeEqual.js.map +1 -1
- package/dist/internal/env.d.ts +2 -2
- package/dist/internal/env.d.ts.map +1 -1
- package/dist/internal/env.js +1 -2
- package/dist/internal/env.js.map +1 -1
- package/dist/middlewares/internal/mppx.d.ts.map +1 -1
- package/dist/middlewares/internal/mppx.js +6 -2
- package/dist/middlewares/internal/mppx.js.map +1 -1
- package/dist/server/Mppx.d.ts +13 -3
- package/dist/server/Mppx.d.ts.map +1 -1
- package/dist/server/Mppx.js +46 -3
- package/dist/server/Mppx.js.map +1 -1
- package/dist/tempo/internal/simulate.d.ts +21 -0
- package/dist/tempo/internal/simulate.d.ts.map +1 -0
- package/dist/tempo/internal/simulate.js +31 -0
- package/dist/tempo/internal/simulate.js.map +1 -0
- package/dist/tempo/server/Charge.d.ts +12 -0
- package/dist/tempo/server/Charge.d.ts.map +1 -1
- package/dist/tempo/server/Charge.js +28 -6
- package/dist/tempo/server/Charge.js.map +1 -1
- package/dist/tempo/server/Session.d.ts +14 -0
- package/dist/tempo/server/Session.d.ts.map +1 -1
- package/dist/tempo/server/Session.js +59 -40
- package/dist/tempo/server/Session.js.map +1 -1
- package/dist/tempo/session/Chain.d.ts +3 -0
- package/dist/tempo/session/Chain.d.ts.map +1 -1
- package/dist/tempo/session/Chain.js +27 -6
- package/dist/tempo/session/Chain.js.map +1 -1
- package/package.json +1 -1
- package/src/Challenge.ts +2 -0
- package/src/Errors.test.ts +43 -18
- package/src/Errors.ts +1 -4
- package/src/client/Mppx.test.ts +1 -0
- package/src/internal/constantTimeEqual.ts +5 -4
- package/src/internal/env.test.ts +2 -2
- package/src/internal/env.ts +4 -5
- package/src/middlewares/express.test.ts +5 -0
- package/src/middlewares/hono.test.ts +5 -0
- package/src/middlewares/internal/mppx.ts +5 -2
- package/src/middlewares/nextjs.test.ts +5 -0
- package/src/proxy/Proxy.test.ts +3 -0
- package/src/proxy/services/openai.test.ts +3 -0
- package/src/server/Mppx.test.ts +93 -2
- package/src/server/Mppx.ts +81 -6
- package/src/tempo/internal/simulate.ts +49 -0
- package/src/tempo/server/Charge.test.ts +62 -0
- package/src/tempo/server/Charge.ts +44 -6
- package/src/tempo/server/Session.test.ts +49 -0
- package/src/tempo/server/Session.ts +76 -34
- package/src/tempo/session/Chain.test.ts +36 -0
- package/src/tempo/session/Chain.ts +38 -2
package/src/internal/env.test.ts
CHANGED
|
@@ -9,8 +9,8 @@ describe('Env.get', () => {
|
|
|
9
9
|
expect(Env.get('realm')).toBe('MPP Payment')
|
|
10
10
|
})
|
|
11
11
|
|
|
12
|
-
test('returns
|
|
13
|
-
expect(Env.get('secretKey')).
|
|
12
|
+
test('returns undefined when MPP_SECRET_KEY is not set', () => {
|
|
13
|
+
expect(Env.get('secretKey')).toBeUndefined()
|
|
14
14
|
})
|
|
15
15
|
|
|
16
16
|
test('returns MPP_SECRET_KEY when set', () => {
|
package/src/internal/env.ts
CHANGED
|
@@ -17,13 +17,12 @@ const variables = {
|
|
|
17
17
|
/** Fallback values when no environment variable is set. */
|
|
18
18
|
const defaults = {
|
|
19
19
|
realm: 'MPP Payment',
|
|
20
|
-
|
|
21
|
-
} as const satisfies Record<keyof typeof variables, string>
|
|
20
|
+
} as const satisfies Partial<Record<keyof typeof variables, string>>
|
|
22
21
|
|
|
23
22
|
/**
|
|
24
23
|
* Resolves a configuration value from environment variables.
|
|
25
24
|
*
|
|
26
|
-
* Checks platform-specific env vars in order, falling back to a default.
|
|
25
|
+
* Checks platform-specific env vars in order, falling back to a default if one exists.
|
|
27
26
|
*
|
|
28
27
|
* @example
|
|
29
28
|
* ```ts
|
|
@@ -31,12 +30,12 @@ const defaults = {
|
|
|
31
30
|
* Env.get('secretKey') // e.g. value of MPP_SECRET_KEY
|
|
32
31
|
* ```
|
|
33
32
|
*/
|
|
34
|
-
export function get(key: keyof typeof variables): string {
|
|
33
|
+
export function get(key: keyof typeof variables): string | undefined {
|
|
35
34
|
for (const name of variables[key]) {
|
|
36
35
|
const value = read(name)
|
|
37
36
|
if (value) return value
|
|
38
37
|
}
|
|
39
|
-
return defaults[key]
|
|
38
|
+
return (defaults as Record<string, string | undefined>)[key]
|
|
40
39
|
}
|
|
41
40
|
|
|
42
41
|
/** Reads a single environment variable, probing available runtime APIs. */
|
|
@@ -21,6 +21,8 @@ function createServer(app: express.Express) {
|
|
|
21
21
|
})
|
|
22
22
|
}
|
|
23
23
|
|
|
24
|
+
const secretKey = 'test-secret-key'
|
|
25
|
+
|
|
24
26
|
describe('charge', () => {
|
|
25
27
|
const mppx = Mppx.create({
|
|
26
28
|
methods: [
|
|
@@ -30,6 +32,7 @@ describe('charge', () => {
|
|
|
30
32
|
recipient: accounts[0].address,
|
|
31
33
|
}),
|
|
32
34
|
],
|
|
35
|
+
secretKey,
|
|
33
36
|
})
|
|
34
37
|
|
|
35
38
|
const { fetch } = Mppx_client.create({
|
|
@@ -99,6 +102,7 @@ describe('session', () => {
|
|
|
99
102
|
escrowContract,
|
|
100
103
|
}),
|
|
101
104
|
],
|
|
105
|
+
secretKey,
|
|
102
106
|
})
|
|
103
107
|
|
|
104
108
|
const app = express()
|
|
@@ -125,6 +129,7 @@ describe('session', () => {
|
|
|
125
129
|
feePayer: accounts[0],
|
|
126
130
|
}),
|
|
127
131
|
],
|
|
132
|
+
secretKey,
|
|
128
133
|
})
|
|
129
134
|
|
|
130
135
|
const { fetch } = Mppx_client.create({
|
|
@@ -21,6 +21,8 @@ function createServer(app: Hono) {
|
|
|
21
21
|
})
|
|
22
22
|
}
|
|
23
23
|
|
|
24
|
+
const secretKey = 'test-secret-key'
|
|
25
|
+
|
|
24
26
|
describe('charge', () => {
|
|
25
27
|
const mppx = Mppx.create({
|
|
26
28
|
methods: [
|
|
@@ -30,6 +32,7 @@ describe('charge', () => {
|
|
|
30
32
|
recipient: accounts[0].address,
|
|
31
33
|
}),
|
|
32
34
|
],
|
|
35
|
+
secretKey,
|
|
33
36
|
})
|
|
34
37
|
|
|
35
38
|
const { fetch } = Mppx_client.create({
|
|
@@ -92,6 +95,7 @@ describe('session', () => {
|
|
|
92
95
|
escrowContract,
|
|
93
96
|
}),
|
|
94
97
|
],
|
|
98
|
+
secretKey,
|
|
95
99
|
})
|
|
96
100
|
|
|
97
101
|
const app = new Hono()
|
|
@@ -118,6 +122,7 @@ describe('session', () => {
|
|
|
118
122
|
feePayer: accounts[0],
|
|
119
123
|
}),
|
|
120
124
|
],
|
|
125
|
+
secretKey,
|
|
121
126
|
})
|
|
122
127
|
|
|
123
128
|
const { fetch } = Mppx_client.create({
|
|
@@ -23,8 +23,11 @@ export function wrap<mppx extends Mppx.Mppx<any, any>, handler>(
|
|
|
23
23
|
): Wrap<mppx, handler> {
|
|
24
24
|
const result: Record<string, unknown> = { ...mppx }
|
|
25
25
|
for (const mi of mppx.methods as readonly Method.AnyServer[]) {
|
|
26
|
-
const
|
|
27
|
-
|
|
26
|
+
const key = `${mi.name}/${mi.intent}`
|
|
27
|
+
const methodFn = (mppx as any)[key]
|
|
28
|
+
result[key] = (options: any) => wrapper(methodFn, options)
|
|
29
|
+
// Also set shorthand intent key if Mppx registered it (no collision)
|
|
30
|
+
if ((mppx as any)[mi.intent]) result[mi.intent] = (options: any) => wrapper(methodFn, options)
|
|
28
31
|
}
|
|
29
32
|
return result as never
|
|
30
33
|
}
|
|
@@ -33,6 +33,8 @@ function createServer(handler: (request: Request) => Promise<Response> | Respons
|
|
|
33
33
|
})
|
|
34
34
|
}
|
|
35
35
|
|
|
36
|
+
const secretKey = 'test-secret-key'
|
|
37
|
+
|
|
36
38
|
describe('charge', () => {
|
|
37
39
|
const mppx = Mppx.create({
|
|
38
40
|
methods: [
|
|
@@ -42,6 +44,7 @@ describe('charge', () => {
|
|
|
42
44
|
recipient: accounts[0].address,
|
|
43
45
|
}),
|
|
44
46
|
],
|
|
47
|
+
secretKey,
|
|
45
48
|
})
|
|
46
49
|
|
|
47
50
|
const { fetch } = Mppx_client.create({
|
|
@@ -106,6 +109,7 @@ describe('session', () => {
|
|
|
106
109
|
escrowContract,
|
|
107
110
|
}),
|
|
108
111
|
],
|
|
112
|
+
secretKey,
|
|
109
113
|
})
|
|
110
114
|
|
|
111
115
|
const handler = mppx.session({ amount: '1', unitType: 'token' })(() =>
|
|
@@ -131,6 +135,7 @@ describe('session', () => {
|
|
|
131
135
|
feePayer: accounts[0],
|
|
132
136
|
}),
|
|
133
137
|
],
|
|
138
|
+
secretKey,
|
|
134
139
|
})
|
|
135
140
|
|
|
136
141
|
const { fetch } = Mppx_client.create({
|
package/src/proxy/Proxy.test.ts
CHANGED
|
@@ -9,6 +9,8 @@ import * as Service from './Service.js'
|
|
|
9
9
|
import { anthropic } from './services/anthropic.js'
|
|
10
10
|
import { openai } from './services/openai.js'
|
|
11
11
|
|
|
12
|
+
const secretKey = 'test-secret-key'
|
|
13
|
+
|
|
12
14
|
const mppx_server = Mppx_server.create({
|
|
13
15
|
methods: [
|
|
14
16
|
tempo_server({
|
|
@@ -18,6 +20,7 @@ const mppx_server = Mppx_server.create({
|
|
|
18
20
|
feePayer: true,
|
|
19
21
|
}),
|
|
20
22
|
],
|
|
23
|
+
secretKey,
|
|
21
24
|
})
|
|
22
25
|
|
|
23
26
|
const mppx_client = Mppx_client.create({
|
|
@@ -10,6 +10,8 @@ import { openai } from './openai.js'
|
|
|
10
10
|
const apiKey = process.env.VITE_OPENAI_API_KEY
|
|
11
11
|
if (!apiKey) console.warn('OPENAI_API_KEY not set — openai proxy tests will be skipped')
|
|
12
12
|
|
|
13
|
+
const secretKey = 'test-secret-key'
|
|
14
|
+
|
|
13
15
|
const mppx_server = Mppx_server.create({
|
|
14
16
|
methods: [
|
|
15
17
|
tempo_server({
|
|
@@ -18,6 +20,7 @@ const mppx_server = Mppx_server.create({
|
|
|
18
20
|
getClient: () => client,
|
|
19
21
|
}),
|
|
20
22
|
],
|
|
23
|
+
secretKey,
|
|
21
24
|
})
|
|
22
25
|
|
|
23
26
|
const mppx_client = Mppx_client.create({
|
package/src/server/Mppx.test.ts
CHANGED
|
@@ -53,7 +53,7 @@ describe('request handler', () => {
|
|
|
53
53
|
}).toMatchInlineSnapshot(`
|
|
54
54
|
{
|
|
55
55
|
"challengeId": "[challengeId]",
|
|
56
|
-
"detail": "Payment is required
|
|
56
|
+
"detail": "Payment is required.",
|
|
57
57
|
"instance": "[instance]",
|
|
58
58
|
"status": 402,
|
|
59
59
|
"title": "Payment Required",
|
|
@@ -138,6 +138,97 @@ describe('request handler', () => {
|
|
|
138
138
|
`)
|
|
139
139
|
})
|
|
140
140
|
|
|
141
|
+
test('returns 402 when credential is from a different route (cross-route scope confusion)', async () => {
|
|
142
|
+
const handler = Mppx.create({ methods: [method], realm, secretKey })
|
|
143
|
+
|
|
144
|
+
// Get a challenge from the "cheap" route
|
|
145
|
+
const cheapHandle = handler.charge({
|
|
146
|
+
amount: '1',
|
|
147
|
+
currency: asset,
|
|
148
|
+
expires: new Date(Date.now() + 60_000).toISOString(),
|
|
149
|
+
recipient: accounts[0].address,
|
|
150
|
+
})
|
|
151
|
+
const cheapResult = await cheapHandle(new Request('https://example.com/cheap'))
|
|
152
|
+
expect(cheapResult.status).toBe(402)
|
|
153
|
+
if (cheapResult.status !== 402) throw new Error()
|
|
154
|
+
|
|
155
|
+
const cheapChallenge = Challenge.fromResponse(cheapResult.challenge)
|
|
156
|
+
|
|
157
|
+
// Build a credential from the cheap challenge
|
|
158
|
+
const credential = Credential.from({
|
|
159
|
+
challenge: cheapChallenge,
|
|
160
|
+
payload: { signature: '0x123', type: 'transaction' },
|
|
161
|
+
})
|
|
162
|
+
|
|
163
|
+
// Present it at the "expensive" route
|
|
164
|
+
const expensiveHandle = handler.charge({
|
|
165
|
+
amount: '1000000',
|
|
166
|
+
currency: asset,
|
|
167
|
+
expires: new Date(Date.now() + 60_000).toISOString(),
|
|
168
|
+
recipient: accounts[0].address,
|
|
169
|
+
})
|
|
170
|
+
const result = await expensiveHandle(
|
|
171
|
+
new Request('https://example.com/expensive', {
|
|
172
|
+
headers: { Authorization: Credential.serialize(credential) },
|
|
173
|
+
}),
|
|
174
|
+
)
|
|
175
|
+
|
|
176
|
+
expect(result.status).toBe(402)
|
|
177
|
+
if (result.status !== 402) throw new Error()
|
|
178
|
+
|
|
179
|
+
const body = (await result.challenge.json()) as { detail: string }
|
|
180
|
+
expect(body.detail).toContain('does not match')
|
|
181
|
+
})
|
|
182
|
+
|
|
183
|
+
test('returns 402 when credential challenge is expired', async () => {
|
|
184
|
+
const pastExpires = new Date(Date.now() - 60_000).toISOString()
|
|
185
|
+
|
|
186
|
+
const handle = Mppx.create({ methods: [method], realm, secretKey }).charge({
|
|
187
|
+
amount: '1000',
|
|
188
|
+
currency: asset,
|
|
189
|
+
expires: pastExpires,
|
|
190
|
+
recipient: accounts[0].address,
|
|
191
|
+
})
|
|
192
|
+
|
|
193
|
+
// Get a fresh challenge (which has the expired timestamp baked in)
|
|
194
|
+
const firstResult = await handle(new Request('https://example.com/resource'))
|
|
195
|
+
expect(firstResult.status).toBe(402)
|
|
196
|
+
if (firstResult.status !== 402) throw new Error()
|
|
197
|
+
|
|
198
|
+
const challenge = Challenge.fromResponse(firstResult.challenge)
|
|
199
|
+
|
|
200
|
+
const credential = Credential.from({
|
|
201
|
+
challenge,
|
|
202
|
+
payload: { signature: '0x123', type: 'transaction' },
|
|
203
|
+
})
|
|
204
|
+
|
|
205
|
+
const result = await handle(
|
|
206
|
+
new Request('https://example.com/resource', {
|
|
207
|
+
headers: { Authorization: Credential.serialize(credential) },
|
|
208
|
+
}),
|
|
209
|
+
)
|
|
210
|
+
|
|
211
|
+
expect(result.status).toBe(402)
|
|
212
|
+
if (result.status !== 402) throw new Error()
|
|
213
|
+
|
|
214
|
+
const body = (await result.challenge.json()) as object
|
|
215
|
+
expect({
|
|
216
|
+
...body,
|
|
217
|
+
challengeId: '[challengeId]',
|
|
218
|
+
detail: '[detail]',
|
|
219
|
+
instance: '[instance]',
|
|
220
|
+
}).toMatchInlineSnapshot(`
|
|
221
|
+
{
|
|
222
|
+
"challengeId": "[challengeId]",
|
|
223
|
+
"detail": "[detail]",
|
|
224
|
+
"instance": "[instance]",
|
|
225
|
+
"status": 402,
|
|
226
|
+
"title": "Payment Expired",
|
|
227
|
+
"type": "https://paymentauth.org/problems/payment-expired",
|
|
228
|
+
}
|
|
229
|
+
`)
|
|
230
|
+
expect((body as { detail: string }).detail).toContain('Payment expired at')
|
|
231
|
+
})
|
|
141
232
|
test('returns 402 when payload schema validation fails', async () => {
|
|
142
233
|
const handle = Mppx.create({ methods: [method], realm, secretKey }).charge({
|
|
143
234
|
amount: '1000',
|
|
@@ -215,7 +306,7 @@ describe('request handler (node)', () => {
|
|
|
215
306
|
}).toMatchInlineSnapshot(`
|
|
216
307
|
{
|
|
217
308
|
"challengeId": "[challengeId]",
|
|
218
|
-
"detail": "Payment is required
|
|
309
|
+
"detail": "Payment is required.",
|
|
219
310
|
"instance": "[instance]",
|
|
220
311
|
"status": 402,
|
|
221
312
|
"title": "Payment Required",
|
package/src/server/Mppx.ts
CHANGED
|
@@ -41,17 +41,43 @@ type EffectiveTransportOf<mi, defaultTransport extends Transport.AnyTransport> =
|
|
|
41
41
|
? defaultTransport
|
|
42
42
|
: TransportOverrideOf<mi>
|
|
43
43
|
|
|
44
|
-
|
|
44
|
+
/** True when exactly one method has the given intent (no name collision). */
|
|
45
|
+
type IsUniqueIntent<methods extends readonly Method.AnyServer[], intent extends string> = Extract<
|
|
46
|
+
methods[number],
|
|
47
|
+
{ intent: intent }
|
|
48
|
+
> extends infer M
|
|
49
|
+
? M extends M
|
|
50
|
+
? [Exclude<Extract<methods[number], { intent: intent }>, M>] extends [never]
|
|
51
|
+
? true
|
|
52
|
+
: false
|
|
53
|
+
: never
|
|
54
|
+
: never
|
|
55
|
+
|
|
56
|
+
/** Only includes shorthand intent keys when the intent is unique across methods. */
|
|
57
|
+
type UniqueIntentHandlers<
|
|
45
58
|
methods extends readonly Method.AnyServer[],
|
|
46
59
|
transport extends Transport.AnyTransport,
|
|
47
60
|
> = {
|
|
48
|
-
[method_name in methods[number]['intent']
|
|
61
|
+
[method_name in methods[number]['intent'] as IsUniqueIntent<methods, method_name> extends true
|
|
62
|
+
? method_name
|
|
63
|
+
: never]: MethodFn<
|
|
49
64
|
Extract<methods[number], { intent: method_name }>,
|
|
50
65
|
EffectiveTransportOf<Extract<methods[number], { intent: method_name }>, transport>,
|
|
51
66
|
NonNullable<Extract<methods[number], { intent: method_name }>['defaults']>
|
|
52
67
|
>
|
|
53
68
|
}
|
|
54
69
|
|
|
70
|
+
type Handlers<
|
|
71
|
+
methods extends readonly Method.AnyServer[],
|
|
72
|
+
transport extends Transport.AnyTransport,
|
|
73
|
+
> = {
|
|
74
|
+
[mi in methods[number] as `${mi['name']}/${mi['intent']}`]: MethodFn<
|
|
75
|
+
mi,
|
|
76
|
+
EffectiveTransportOf<mi, transport>,
|
|
77
|
+
NonNullable<mi['defaults']>
|
|
78
|
+
>
|
|
79
|
+
} & UniqueIntentHandlers<methods, transport>
|
|
80
|
+
|
|
55
81
|
/**
|
|
56
82
|
* Creates a server-side payment handler from methods.
|
|
57
83
|
*
|
|
@@ -73,17 +99,25 @@ export function create<
|
|
|
73
99
|
const transport extends Transport.AnyTransport = Transport.Http,
|
|
74
100
|
>(config: create.Config<methods, transport>): Mppx<methods, transport> {
|
|
75
101
|
const {
|
|
76
|
-
realm = Env.get('realm'),
|
|
102
|
+
realm = Env.get('realm') ?? 'MPP Payment',
|
|
77
103
|
secretKey = Env.get('secretKey'),
|
|
78
104
|
transport = Transport.http() as transport,
|
|
79
105
|
} = config
|
|
80
106
|
|
|
107
|
+
if (!secretKey) {
|
|
108
|
+
throw new Error(
|
|
109
|
+
'Missing secret key. Set the MPP_SECRET_KEY environment variable or pass `secretKey` to Mppx.create().',
|
|
110
|
+
)
|
|
111
|
+
}
|
|
112
|
+
|
|
81
113
|
const methods = config.methods.flat() as unknown as FlattenMethods<methods>
|
|
82
114
|
|
|
83
115
|
const handlers: Record<string, unknown> = {}
|
|
116
|
+
const intentCount: Record<string, number> = {}
|
|
84
117
|
|
|
85
118
|
for (const mi of methods) {
|
|
86
|
-
|
|
119
|
+
intentCount[mi.intent] = (intentCount[mi.intent] ?? 0) + 1
|
|
120
|
+
handlers[`${mi.name}/${mi.intent}`] = createMethodFn({
|
|
87
121
|
defaults: mi.defaults,
|
|
88
122
|
method: mi,
|
|
89
123
|
realm,
|
|
@@ -95,6 +129,11 @@ export function create<
|
|
|
95
129
|
})
|
|
96
130
|
}
|
|
97
131
|
|
|
132
|
+
// Also set shorthand intent key when there's no collision
|
|
133
|
+
for (const mi of methods) {
|
|
134
|
+
if (intentCount[mi.intent] === 1) handlers[mi.intent] = handlers[`${mi.name}/${mi.intent}`]
|
|
135
|
+
}
|
|
136
|
+
|
|
98
137
|
return { methods, realm: realm as string, transport, ...handlers } as never
|
|
99
138
|
}
|
|
100
139
|
|
|
@@ -107,7 +146,7 @@ export declare namespace create {
|
|
|
107
146
|
methods: methods
|
|
108
147
|
/** Server realm (e.g., hostname). Auto-detected from environment variables (`MPP_REALM`, `VERCEL_URL`, `RAILWAY_PUBLIC_DOMAIN`, `RENDER_EXTERNAL_HOSTNAME`, `HOST`, `HOSTNAME`), falling back to `"localhost"`. */
|
|
109
148
|
realm?: string | undefined
|
|
110
|
-
/** Secret key for HMAC-bound challenge IDs for stateless verification. Auto-detected from `MPP_SECRET_KEY` environment variable
|
|
149
|
+
/** Secret key for HMAC-bound challenge IDs for stateless verification. Auto-detected from `MPP_SECRET_KEY` environment variable. Throws if neither provided nor set. */
|
|
111
150
|
secretKey?: string | undefined
|
|
112
151
|
/** Transport to use. @default Transport.http() */
|
|
113
152
|
transport?: transport | undefined
|
|
@@ -185,7 +224,7 @@ function createMethodFn(parameters: createMethodFn.Parameters): createMethodFn.R
|
|
|
185
224
|
const response = await transport.respondChallenge({
|
|
186
225
|
challenge,
|
|
187
226
|
input,
|
|
188
|
-
error: new Errors.PaymentRequiredError({
|
|
227
|
+
error: new Errors.PaymentRequiredError({ description }),
|
|
189
228
|
})
|
|
190
229
|
return { challenge: response, status: 402 }
|
|
191
230
|
}
|
|
@@ -204,6 +243,42 @@ function createMethodFn(parameters: createMethodFn.Parameters): createMethodFn.R
|
|
|
204
243
|
return { challenge: response, status: 402 }
|
|
205
244
|
}
|
|
206
245
|
|
|
246
|
+
// Verify the credential's challenge matches this route's configured
|
|
247
|
+
// request. Prevents cross-route scope confusion where a credential
|
|
248
|
+
// issued for a cheap route is presented at an expensive route.
|
|
249
|
+
{
|
|
250
|
+
const routeReq = challenge.request as Record<string, unknown>
|
|
251
|
+
const echoedReq = credential.challenge.request as Record<string, unknown>
|
|
252
|
+
for (const field of ['amount', 'currency', 'recipient'] as const) {
|
|
253
|
+
if (
|
|
254
|
+
routeReq[field] !== undefined &&
|
|
255
|
+
echoedReq[field] !== undefined &&
|
|
256
|
+
String(routeReq[field]) !== String(echoedReq[field])
|
|
257
|
+
) {
|
|
258
|
+
const response = await transport.respondChallenge({
|
|
259
|
+
challenge,
|
|
260
|
+
input,
|
|
261
|
+
error: new Errors.InvalidChallengeError({
|
|
262
|
+
id: credential.challenge.id,
|
|
263
|
+
reason: `credential ${field} does not match this route's requirements`,
|
|
264
|
+
}),
|
|
265
|
+
})
|
|
266
|
+
return { challenge: response, status: 402 }
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// Reject expired credentials
|
|
272
|
+
if (credential.challenge.expires && new Date(credential.challenge.expires) < new Date()) {
|
|
273
|
+
const response = await transport.respondChallenge({
|
|
274
|
+
challenge,
|
|
275
|
+
input,
|
|
276
|
+
error: new Errors.PaymentExpiredError({
|
|
277
|
+
expires: credential.challenge.expires,
|
|
278
|
+
}),
|
|
279
|
+
})
|
|
280
|
+
return { challenge: response, status: 402 }
|
|
281
|
+
}
|
|
207
282
|
// Validate payload structure against method schema
|
|
208
283
|
try {
|
|
209
284
|
method.schema.credential.payload.parse(credential.payload)
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import type { Address, Client } from 'viem'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Simulate a Tempo transaction via `eth_estimateGas` to catch reverts
|
|
5
|
+
* (e.g. insufficient balance, invalid calls) before broadcasting.
|
|
6
|
+
*/
|
|
7
|
+
export async function simulateTransaction(
|
|
8
|
+
client: Client,
|
|
9
|
+
transaction: {
|
|
10
|
+
from: Address
|
|
11
|
+
chainId: number
|
|
12
|
+
nonce?: number | bigint | undefined
|
|
13
|
+
maxFeePerGas?: bigint | undefined
|
|
14
|
+
maxPriorityFeePerGas?: bigint | undefined
|
|
15
|
+
feeToken?: string | bigint | undefined
|
|
16
|
+
nonceKey?: bigint | undefined
|
|
17
|
+
validBefore?: number | undefined
|
|
18
|
+
calls?: readonly {
|
|
19
|
+
to?: string | undefined
|
|
20
|
+
value?: bigint | undefined
|
|
21
|
+
data?: string | undefined
|
|
22
|
+
}[]
|
|
23
|
+
},
|
|
24
|
+
): Promise<void> {
|
|
25
|
+
const simCalls = (transaction.calls ?? []).map((c) => ({
|
|
26
|
+
to: c.to,
|
|
27
|
+
value: c.value ? `0x${c.value.toString(16)}` : '0x0',
|
|
28
|
+
input: c.data ?? '0x',
|
|
29
|
+
}))
|
|
30
|
+
await client.request({
|
|
31
|
+
method: 'eth_estimateGas' as never,
|
|
32
|
+
params: [
|
|
33
|
+
{
|
|
34
|
+
from: transaction.from,
|
|
35
|
+
chainId: `0x${transaction.chainId.toString(16)}`,
|
|
36
|
+
nonce: `0x${BigInt(transaction.nonce ?? 0).toString(16)}`,
|
|
37
|
+
gas: '0x2dc6c0', // 3M cap
|
|
38
|
+
maxFeePerGas: `0x${(transaction.maxFeePerGas ?? 0n).toString(16)}`,
|
|
39
|
+
maxPriorityFeePerGas: `0x${(transaction.maxPriorityFeePerGas ?? 0n).toString(16)}`,
|
|
40
|
+
feeToken: transaction.feeToken,
|
|
41
|
+
nonceKey: `0x${(transaction.nonceKey ?? 0n).toString(16)}`,
|
|
42
|
+
calls: simCalls,
|
|
43
|
+
...(transaction.validBefore
|
|
44
|
+
? { validBefore: `0x${transaction.validBefore.toString(16)}` }
|
|
45
|
+
: {}),
|
|
46
|
+
},
|
|
47
|
+
] as never,
|
|
48
|
+
})
|
|
49
|
+
}
|
|
@@ -524,6 +524,68 @@ describe('tempo', () => {
|
|
|
524
524
|
})
|
|
525
525
|
})
|
|
526
526
|
|
|
527
|
+
describe('intent: charge; type: transaction; waitForConfirmation: false', () => {
|
|
528
|
+
test('returns receipt without waiting for confirmation', async () => {
|
|
529
|
+
const serverNoWait = Mppx_server.create({
|
|
530
|
+
methods: [
|
|
531
|
+
tempo_server.charge({
|
|
532
|
+
getClient() {
|
|
533
|
+
return client
|
|
534
|
+
},
|
|
535
|
+
currency: asset,
|
|
536
|
+
account: accounts[0],
|
|
537
|
+
waitForConfirmation: false,
|
|
538
|
+
}),
|
|
539
|
+
],
|
|
540
|
+
realm,
|
|
541
|
+
secretKey,
|
|
542
|
+
})
|
|
543
|
+
|
|
544
|
+
const mppx = Mppx_client.create({
|
|
545
|
+
polyfill: false,
|
|
546
|
+
methods: [
|
|
547
|
+
tempo_client({
|
|
548
|
+
account: accounts[1],
|
|
549
|
+
getClient() {
|
|
550
|
+
return client
|
|
551
|
+
},
|
|
552
|
+
}),
|
|
553
|
+
],
|
|
554
|
+
})
|
|
555
|
+
|
|
556
|
+
const httpServer = await Http.createServer(async (req, res) => {
|
|
557
|
+
const result = await Mppx_server.toNodeListener(
|
|
558
|
+
serverNoWait.charge({
|
|
559
|
+
amount: '1',
|
|
560
|
+
currency: asset,
|
|
561
|
+
recipient: accounts[0].address,
|
|
562
|
+
}),
|
|
563
|
+
)(req, res)
|
|
564
|
+
if (result.status === 402) return
|
|
565
|
+
res.end('OK')
|
|
566
|
+
})
|
|
567
|
+
|
|
568
|
+
const response = await fetch(httpServer.url)
|
|
569
|
+
expect(response.status).toBe(402)
|
|
570
|
+
|
|
571
|
+
const credential = await mppx.createCredential(response)
|
|
572
|
+
|
|
573
|
+
{
|
|
574
|
+
const response = await fetch(httpServer.url, {
|
|
575
|
+
headers: { Authorization: credential },
|
|
576
|
+
})
|
|
577
|
+
expect(response.status).toBe(200)
|
|
578
|
+
|
|
579
|
+
const receipt = Receipt.fromResponse(response)
|
|
580
|
+
expect(receipt.status).toBe('success')
|
|
581
|
+
expect(receipt.method).toBe('tempo')
|
|
582
|
+
expect(receipt.reference).toBeDefined()
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
httpServer.close()
|
|
586
|
+
})
|
|
587
|
+
})
|
|
588
|
+
|
|
527
589
|
describe('intent: unknown', () => {
|
|
528
590
|
test('behavior: returns 402 for invalid payload schema', async () => {
|
|
529
591
|
const httpServer = await Http.createServer(async (req, res) => {
|
|
@@ -5,7 +5,12 @@ import {
|
|
|
5
5
|
type TransactionReceipt,
|
|
6
6
|
toFunctionSelector,
|
|
7
7
|
} from 'viem'
|
|
8
|
-
import {
|
|
8
|
+
import {
|
|
9
|
+
getTransactionReceipt,
|
|
10
|
+
sendRawTransaction,
|
|
11
|
+
sendRawTransactionSync,
|
|
12
|
+
signTransaction,
|
|
13
|
+
} from 'viem/actions'
|
|
9
14
|
import { tempo as tempo_chain } from 'viem/chains'
|
|
10
15
|
import { Abis, Transaction } from 'viem/tempo'
|
|
11
16
|
import { PaymentExpiredError } from '../../Errors.js'
|
|
@@ -14,6 +19,7 @@ import * as Method from '../../Method.js'
|
|
|
14
19
|
import * as Client from '../../viem/Client.js'
|
|
15
20
|
import * as Account from '../internal/account.js'
|
|
16
21
|
import * as defaults from '../internal/defaults.js'
|
|
22
|
+
import { simulateTransaction } from '../internal/simulate.js'
|
|
17
23
|
import type * as types from '../internal/types.js'
|
|
18
24
|
import * as Methods from '../Methods.js'
|
|
19
25
|
|
|
@@ -45,6 +51,7 @@ export function charge<const parameters extends charge.Parameters>(
|
|
|
45
51
|
description,
|
|
46
52
|
externalId,
|
|
47
53
|
memo,
|
|
54
|
+
waitForConfirmation = true,
|
|
48
55
|
} = parameters
|
|
49
56
|
|
|
50
57
|
const { recipient, feePayer } = Account.resolve(parameters)
|
|
@@ -250,11 +257,30 @@ export function charge<const parameters extends charge.Parameters>(
|
|
|
250
257
|
return serializedTransaction
|
|
251
258
|
})()
|
|
252
259
|
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
260
|
+
if (waitForConfirmation) {
|
|
261
|
+
const receipt = await sendRawTransactionSync(client, {
|
|
262
|
+
serializedTransaction: serializedTransaction_final,
|
|
263
|
+
})
|
|
264
|
+
return toReceipt(receipt)
|
|
265
|
+
} else {
|
|
266
|
+
// Optimistic path: simulate to catch obvious reverts, then broadcast
|
|
267
|
+
// without waiting for on-chain confirmation. The returned receipt
|
|
268
|
+
// assumes success — callers opt into this risk via waitForConfirmation: false.
|
|
269
|
+
await simulateTransaction(client, {
|
|
270
|
+
...transaction,
|
|
271
|
+
from: transaction.from as `0x${string}`,
|
|
272
|
+
calls,
|
|
273
|
+
})
|
|
274
|
+
const hash = await sendRawTransaction(client, {
|
|
275
|
+
serializedTransaction: serializedTransaction_final,
|
|
276
|
+
})
|
|
277
|
+
return {
|
|
278
|
+
method: 'tempo',
|
|
279
|
+
status: 'success',
|
|
280
|
+
timestamp: new Date().toISOString(),
|
|
281
|
+
reference: hash,
|
|
282
|
+
} as const
|
|
283
|
+
}
|
|
258
284
|
}
|
|
259
285
|
|
|
260
286
|
default:
|
|
@@ -270,6 +296,18 @@ export declare namespace charge {
|
|
|
270
296
|
type Parameters = {
|
|
271
297
|
/** Testnet mode. */
|
|
272
298
|
testnet?: boolean | undefined
|
|
299
|
+
/**
|
|
300
|
+
* Whether to wait for the charge transaction to confirm on-chain before
|
|
301
|
+
* responding. @default true
|
|
302
|
+
*
|
|
303
|
+
* When `false`, the transaction is simulated via `eth_estimateGas` and
|
|
304
|
+
* broadcast without waiting for inclusion. The receipt will optimistically
|
|
305
|
+
* report `status: 'success'` based on simulation alone — if the
|
|
306
|
+
* transaction reverts on-chain after broadcast (e.g. due to a state
|
|
307
|
+
* change between simulation and inclusion), the receipt will not reflect
|
|
308
|
+
* the failure.
|
|
309
|
+
*/
|
|
310
|
+
waitForConfirmation?: boolean | undefined
|
|
273
311
|
} & Client.getResolver.Parameters &
|
|
274
312
|
Account.resolve.Parameters &
|
|
275
313
|
Defaults
|