mppx 0.5.16 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (67) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/dist/cli/cli.d.ts.map +1 -1
  3. package/dist/cli/cli.js +30 -1
  4. package/dist/cli/cli.js.map +1 -1
  5. package/dist/client/Mppx.d.ts +2 -0
  6. package/dist/client/Mppx.d.ts.map +1 -1
  7. package/dist/client/Mppx.js +4 -1
  8. package/dist/client/Mppx.js.map +1 -1
  9. package/dist/client/index.d.ts +1 -0
  10. package/dist/client/index.d.ts.map +1 -1
  11. package/dist/client/index.js +1 -0
  12. package/dist/client/index.js.map +1 -1
  13. package/dist/client/internal/Fetch.d.ts +4 -0
  14. package/dist/client/internal/Fetch.d.ts.map +1 -1
  15. package/dist/client/internal/Fetch.js +43 -5
  16. package/dist/client/internal/Fetch.js.map +1 -1
  17. package/dist/server/Mppx.d.ts +38 -1
  18. package/dist/server/Mppx.d.ts.map +1 -1
  19. package/dist/server/Mppx.js +70 -1
  20. package/dist/server/Mppx.js.map +1 -1
  21. package/dist/stripe/server/Charge.d.ts.map +1 -1
  22. package/dist/stripe/server/Charge.js +8 -1
  23. package/dist/stripe/server/Charge.js.map +1 -1
  24. package/dist/stripe/server/internal/html.gen.d.ts +1 -1
  25. package/dist/stripe/server/internal/html.gen.js +1 -1
  26. package/dist/tempo/server/Charge.d.ts.map +1 -1
  27. package/dist/tempo/server/Charge.js +15 -4
  28. package/dist/tempo/server/Charge.js.map +1 -1
  29. package/dist/tempo/server/Session.d.ts +39 -38
  30. package/dist/tempo/server/Session.d.ts.map +1 -1
  31. package/dist/tempo/server/Session.js +14 -24
  32. package/dist/tempo/server/Session.js.map +1 -1
  33. package/dist/tempo/server/internal/html.gen.d.ts +1 -1
  34. package/dist/tempo/server/internal/html.gen.d.ts.map +1 -1
  35. package/dist/tempo/server/internal/html.gen.js +1 -1
  36. package/dist/tempo/server/internal/html.gen.js.map +1 -1
  37. package/dist/tempo/server/internal/request-body.d.ts +8 -0
  38. package/dist/tempo/server/internal/request-body.d.ts.map +1 -0
  39. package/dist/tempo/server/internal/request-body.js +27 -0
  40. package/dist/tempo/server/internal/request-body.js.map +1 -0
  41. package/dist/tempo/server/internal/transport.d.ts.map +1 -1
  42. package/dist/tempo/server/internal/transport.js +4 -14
  43. package/dist/tempo/server/internal/transport.js.map +1 -1
  44. package/package.json +3 -3
  45. package/src/cli/cli.test.ts +36 -7
  46. package/src/cli/cli.ts +33 -1
  47. package/src/client/Mppx.ts +11 -2
  48. package/src/client/index.ts +1 -0
  49. package/src/client/internal/Fetch.browser.test.ts +58 -0
  50. package/src/client/internal/Fetch.test.ts +173 -0
  51. package/src/client/internal/Fetch.ts +62 -3
  52. package/src/server/Mppx.test-d.ts +36 -0
  53. package/src/server/Mppx.test.ts +926 -1
  54. package/src/server/Mppx.ts +141 -2
  55. package/src/server/Transport.test.ts +2 -1
  56. package/src/stripe/server/Charge.ts +7 -1
  57. package/src/stripe/server/internal/html/package.json +1 -1
  58. package/src/stripe/server/internal/html.gen.ts +1 -1
  59. package/src/tempo/server/Charge.ts +15 -4
  60. package/src/tempo/server/Session.test.ts +68 -0
  61. package/src/tempo/server/Session.ts +15 -35
  62. package/src/tempo/server/internal/html/package.json +1 -1
  63. package/src/tempo/server/internal/html.gen.ts +1 -1
  64. package/src/tempo/server/internal/request-body.test.ts +142 -0
  65. package/src/tempo/server/internal/request-body.ts +37 -0
  66. package/src/tempo/server/internal/transport.test.ts +42 -2
  67. package/src/tempo/server/internal/transport.ts +4 -16
@@ -1,9 +1,16 @@
1
1
  import * as http from 'node:http'
2
2
 
3
3
  import { Challenge, Credential, Method, z } from 'mppx'
4
- import { Mppx, Transport, tempo } from 'mppx/server'
4
+ import {
5
+ Mppx as Mppx_client,
6
+ session as tempo_session_client,
7
+ tempo as tempo_client,
8
+ } from 'mppx/client'
9
+ import { Mppx, stripe, Store, Transport, tempo } from 'mppx/server'
10
+ import { getTransactionReceipt } from 'viem/actions'
5
11
  import { describe, expect, test } from 'vp/test'
6
12
  import * as Http from '~test/Http.js'
13
+ import { deployEscrow } from '~test/tempo/session.js'
7
14
  import { accounts, asset, client } from '~test/tempo/viem.js'
8
15
 
9
16
  const realm = 'api.example.com'
@@ -2861,3 +2868,921 @@ describe('realm auto-detection', () => {
2861
2868
  expect(body.detail).toContain('realm')
2862
2869
  })
2863
2870
  })
2871
+
2872
+ // ── mppx.challenge ──────────────────────────────────────────────────────
2873
+
2874
+ describe('challenge', () => {
2875
+ const mockCharge = Method.from({
2876
+ name: 'alpha',
2877
+ intent: 'charge',
2878
+ schema: {
2879
+ credential: { payload: z.object({ token: z.string() }) },
2880
+ request: z.object({
2881
+ amount: z.string(),
2882
+ currency: z.string(),
2883
+ decimals: z.number(),
2884
+ recipient: z.string(),
2885
+ }),
2886
+ },
2887
+ })
2888
+
2889
+ const mockSession = Method.from({
2890
+ name: 'alpha',
2891
+ intent: 'session',
2892
+ schema: {
2893
+ credential: {
2894
+ payload: z.discriminatedUnion('action', [
2895
+ z.object({ action: z.literal('open'), token: z.string() }),
2896
+ z.object({ action: z.literal('voucher'), amount: z.string() }),
2897
+ ]),
2898
+ },
2899
+ request: z.object({
2900
+ amount: z.string(),
2901
+ currency: z.string(),
2902
+ recipient: z.string(),
2903
+ unitType: z.string(),
2904
+ }),
2905
+ },
2906
+ })
2907
+
2908
+ const betaCharge = Method.from({
2909
+ name: 'beta',
2910
+ intent: 'charge',
2911
+ schema: {
2912
+ credential: { payload: z.object({ token: z.string() }) },
2913
+ request: z.object({
2914
+ amount: z.string(),
2915
+ currency: z.string(),
2916
+ decimals: z.number(),
2917
+ recipient: z.string(),
2918
+ }),
2919
+ },
2920
+ })
2921
+
2922
+ function mockReceipt(name: string) {
2923
+ return {
2924
+ method: name,
2925
+ reference: `tx-${name}`,
2926
+ status: 'success' as const,
2927
+ timestamp: new Date().toISOString(),
2928
+ }
2929
+ }
2930
+
2931
+ const alphaChargeServer = Method.toServer(mockCharge, {
2932
+ async verify() {
2933
+ return mockReceipt('alpha')
2934
+ },
2935
+ })
2936
+
2937
+ const alphaSessionServer = Method.toServer(mockSession, {
2938
+ async verify() {
2939
+ return mockReceipt('alpha-session')
2940
+ },
2941
+ })
2942
+
2943
+ const betaChargeServer = Method.toServer(betaCharge, {
2944
+ async verify() {
2945
+ return mockReceipt('beta')
2946
+ },
2947
+ })
2948
+
2949
+ const challengeOpts = {
2950
+ amount: '1000',
2951
+ currency: '0x0000000000000000000000000000000000000001',
2952
+ decimals: 6,
2953
+ expires: new Date(Date.now() + 60_000).toISOString(),
2954
+ recipient: '0x0000000000000000000000000000000000000002',
2955
+ }
2956
+
2957
+ test('mppx.challenge.alpha.charge returns a valid Challenge object', async () => {
2958
+ const mppx = Mppx.create({
2959
+ methods: [alphaChargeServer, alphaSessionServer, betaChargeServer],
2960
+ realm,
2961
+ secretKey,
2962
+ })
2963
+
2964
+ const challenge = await mppx.challenge.alpha.charge(challengeOpts)
2965
+
2966
+ expect(challenge.method).toBe('alpha')
2967
+ expect(challenge.intent).toBe('charge')
2968
+ expect(challenge.realm).toBe(realm)
2969
+ expect(challenge.request.amount).toBe('1000')
2970
+ expect(challenge.request.currency).toBe('0x0000000000000000000000000000000000000001')
2971
+ expect(challenge.request.recipient).toBe('0x0000000000000000000000000000000000000002')
2972
+ expect(challenge.id).toBeDefined()
2973
+ })
2974
+
2975
+ test('mppx.challenge.alpha.session returns a valid Challenge object', async () => {
2976
+ const mppx = Mppx.create({
2977
+ methods: [alphaChargeServer, alphaSessionServer, betaChargeServer],
2978
+ realm,
2979
+ secretKey,
2980
+ })
2981
+
2982
+ const challenge = await mppx.challenge.alpha.session({
2983
+ amount: '500',
2984
+ currency: '0x0000000000000000000000000000000000000001',
2985
+ recipient: '0x0000000000000000000000000000000000000002',
2986
+ unitType: 'token',
2987
+ })
2988
+
2989
+ expect(challenge.method).toBe('alpha')
2990
+ expect(challenge.intent).toBe('session')
2991
+ expect(challenge.realm).toBe(realm)
2992
+ expect(challenge.request.unitType).toBe('token')
2993
+ })
2994
+
2995
+ test('mppx.challenge.beta.charge returns challenge for a different method', async () => {
2996
+ const mppx = Mppx.create({
2997
+ methods: [alphaChargeServer, betaChargeServer],
2998
+ realm,
2999
+ secretKey,
3000
+ })
3001
+
3002
+ const challenge = await mppx.challenge.beta.charge(challengeOpts)
3003
+
3004
+ expect(challenge.method).toBe('beta')
3005
+ expect(challenge.intent).toBe('charge')
3006
+ })
3007
+
3008
+ test('challenge ID is HMAC-bound and verifiable', async () => {
3009
+ const mppx = Mppx.create({
3010
+ methods: [alphaChargeServer],
3011
+ realm,
3012
+ secretKey,
3013
+ })
3014
+
3015
+ const challenge = await mppx.challenge.alpha.charge(challengeOpts)
3016
+ expect(Challenge.verify(challenge, { secretKey })).toBe(true)
3017
+ })
3018
+
3019
+ test('challenge includes description and meta when provided', async () => {
3020
+ const mppx = Mppx.create({
3021
+ methods: [alphaChargeServer],
3022
+ realm,
3023
+ secretKey,
3024
+ })
3025
+
3026
+ const challenge = await mppx.challenge.alpha.charge({
3027
+ ...challengeOpts,
3028
+ description: 'Order #123',
3029
+ meta: { checkout_id: 'chk_abc' },
3030
+ })
3031
+
3032
+ expect(challenge.description).toBe('Order #123')
3033
+ expect(challenge.opaque).toEqual({ checkout_id: 'chk_abc' })
3034
+ })
3035
+
3036
+ test('challenge applies schema transforms', async () => {
3037
+ // Method with a z.transform that converts decimals
3038
+ const transformMethod = Method.from({
3039
+ name: 'transform',
3040
+ intent: 'charge',
3041
+ schema: {
3042
+ credential: { payload: z.object({ token: z.string() }) },
3043
+ request: z.pipe(
3044
+ z.object({
3045
+ amount: z.string(),
3046
+ currency: z.string(),
3047
+ decimals: z.number(),
3048
+ recipient: z.string(),
3049
+ }),
3050
+ z.transform(({ amount, currency, decimals, recipient }) => ({
3051
+ amount: String(Number(amount) * 10 ** decimals),
3052
+ currency,
3053
+ recipient,
3054
+ })),
3055
+ ),
3056
+ },
3057
+ })
3058
+
3059
+ const serverMethod = Method.toServer(transformMethod, {
3060
+ async verify() {
3061
+ return mockReceipt('transform')
3062
+ },
3063
+ })
3064
+
3065
+ const mppx = Mppx.create({ methods: [serverMethod], realm, secretKey })
3066
+
3067
+ const challenge = await mppx.challenge.transform.charge({
3068
+ amount: '25.92',
3069
+ currency: '0x0000000000000000000000000000000000000001',
3070
+ decimals: 6,
3071
+ recipient: '0x0000000000000000000000000000000000000002',
3072
+ })
3073
+
3074
+ // Schema transform should apply: 25.92 * 10^6 = 25920000
3075
+ expect(challenge.request.amount).toBe('25920000')
3076
+ })
3077
+
3078
+ test('challenge awaits async request hooks before creating the challenge', async () => {
3079
+ const asyncMethod = Method.from({
3080
+ name: 'async',
3081
+ intent: 'charge',
3082
+ schema: {
3083
+ credential: { payload: z.object({ token: z.string() }) },
3084
+ request: z.pipe(
3085
+ z.object({
3086
+ amount: z.string(),
3087
+ chainId: z.optional(z.number()),
3088
+ currency: z.string(),
3089
+ decimals: z.number(),
3090
+ recipient: z.string(),
3091
+ }),
3092
+ z.transform(({ amount, chainId, currency, decimals, recipient }) => ({
3093
+ amount: String(Number(amount) * 10 ** decimals),
3094
+ currency,
3095
+ methodDetails: { chainId },
3096
+ recipient,
3097
+ })),
3098
+ ),
3099
+ },
3100
+ })
3101
+
3102
+ const asyncServer = Method.toServer(asyncMethod, {
3103
+ async request({ request }) {
3104
+ await Promise.resolve()
3105
+ return { ...request, chainId: 42431 }
3106
+ },
3107
+ async verify() {
3108
+ return mockReceipt('async')
3109
+ },
3110
+ })
3111
+
3112
+ const mppx = Mppx.create({ methods: [asyncServer], realm, secretKey })
3113
+
3114
+ const challenge = await mppx.challenge.async.charge({
3115
+ amount: '25.92',
3116
+ currency: '0x0000000000000000000000000000000000000001',
3117
+ decimals: 6,
3118
+ recipient: '0x0000000000000000000000000000000000000002',
3119
+ })
3120
+
3121
+ expect(challenge.request.amount).toBe('25920000')
3122
+ expect(challenge.request.methodDetails).toEqual({ chainId: 42431 })
3123
+ })
3124
+
3125
+ test('challenge produced by mppx.challenge is accepted by the 402 handler', async () => {
3126
+ const mppx = Mppx.create({
3127
+ methods: [alphaChargeServer],
3128
+ realm,
3129
+ secretKey,
3130
+ })
3131
+
3132
+ // Generate challenge via the new API
3133
+ const challenge = await mppx.challenge.alpha.charge(challengeOpts)
3134
+
3135
+ // Build a credential from it
3136
+ const credential = Credential.from({ challenge, payload: { token: 'valid' } })
3137
+
3138
+ // Present it to the 402 handler
3139
+ const result = await mppx.charge(challengeOpts)(
3140
+ new Request('https://example.com/resource', {
3141
+ headers: { Authorization: Credential.serialize(credential) },
3142
+ }),
3143
+ )
3144
+
3145
+ expect(result.status).toBe(200)
3146
+ })
3147
+ })
3148
+
3149
+ // ── mppx.verifyCredential ───────────────────────────────────────────────
3150
+
3151
+ describe('verifyCredential', () => {
3152
+ const mockCharge = Method.from({
3153
+ name: 'alpha',
3154
+ intent: 'charge',
3155
+ schema: {
3156
+ credential: { payload: z.object({ token: z.string() }) },
3157
+ request: z.object({
3158
+ amount: z.string(),
3159
+ currency: z.string(),
3160
+ decimals: z.number(),
3161
+ recipient: z.string(),
3162
+ }),
3163
+ },
3164
+ })
3165
+
3166
+ const mockSession = Method.from({
3167
+ name: 'alpha',
3168
+ intent: 'session',
3169
+ schema: {
3170
+ credential: {
3171
+ payload: z.discriminatedUnion('action', [
3172
+ z.object({ action: z.literal('open'), token: z.string() }),
3173
+ z.object({ action: z.literal('voucher'), amount: z.string() }),
3174
+ ]),
3175
+ },
3176
+ request: z.object({
3177
+ amount: z.string(),
3178
+ currency: z.string(),
3179
+ recipient: z.string(),
3180
+ unitType: z.string(),
3181
+ }),
3182
+ },
3183
+ })
3184
+
3185
+ const betaCharge = Method.from({
3186
+ name: 'beta',
3187
+ intent: 'charge',
3188
+ schema: {
3189
+ credential: { payload: z.object({ token: z.string() }) },
3190
+ request: z.object({
3191
+ amount: z.string(),
3192
+ currency: z.string(),
3193
+ decimals: z.number(),
3194
+ recipient: z.string(),
3195
+ }),
3196
+ },
3197
+ })
3198
+
3199
+ function mockReceipt(name: string) {
3200
+ return {
3201
+ method: name,
3202
+ reference: `tx-${name}`,
3203
+ status: 'success' as const,
3204
+ timestamp: new Date().toISOString(),
3205
+ }
3206
+ }
3207
+
3208
+ let verifyArgs: Record<string, unknown> | undefined
3209
+
3210
+ const alphaChargeServer = Method.toServer(mockCharge, {
3211
+ async verify({ credential, request }) {
3212
+ verifyArgs = { credential, request }
3213
+ return mockReceipt('alpha')
3214
+ },
3215
+ })
3216
+
3217
+ const alphaSessionServer = Method.toServer(mockSession, {
3218
+ async verify({ credential, request }) {
3219
+ verifyArgs = { credential, request }
3220
+ return mockReceipt('alpha-session')
3221
+ },
3222
+ })
3223
+
3224
+ const betaChargeServer = Method.toServer(betaCharge, {
3225
+ async verify() {
3226
+ return mockReceipt('beta')
3227
+ },
3228
+ })
3229
+
3230
+ const challengeOpts = {
3231
+ amount: '1000',
3232
+ currency: '0x0000000000000000000000000000000000000001',
3233
+ decimals: 6,
3234
+ expires: new Date(Date.now() + 60_000).toISOString(),
3235
+ recipient: '0x0000000000000000000000000000000000000002',
3236
+ }
3237
+
3238
+ test('verifies a serialized credential string (charge)', async () => {
3239
+ verifyArgs = undefined
3240
+ const mppx = Mppx.create({
3241
+ methods: [alphaChargeServer, alphaSessionServer, betaChargeServer],
3242
+ realm,
3243
+ secretKey,
3244
+ })
3245
+
3246
+ const challenge = await mppx.challenge.alpha.charge(challengeOpts)
3247
+ const credential = Credential.from({ challenge, payload: { token: 'valid' } })
3248
+ const serialized = Credential.serialize(credential)
3249
+
3250
+ const receipt = await mppx.verifyCredential(serialized)
3251
+
3252
+ expect(receipt.status).toBe('success')
3253
+ expect(receipt.method).toBe('alpha')
3254
+ expect(verifyArgs).toBeDefined()
3255
+ })
3256
+
3257
+ test('verifies a parsed Credential object (charge)', async () => {
3258
+ verifyArgs = undefined
3259
+ const mppx = Mppx.create({
3260
+ methods: [alphaChargeServer],
3261
+ realm,
3262
+ secretKey,
3263
+ })
3264
+
3265
+ const challenge = await mppx.challenge.alpha.charge(challengeOpts)
3266
+ const credential = Credential.from({ challenge, payload: { token: 'valid' } })
3267
+
3268
+ const receipt = await mppx.verifyCredential(credential)
3269
+
3270
+ expect(receipt.status).toBe('success')
3271
+ expect(receipt.method).toBe('alpha')
3272
+ })
3273
+
3274
+ test('verifies a credential for session intent', async () => {
3275
+ verifyArgs = undefined
3276
+ const mppx = Mppx.create({
3277
+ methods: [alphaChargeServer, alphaSessionServer],
3278
+ realm,
3279
+ secretKey,
3280
+ })
3281
+
3282
+ const challenge = await mppx.challenge.alpha.session({
3283
+ amount: '500',
3284
+ currency: '0x0000000000000000000000000000000000000001',
3285
+ recipient: '0x0000000000000000000000000000000000000002',
3286
+ unitType: 'token',
3287
+ })
3288
+ const credential = Credential.from({
3289
+ challenge,
3290
+ payload: { action: 'open', token: 'valid' },
3291
+ })
3292
+
3293
+ const receipt = await mppx.verifyCredential(credential)
3294
+
3295
+ expect(receipt.status).toBe('success')
3296
+ expect(receipt.method).toBe('alpha-session')
3297
+ })
3298
+
3299
+ test('dispatches to correct method when multiple methods are registered', async () => {
3300
+ const mppx = Mppx.create({
3301
+ methods: [alphaChargeServer, betaChargeServer],
3302
+ realm,
3303
+ secretKey,
3304
+ })
3305
+
3306
+ const challenge = await mppx.challenge.beta.charge(challengeOpts)
3307
+ const credential = Credential.from({ challenge, payload: { token: 'valid' } })
3308
+
3309
+ const receipt = await mppx.verifyCredential(credential)
3310
+
3311
+ expect(receipt.method).toBe('beta')
3312
+ })
3313
+
3314
+ test('rejects credential with wrong HMAC (not issued by this server)', async () => {
3315
+ const mppx = Mppx.create({
3316
+ methods: [alphaChargeServer],
3317
+ realm,
3318
+ secretKey,
3319
+ })
3320
+
3321
+ const wrongChallenge = Challenge.from({
3322
+ id: 'tampered-id',
3323
+ intent: 'charge',
3324
+ method: 'alpha',
3325
+ realm,
3326
+ request: {
3327
+ amount: '1000',
3328
+ currency: '0x0000000000000000000000000000000000000001',
3329
+ decimals: 6,
3330
+ recipient: '0x0000000000000000000000000000000000000002',
3331
+ },
3332
+ })
3333
+ const credential = Credential.from({
3334
+ challenge: wrongChallenge,
3335
+ payload: { token: 'valid' },
3336
+ })
3337
+
3338
+ await expect(mppx.verifyCredential(credential)).rejects.toThrow(
3339
+ 'challenge was not issued by this server',
3340
+ )
3341
+ })
3342
+
3343
+ test('rejects credential with expired challenge', async () => {
3344
+ const mppx = Mppx.create({
3345
+ methods: [alphaChargeServer],
3346
+ realm,
3347
+ secretKey,
3348
+ })
3349
+
3350
+ const challenge = await mppx.challenge.alpha.charge({
3351
+ ...challengeOpts,
3352
+ expires: new Date(Date.now() - 1000).toISOString(), // already expired
3353
+ })
3354
+ const credential = Credential.from({ challenge, payload: { token: 'valid' } })
3355
+
3356
+ await expect(mppx.verifyCredential(credential)).rejects.toThrow()
3357
+ })
3358
+
3359
+ test('rejects credential with invalid payload schema', async () => {
3360
+ const mppx = Mppx.create({
3361
+ methods: [alphaChargeServer],
3362
+ realm,
3363
+ secretKey,
3364
+ })
3365
+
3366
+ const challenge = await mppx.challenge.alpha.charge(challengeOpts)
3367
+ const credential = Credential.from({
3368
+ challenge,
3369
+ payload: { wrong_field: 123 }, // doesn't match z.object({ token: z.string() })
3370
+ })
3371
+
3372
+ await expect(mppx.verifyCredential(credential)).rejects.toThrow()
3373
+ })
3374
+
3375
+ test('rejects credential for unregistered method/intent', async () => {
3376
+ const mppx = Mppx.create({
3377
+ methods: [alphaChargeServer],
3378
+ realm,
3379
+ secretKey,
3380
+ })
3381
+
3382
+ // Forge a challenge for an unregistered method using the same secret
3383
+ const challenge = Challenge.from({
3384
+ secretKey,
3385
+ intent: 'charge',
3386
+ method: 'unknown',
3387
+ realm,
3388
+ expires: new Date(Date.now() + 60_000).toISOString(),
3389
+ request: {
3390
+ amount: '1000',
3391
+ currency: '0x0000000000000000000000000000000000000001',
3392
+ },
3393
+ })
3394
+ const credential = Credential.from({ challenge, payload: { token: 'valid' } })
3395
+
3396
+ await expect(mppx.verifyCredential(credential)).rejects.toThrow(
3397
+ 'no registered method for unknown/charge',
3398
+ )
3399
+ })
3400
+
3401
+ test('rejects malformed credential string', async () => {
3402
+ const mppx = Mppx.create({
3403
+ methods: [alphaChargeServer],
3404
+ realm,
3405
+ secretKey,
3406
+ })
3407
+
3408
+ await expect(mppx.verifyCredential('not-valid-base64')).rejects.toThrow()
3409
+ })
3410
+
3411
+ test('challenge + verifyCredential round-trip with schema transforms', async () => {
3412
+ const transformMethod = Method.from({
3413
+ name: 'transform',
3414
+ intent: 'charge',
3415
+ schema: {
3416
+ credential: { payload: z.object({ token: z.string() }) },
3417
+ request: z.pipe(
3418
+ z.object({
3419
+ amount: z.string(),
3420
+ currency: z.string(),
3421
+ decimals: z.number(),
3422
+ recipient: z.string(),
3423
+ }),
3424
+ z.transform(({ amount, currency, decimals, recipient }) => ({
3425
+ amount: String(Number(amount) * 10 ** decimals),
3426
+ currency,
3427
+ recipient,
3428
+ })),
3429
+ ),
3430
+ },
3431
+ })
3432
+
3433
+ const serverMethod = Method.toServer(transformMethod, {
3434
+ async verify() {
3435
+ return mockReceipt('transform')
3436
+ },
3437
+ })
3438
+
3439
+ const mppx = Mppx.create({ methods: [serverMethod], realm, secretKey })
3440
+
3441
+ // Generate challenge with human-readable amount
3442
+ const challenge = await mppx.challenge.transform.charge({
3443
+ amount: '25.92',
3444
+ currency: '0x0000000000000000000000000000000000000001',
3445
+ decimals: 6,
3446
+ recipient: '0x0000000000000000000000000000000000000002',
3447
+ })
3448
+
3449
+ // Verify the transform was applied
3450
+ expect(challenge.request.amount).toBe('25920000')
3451
+
3452
+ // Build credential and verify end-to-end
3453
+ const credential = Credential.from({ challenge, payload: { token: 'valid' } })
3454
+ const receipt = await mppx.verifyCredential(credential)
3455
+
3456
+ expect(receipt.status).toBe('success')
3457
+ expect(receipt.method).toBe('transform')
3458
+ })
3459
+
3460
+ test('verifies a credential for a transformed built-in method', async () => {
3461
+ const stripeClient = {
3462
+ paymentIntents: {
3463
+ create: async (input: { amount: number; currency: string }) => {
3464
+ expect(input.amount).toBe(2592)
3465
+ expect(input.currency).toBe('usd')
3466
+
3467
+ return {
3468
+ id: 'pi_123',
3469
+ lastResponse: { headers: {} },
3470
+ status: 'succeeded',
3471
+ }
3472
+ },
3473
+ },
3474
+ }
3475
+
3476
+ const mppx = Mppx.create({
3477
+ methods: [
3478
+ stripe.charge({
3479
+ client: stripeClient as never,
3480
+ currency: 'usd',
3481
+ decimals: 2,
3482
+ networkId: 'internal',
3483
+ paymentMethodTypes: ['card'],
3484
+ }),
3485
+ ],
3486
+ realm,
3487
+ secretKey,
3488
+ })
3489
+
3490
+ const challenge = await mppx.challenge.stripe.charge({
3491
+ amount: '25.92',
3492
+ })
3493
+ const credential = Credential.from({
3494
+ challenge,
3495
+ payload: { spt: 'spt_test' },
3496
+ })
3497
+
3498
+ const receipt = await mppx.verifyCredential(credential)
3499
+
3500
+ expect(receipt.status).toBe('success')
3501
+ expect(receipt.method).toBe('stripe')
3502
+ })
3503
+
3504
+ test('verifies a serialized credential for a transformed built-in method', async () => {
3505
+ const stripeClient = {
3506
+ paymentIntents: {
3507
+ create: async (input: { amount: number; currency: string }) => {
3508
+ expect(input.amount).toBe(2592)
3509
+ expect(input.currency).toBe('usd')
3510
+
3511
+ return {
3512
+ id: 'pi_456',
3513
+ lastResponse: { headers: {} },
3514
+ status: 'succeeded',
3515
+ }
3516
+ },
3517
+ },
3518
+ }
3519
+
3520
+ const mppx = Mppx.create({
3521
+ methods: [
3522
+ stripe.charge({
3523
+ client: stripeClient as never,
3524
+ currency: 'usd',
3525
+ decimals: 2,
3526
+ networkId: 'internal',
3527
+ paymentMethodTypes: ['card'],
3528
+ }),
3529
+ ],
3530
+ realm,
3531
+ secretKey,
3532
+ })
3533
+
3534
+ const challenge = await mppx.challenge.stripe.charge({ amount: '25.92' })
3535
+ const credential = Credential.from({
3536
+ challenge,
3537
+ payload: { spt: 'spt_serialized' },
3538
+ })
3539
+
3540
+ const receipt = await mppx.verifyCredential(Credential.serialize(credential))
3541
+
3542
+ expect(receipt.status).toBe('success')
3543
+ expect(receipt.method).toBe('stripe')
3544
+ })
3545
+
3546
+ test('verifies a zero-amount proof credential created from a real 402 response', async () => {
3547
+ const server = Mppx.create({
3548
+ methods: [
3549
+ tempo.charge({
3550
+ account: accounts[0],
3551
+ currency: asset,
3552
+ getClient: () => client,
3553
+ }),
3554
+ ],
3555
+ realm,
3556
+ secretKey,
3557
+ })
3558
+ const clientMppx = Mppx_client.create({
3559
+ polyfill: false,
3560
+ methods: [
3561
+ tempo_client.charge({
3562
+ account: accounts[1],
3563
+ getClient: () => client,
3564
+ }),
3565
+ ],
3566
+ })
3567
+
3568
+ const httpServer = await Http.createServer(async (req, res) => {
3569
+ const result = await Mppx.toNodeListener(server.charge({ amount: '0' }))(req, res)
3570
+ if (result.status === 402) return
3571
+ res.end('OK')
3572
+ })
3573
+
3574
+ const response = await fetch(httpServer.url)
3575
+ expect(response.status).toBe(402)
3576
+
3577
+ const serializedCredential = await clientMppx.createCredential(response)
3578
+ const proofCredential = Credential.deserialize(serializedCredential)
3579
+ expect(proofCredential.payload).toMatchObject({ type: 'proof' })
3580
+
3581
+ const receipt = await server.verifyCredential(serializedCredential)
3582
+
3583
+ expect(receipt.status).toBe('success')
3584
+ expect(receipt.method).toBe('tempo')
3585
+
3586
+ httpServer.close()
3587
+ })
3588
+
3589
+ test('verifies a sponsored tempo credential created from a real 402 response', async () => {
3590
+ const server = Mppx.create({
3591
+ methods: [
3592
+ tempo.charge({
3593
+ account: accounts[0],
3594
+ currency: asset,
3595
+ feePayer: true,
3596
+ getClient: () => client,
3597
+ }),
3598
+ ],
3599
+ realm,
3600
+ secretKey,
3601
+ })
3602
+ const clientMppx = Mppx_client.create({
3603
+ polyfill: false,
3604
+ methods: [
3605
+ tempo_client.charge({
3606
+ account: accounts[1],
3607
+ getClient: () => client,
3608
+ }),
3609
+ ],
3610
+ })
3611
+
3612
+ const httpServer = await Http.createServer(async (req, res) => {
3613
+ const result = await Mppx.toNodeListener(server.charge({ amount: '1' }))(req, res)
3614
+ if (result.status === 402) return
3615
+ res.end('OK')
3616
+ })
3617
+
3618
+ const response = await fetch(httpServer.url)
3619
+ expect(response.status).toBe(402)
3620
+
3621
+ const serializedCredential = await clientMppx.createCredential(response, { mode: 'pull' })
3622
+ const transactionCredential = Credential.deserialize(serializedCredential)
3623
+ expect(transactionCredential.payload).toMatchObject({ type: 'transaction' })
3624
+
3625
+ const receipt = await server.verifyCredential(serializedCredential)
3626
+
3627
+ expect(receipt.status).toBe('success')
3628
+ expect(receipt.method).toBe('tempo')
3629
+
3630
+ const txReceipt = await getTransactionReceipt(client, {
3631
+ hash: receipt.reference as `0x${string}`,
3632
+ })
3633
+ expect((txReceipt as { feePayer?: string }).feePayer).toBe(accounts[0].address.toLowerCase())
3634
+
3635
+ httpServer.close()
3636
+ })
3637
+
3638
+ test('verifies real session open and voucher credentials created from 402 responses', async () => {
3639
+ const escrowContract = await deployEscrow()
3640
+ const server = Mppx.create({
3641
+ methods: [
3642
+ tempo.session({
3643
+ store: Store.memory(),
3644
+ getClient: () => client,
3645
+ account: accounts[0],
3646
+ currency: asset,
3647
+ escrowContract,
3648
+ chainId: client.chain!.id,
3649
+ }),
3650
+ ],
3651
+ realm,
3652
+ secretKey,
3653
+ })
3654
+ const clientMppx = Mppx_client.create({
3655
+ polyfill: false,
3656
+ methods: [
3657
+ tempo_session_client({
3658
+ account: accounts[1],
3659
+ deposit: '10',
3660
+ getClient: () => client,
3661
+ }),
3662
+ ],
3663
+ })
3664
+
3665
+ const httpServer = await Http.createServer(async (req, res) => {
3666
+ const result = await Mppx.toNodeListener(
3667
+ server.session({ amount: '1', unitType: 'request' }),
3668
+ )(req, res)
3669
+ if (result.status === 402) return
3670
+ res.end('OK')
3671
+ })
3672
+
3673
+ const openChallengeResponse = await fetch(httpServer.url)
3674
+ expect(openChallengeResponse.status).toBe(402)
3675
+
3676
+ const serializedOpenCredential = await clientMppx.createCredential(openChallengeResponse)
3677
+ const openCredential = Credential.deserialize(serializedOpenCredential)
3678
+ expect(openCredential.payload).toMatchObject({ action: 'open' })
3679
+
3680
+ const openReceipt = await server.verifyCredential(serializedOpenCredential)
3681
+
3682
+ expect(openReceipt.status).toBe('success')
3683
+ expect(openReceipt.method).toBe('tempo')
3684
+
3685
+ const voucherChallengeResponse = await fetch(httpServer.url)
3686
+ expect(voucherChallengeResponse.status).toBe(402)
3687
+
3688
+ const serializedVoucherCredential = await clientMppx.createCredential(voucherChallengeResponse)
3689
+ const voucherCredential = Credential.deserialize(serializedVoucherCredential)
3690
+ expect(voucherCredential.payload).toMatchObject({ action: 'voucher' })
3691
+
3692
+ const voucherReceipt = await server.verifyCredential(serializedVoucherCredential)
3693
+
3694
+ expect(voucherReceipt.status).toBe('success')
3695
+ expect(voucherReceipt.method).toBe('tempo')
3696
+ expect(voucherReceipt.reference).toBe(openReceipt.reference)
3697
+
3698
+ httpServer.close()
3699
+ })
3700
+
3701
+ test('verifies a sponsored tempo credential created by the real client', async () => {
3702
+ const server = Mppx.create({
3703
+ methods: [
3704
+ tempo.charge({
3705
+ account: accounts[0],
3706
+ currency: asset,
3707
+ feePayer: true,
3708
+ getClient: () => client,
3709
+ }),
3710
+ ],
3711
+ realm,
3712
+ secretKey,
3713
+ })
3714
+
3715
+ const challenge = await server.challenge.tempo.charge({ amount: '1' })
3716
+ const clientMethod = tempo_client.charge({
3717
+ account: accounts[1],
3718
+ getClient: () => client,
3719
+ })
3720
+ const credential = await clientMethod.createCredential({
3721
+ challenge: challenge as Parameters<typeof clientMethod.createCredential>[0]['challenge'],
3722
+ context: { mode: 'pull' },
3723
+ })
3724
+
3725
+ const receipt = await server.verifyCredential(credential)
3726
+
3727
+ expect(receipt.status).toBe('success')
3728
+ expect(receipt.method).toBe('tempo')
3729
+
3730
+ const txReceipt = await getTransactionReceipt(client, {
3731
+ hash: receipt.reference as `0x${string}`,
3732
+ })
3733
+ expect((txReceipt as { feePayer?: string }).feePayer).toBe(accounts[0].address.toLowerCase())
3734
+ })
3735
+
3736
+ test('verifies a sponsored tempo credential object created by the real client', async () => {
3737
+ const server = Mppx.create({
3738
+ methods: [
3739
+ tempo.charge({
3740
+ account: accounts[0],
3741
+ currency: asset,
3742
+ feePayer: true,
3743
+ getClient: () => client,
3744
+ }),
3745
+ ],
3746
+ realm,
3747
+ secretKey,
3748
+ })
3749
+
3750
+ const challenge = await server.challenge.tempo.charge({ amount: '1' })
3751
+ const clientMethod = tempo_client.charge({
3752
+ account: accounts[1],
3753
+ getClient: () => client,
3754
+ })
3755
+ const serializedCredential = await clientMethod.createCredential({
3756
+ challenge: challenge as Parameters<typeof clientMethod.createCredential>[0]['challenge'],
3757
+ context: { mode: 'pull' },
3758
+ })
3759
+
3760
+ const receipt = await server.verifyCredential(Credential.deserialize(serializedCredential))
3761
+
3762
+ expect(receipt.status).toBe('success')
3763
+ expect(receipt.method).toBe('tempo')
3764
+
3765
+ const txReceipt = await getTransactionReceipt(client, {
3766
+ hash: receipt.reference as `0x${string}`,
3767
+ })
3768
+ expect((txReceipt as { feePayer?: string }).feePayer).toBe(accounts[0].address.toLowerCase())
3769
+ })
3770
+
3771
+ test('challenge + verifyCredential round-trip with serialized string', async () => {
3772
+ const mppx = Mppx.create({
3773
+ methods: [alphaChargeServer, alphaSessionServer],
3774
+ realm,
3775
+ secretKey,
3776
+ })
3777
+
3778
+ // Generate, serialize, verify — the full UCP flow
3779
+ const challenge = await mppx.challenge.alpha.charge(challengeOpts)
3780
+ const credential = Credential.from({ challenge, payload: { token: 'valid' } })
3781
+ const serialized = Credential.serialize(credential)
3782
+
3783
+ // Simulate receiving the credential string from a UCP instrument
3784
+ const receipt = await mppx.verifyCredential(serialized)
3785
+
3786
+ expect(receipt.status).toBe('success')
3787
+ })
3788
+ })