mppx 0.4.12 → 0.5.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 (52) hide show
  1. package/CHANGELOG.md +6 -0
  2. package/dist/Expires.d.ts +7 -0
  3. package/dist/Expires.d.ts.map +1 -1
  4. package/dist/Expires.js +21 -0
  5. package/dist/Expires.js.map +1 -1
  6. package/dist/server/Mppx.js +6 -5
  7. package/dist/server/Mppx.js.map +1 -1
  8. package/dist/stripe/server/Charge.d.ts.map +1 -1
  9. package/dist/stripe/server/Charge.js +3 -3
  10. package/dist/stripe/server/Charge.js.map +1 -1
  11. package/dist/tempo/Methods.d.ts +3 -0
  12. package/dist/tempo/Methods.d.ts.map +1 -1
  13. package/dist/tempo/Methods.js +1 -0
  14. package/dist/tempo/Methods.js.map +1 -1
  15. package/dist/tempo/client/Charge.d.ts +3 -0
  16. package/dist/tempo/client/Charge.d.ts.map +1 -1
  17. package/dist/tempo/client/Charge.js +18 -2
  18. package/dist/tempo/client/Charge.js.map +1 -1
  19. package/dist/tempo/client/Methods.d.ts +3 -0
  20. package/dist/tempo/client/Methods.d.ts.map +1 -1
  21. package/dist/tempo/internal/proof.d.ts +23 -0
  22. package/dist/tempo/internal/proof.d.ts.map +1 -0
  23. package/dist/tempo/internal/proof.js +17 -0
  24. package/dist/tempo/internal/proof.js.map +1 -0
  25. package/dist/tempo/server/Charge.d.ts +3 -0
  26. package/dist/tempo/server/Charge.d.ts.map +1 -1
  27. package/dist/tempo/server/Charge.js +32 -4
  28. package/dist/tempo/server/Charge.js.map +1 -1
  29. package/dist/tempo/server/Methods.d.ts +3 -0
  30. package/dist/tempo/server/Methods.d.ts.map +1 -1
  31. package/package.json +1 -1
  32. package/src/Expires.ts +25 -0
  33. package/src/cli/cli.test.ts +230 -1
  34. package/src/middlewares/elysia.test.ts +127 -4
  35. package/src/middlewares/express.test.ts +120 -54
  36. package/src/middlewares/hono.test.ts +73 -34
  37. package/src/middlewares/nextjs.test.ts +159 -36
  38. package/src/server/Mppx.test.ts +86 -0
  39. package/src/server/Mppx.ts +5 -5
  40. package/src/stripe/server/Charge.ts +3 -7
  41. package/src/tempo/Methods.test.ts +26 -0
  42. package/src/tempo/Methods.ts +1 -0
  43. package/src/tempo/client/Charge.ts +26 -3
  44. package/src/tempo/internal/charge.test.ts +66 -0
  45. package/src/tempo/internal/proof.test.ts +36 -0
  46. package/src/tempo/internal/proof.ts +19 -0
  47. package/src/tempo/server/Charge.test.ts +362 -1
  48. package/src/tempo/server/Charge.ts +40 -2
  49. package/src/tempo/server/Session.test.ts +1123 -53
  50. package/src/tempo/server/internal/transport.test.ts +32 -0
  51. package/src/tempo/session/Chain.test.ts +35 -0
  52. package/src/tempo/session/Sse.test.ts +31 -0
@@ -5,7 +5,12 @@ import type { Hex } from 'ox'
5
5
  import { TxEnvelopeTempo } from 'ox/tempo'
6
6
  import { Handler } from 'tempo.ts/server'
7
7
  import { createClient, custom, encodeFunctionData, parseUnits } from 'viem'
8
- import { getTransactionReceipt, prepareTransactionRequest, signTransaction } from 'viem/actions'
8
+ import {
9
+ getTransactionReceipt,
10
+ prepareTransactionRequest,
11
+ signTypedData,
12
+ signTransaction,
13
+ } from 'viem/actions'
9
14
  import { Abis, Account, Actions, Addresses, Secp256k1, Tick, Transaction } from 'viem/tempo'
10
15
  import { beforeAll, describe, expect, test } from 'vp/test'
11
16
  import * as Http from '~test/Http.js'
@@ -14,6 +19,7 @@ import { accounts, asset, chain, client, fundAccount } from '~test/tempo/viem.js
14
19
 
15
20
  import * as Store from '../../Store.js'
16
21
  import * as Attribution from '../Attribution.js'
22
+ import * as Proof from '../internal/proof.js'
17
23
  import { signVoucher } from '../session/Voucher.js'
18
24
 
19
25
  const realm = 'api.example.com'
@@ -1920,6 +1926,361 @@ describe('tempo', () => {
1920
1926
  })
1921
1927
  })
1922
1928
 
1929
+ describe('intent: charge; type: proof (zero-dollar auth)', () => {
1930
+ test('default: end-to-end zero-dollar auth via SDK', async () => {
1931
+ const mppx = Mppx_client.create({
1932
+ polyfill: false,
1933
+ methods: [
1934
+ tempo_client({
1935
+ account: accounts[1],
1936
+ getClient: () => client,
1937
+ }),
1938
+ ],
1939
+ })
1940
+
1941
+ const httpServer = await Http.createServer(async (req, res) => {
1942
+ const result = await Mppx_server.toNodeListener(
1943
+ server.charge({ amount: '0', decimals: 6 }),
1944
+ )(req, res)
1945
+ if (result.status === 402) return
1946
+ res.end('OK')
1947
+ })
1948
+
1949
+ const response = await mppx.fetch(httpServer.url)
1950
+ expect(response.status).toBe(200)
1951
+
1952
+ const receipt = Receipt.fromResponse(response)
1953
+ expect(receipt.status).toBe('success')
1954
+ expect(receipt.method).toBe('tempo')
1955
+ expect(receipt.reference).toBeDefined()
1956
+
1957
+ httpServer.close()
1958
+ })
1959
+
1960
+ test('behavior: proof credential contains valid source DID', async () => {
1961
+ const httpServer = await Http.createServer(async (req, res) => {
1962
+ const result = await Mppx_server.toNodeListener(
1963
+ server.charge({ amount: '0', decimals: 6 }),
1964
+ )(req, res)
1965
+ if (result.status === 402) return
1966
+ res.end('OK')
1967
+ })
1968
+
1969
+ const response1 = await fetch(httpServer.url)
1970
+ expect(response1.status).toBe(402)
1971
+
1972
+ const challenge = Challenge.fromResponse(response1, {
1973
+ methods: [tempo_client.charge()],
1974
+ })
1975
+
1976
+ const signature = await signTypedData(client, {
1977
+ account: accounts[1],
1978
+ domain: Proof.domain(chain.id),
1979
+ types: Proof.types,
1980
+ primaryType: 'Proof',
1981
+ message: Proof.message(challenge.id),
1982
+ })
1983
+
1984
+ const credential = Credential.from({
1985
+ challenge,
1986
+ payload: { signature, type: 'proof' as const },
1987
+ source: `did:pkh:eip155:${chain.id}:${accounts[1].address}`,
1988
+ })
1989
+
1990
+ const response2 = await fetch(httpServer.url, {
1991
+ headers: { Authorization: Credential.serialize(credential) },
1992
+ })
1993
+ expect(response2.status).toBe(200)
1994
+
1995
+ httpServer.close()
1996
+ })
1997
+
1998
+ test('behavior: rejects proof with wrong signer', async () => {
1999
+ const httpServer = await Http.createServer(async (req, res) => {
2000
+ const result = await Mppx_server.toNodeListener(
2001
+ server.charge({ amount: '0', decimals: 6 }),
2002
+ )(req, res)
2003
+ if (result.status === 402) return
2004
+ res.end('OK')
2005
+ })
2006
+
2007
+ const response1 = await fetch(httpServer.url)
2008
+ const challenge = Challenge.fromResponse(response1, {
2009
+ methods: [tempo_client.charge()],
2010
+ })
2011
+
2012
+ // Sign with accounts[2] but claim source is accounts[1]
2013
+ const signature = await signTypedData(client, {
2014
+ account: accounts[2],
2015
+ domain: Proof.domain(chain.id),
2016
+ types: Proof.types,
2017
+ primaryType: 'Proof',
2018
+ message: Proof.message(challenge.id),
2019
+ })
2020
+
2021
+ const credential = Credential.from({
2022
+ challenge,
2023
+ payload: { signature, type: 'proof' as const },
2024
+ source: `did:pkh:eip155:${chain.id}:${accounts[1].address}`,
2025
+ })
2026
+
2027
+ const response2 = await fetch(httpServer.url, {
2028
+ headers: { Authorization: Credential.serialize(credential) },
2029
+ })
2030
+ expect(response2.status).toBe(402)
2031
+
2032
+ httpServer.close()
2033
+ })
2034
+
2035
+ test('behavior: rejects proof without source', async () => {
2036
+ const httpServer = await Http.createServer(async (req, res) => {
2037
+ const result = await Mppx_server.toNodeListener(
2038
+ server.charge({ amount: '0', decimals: 6 }),
2039
+ )(req, res)
2040
+ if (result.status === 402) return
2041
+ res.end('OK')
2042
+ })
2043
+
2044
+ const response1 = await fetch(httpServer.url)
2045
+ const challenge = Challenge.fromResponse(response1, {
2046
+ methods: [tempo_client.charge()],
2047
+ })
2048
+
2049
+ const signature = await signTypedData(client, {
2050
+ account: accounts[1],
2051
+ domain: Proof.domain(chain.id),
2052
+ types: Proof.types,
2053
+ primaryType: 'Proof',
2054
+ message: Proof.message(challenge.id),
2055
+ })
2056
+
2057
+ const credential = Credential.from({
2058
+ challenge,
2059
+ payload: { signature, type: 'proof' as const },
2060
+ // no source
2061
+ })
2062
+
2063
+ const response2 = await fetch(httpServer.url, {
2064
+ headers: { Authorization: Credential.serialize(credential) },
2065
+ })
2066
+ expect(response2.status).toBe(402)
2067
+
2068
+ httpServer.close()
2069
+ })
2070
+
2071
+ test('behavior: rejects transaction payload for zero-amount', async () => {
2072
+ const httpServer = await Http.createServer(async (req, res) => {
2073
+ const result = await Mppx_server.toNodeListener(
2074
+ server.charge({ amount: '0', decimals: 6 }),
2075
+ )(req, res)
2076
+ if (result.status === 402) return
2077
+ res.end('OK')
2078
+ })
2079
+
2080
+ const response1 = await fetch(httpServer.url)
2081
+ const challenge = Challenge.fromResponse(response1, {
2082
+ methods: [tempo_client.charge()],
2083
+ })
2084
+
2085
+ const credential = Credential.from({
2086
+ challenge,
2087
+ payload: { signature: '0xdead', type: 'transaction' as const },
2088
+ source: `did:pkh:eip155:${chain.id}:${accounts[1].address}`,
2089
+ })
2090
+
2091
+ const response2 = await fetch(httpServer.url, {
2092
+ headers: { Authorization: Credential.serialize(credential) },
2093
+ })
2094
+ expect(response2.status).toBe(402)
2095
+ const body = (await response2.json()) as { detail: string }
2096
+ expect(body.detail).toContain('Zero-amount challenges require a proof credential.')
2097
+
2098
+ httpServer.close()
2099
+ })
2100
+
2101
+ test('behavior: rejects hash payload for zero-amount', async () => {
2102
+ const httpServer = await Http.createServer(async (req, res) => {
2103
+ const result = await Mppx_server.toNodeListener(
2104
+ server.charge({ amount: '0', decimals: 6 }),
2105
+ )(req, res)
2106
+ if (result.status === 402) return
2107
+ res.end('OK')
2108
+ })
2109
+
2110
+ const response1 = await fetch(httpServer.url)
2111
+ const challenge = Challenge.fromResponse(response1, {
2112
+ methods: [tempo_client.charge()],
2113
+ })
2114
+
2115
+ const credential = Credential.from({
2116
+ challenge,
2117
+ payload: {
2118
+ hash: '0x0000000000000000000000000000000000000000000000000000000000000001',
2119
+ type: 'hash' as const,
2120
+ },
2121
+ source: `did:pkh:eip155:${chain.id}:${accounts[1].address}`,
2122
+ })
2123
+
2124
+ const response2 = await fetch(httpServer.url, {
2125
+ headers: { Authorization: Credential.serialize(credential) },
2126
+ })
2127
+ expect(response2.status).toBe(402)
2128
+ const body = (await response2.json()) as { detail: string }
2129
+ expect(body.detail).toContain('Zero-amount challenges require a proof credential.')
2130
+
2131
+ httpServer.close()
2132
+ })
2133
+
2134
+ test('behavior: rejects proof payload for non-zero amount', async () => {
2135
+ const httpServer = await Http.createServer(async (req, res) => {
2136
+ const result = await Mppx_server.toNodeListener(
2137
+ server.charge({ amount: '1', decimals: 6 }),
2138
+ )(req, res)
2139
+ if (result.status === 402) return
2140
+ res.end('OK')
2141
+ })
2142
+
2143
+ const response1 = await fetch(httpServer.url)
2144
+ const challenge = Challenge.fromResponse(response1, {
2145
+ methods: [tempo_client.charge()],
2146
+ })
2147
+
2148
+ const signature = await signTypedData(client, {
2149
+ account: accounts[1],
2150
+ domain: Proof.domain(chain.id),
2151
+ types: Proof.types,
2152
+ primaryType: 'Proof',
2153
+ message: Proof.message(challenge.id),
2154
+ })
2155
+
2156
+ const credential = Credential.from({
2157
+ challenge,
2158
+ payload: { signature, type: 'proof' as const },
2159
+ source: `did:pkh:eip155:${chain.id}:${accounts[1].address}`,
2160
+ })
2161
+
2162
+ const response2 = await fetch(httpServer.url, {
2163
+ headers: { Authorization: Credential.serialize(credential) },
2164
+ })
2165
+ expect(response2.status).toBe(402)
2166
+ const body = (await response2.json()) as { detail: string }
2167
+ expect(body.detail).toContain('Proof credentials are only valid for zero-amount challenges.')
2168
+
2169
+ httpServer.close()
2170
+ })
2171
+
2172
+ test('behavior: receipt reference is the challenge ID', async () => {
2173
+ const httpServer = await Http.createServer(async (req, res) => {
2174
+ const result = await Mppx_server.toNodeListener(
2175
+ server.charge({ amount: '0', decimals: 6 }),
2176
+ )(req, res)
2177
+ if (result.status === 402) return
2178
+ res.end('OK')
2179
+ })
2180
+
2181
+ const response1 = await fetch(httpServer.url)
2182
+ const challenge = Challenge.fromResponse(response1, {
2183
+ methods: [tempo_client.charge()],
2184
+ })
2185
+
2186
+ const signature = await signTypedData(client, {
2187
+ account: accounts[1],
2188
+ domain: Proof.domain(chain.id),
2189
+ types: Proof.types,
2190
+ primaryType: 'Proof',
2191
+ message: Proof.message(challenge.id),
2192
+ })
2193
+
2194
+ const credential = Credential.from({
2195
+ challenge,
2196
+ payload: { signature, type: 'proof' as const },
2197
+ source: `did:pkh:eip155:${chain.id}:${accounts[1].address}`,
2198
+ })
2199
+
2200
+ const response2 = await fetch(httpServer.url, {
2201
+ headers: { Authorization: Credential.serialize(credential) },
2202
+ })
2203
+ expect(response2.status).toBe(200)
2204
+ const receipt = Receipt.fromResponse(response2)
2205
+ expect(receipt.reference).toBe(challenge.id)
2206
+
2207
+ httpServer.close()
2208
+ })
2209
+
2210
+ test('behavior: rejects proof signed with wrong chainId domain', async () => {
2211
+ const httpServer = await Http.createServer(async (req, res) => {
2212
+ const result = await Mppx_server.toNodeListener(
2213
+ server.charge({ amount: '0', decimals: 6 }),
2214
+ )(req, res)
2215
+ if (result.status === 402) return
2216
+ res.end('OK')
2217
+ })
2218
+
2219
+ const response1 = await fetch(httpServer.url)
2220
+ const challenge = Challenge.fromResponse(response1, {
2221
+ methods: [tempo_client.charge()],
2222
+ })
2223
+
2224
+ // Sign with a different chainId in the EIP-712 domain
2225
+ const signature = await signTypedData(client, {
2226
+ account: accounts[1],
2227
+ domain: Proof.domain(99999),
2228
+ types: Proof.types,
2229
+ primaryType: 'Proof',
2230
+ message: Proof.message(challenge.id),
2231
+ })
2232
+
2233
+ const credential = Credential.from({
2234
+ challenge,
2235
+ payload: { signature, type: 'proof' as const },
2236
+ source: `did:pkh:eip155:${chain.id}:${accounts[1].address}`,
2237
+ })
2238
+
2239
+ const response2 = await fetch(httpServer.url, {
2240
+ headers: { Authorization: Credential.serialize(credential) },
2241
+ })
2242
+ expect(response2.status).toBe(402)
2243
+
2244
+ httpServer.close()
2245
+ })
2246
+
2247
+ test('behavior: rejects proof with malformed source DID', async () => {
2248
+ const httpServer = await Http.createServer(async (req, res) => {
2249
+ const result = await Mppx_server.toNodeListener(
2250
+ server.charge({ amount: '0', decimals: 6 }),
2251
+ )(req, res)
2252
+ if (result.status === 402) return
2253
+ res.end('OK')
2254
+ })
2255
+
2256
+ const response1 = await fetch(httpServer.url)
2257
+ const challenge = Challenge.fromResponse(response1, {
2258
+ methods: [tempo_client.charge()],
2259
+ })
2260
+
2261
+ const signature = await signTypedData(client, {
2262
+ account: accounts[1],
2263
+ domain: Proof.domain(chain.id),
2264
+ types: Proof.types,
2265
+ primaryType: 'Proof',
2266
+ message: Proof.message(challenge.id),
2267
+ })
2268
+
2269
+ const credential = Credential.from({
2270
+ challenge,
2271
+ payload: { signature, type: 'proof' as const },
2272
+ source: 'not-a-valid-did',
2273
+ })
2274
+
2275
+ const response2 = await fetch(httpServer.url, {
2276
+ headers: { Authorization: Credential.serialize(credential) },
2277
+ })
2278
+ expect(response2.status).toBe(402)
2279
+
2280
+ httpServer.close()
2281
+ })
2282
+ })
2283
+
1923
2284
  describe('intent: unknown', () => {
1924
2285
  test('behavior: returns 402 for invalid payload schema', async () => {
1925
2286
  const httpServer = await Http.createServer(async (req, res) => {
@@ -4,12 +4,13 @@ import {
4
4
  sendRawTransaction,
5
5
  sendRawTransactionSync,
6
6
  signTransaction,
7
+ verifyTypedData,
7
8
  call as viem_call,
8
9
  } from 'viem/actions'
9
10
  import { tempo as tempo_chain } from 'viem/chains'
10
11
  import { Abis, Transaction } from 'viem/tempo'
11
12
 
12
- import { PaymentExpiredError } from '../../Errors.js'
13
+ import * as Expires from '../../Expires.js'
13
14
  import type { LooseOmit, NoExtraKeys } from '../../internal/types.js'
14
15
  import * as Method from '../../Method.js'
15
16
  import * as Store from '../../Store.js'
@@ -19,6 +20,7 @@ import * as TempoAddress from '../internal/address.js'
19
20
  import * as Charge_internal from '../internal/charge.js'
20
21
  import * as defaults from '../internal/defaults.js'
21
22
  import * as FeePayer from '../internal/fee-payer.js'
23
+ import * as Proof from '../internal/proof.js'
22
24
  import * as Selectors from '../internal/selectors.js'
23
25
  import type * as types from '../internal/types.js'
24
26
  import * as Methods from '../Methods.js'
@@ -118,11 +120,15 @@ export function charge<const parameters extends charge.Parameters>(
118
120
  const currency = challengeRequest.currency as `0x${string}`
119
121
  const recipient = challengeRequest.recipient as `0x${string}`
120
122
 
121
- if (expires && new Date(expires) < new Date()) throw new PaymentExpiredError({ expires })
123
+ Expires.assert(expires, challenge.id)
122
124
 
123
125
  const memo = methodDetails?.memo as `0x${string}` | undefined
124
126
 
125
127
  const payload = credential.payload
128
+ const isZeroAmount = BigInt(amount) === 0n
129
+
130
+ if (isZeroAmount && payload.type !== 'proof')
131
+ throw new MismatchError('Zero-amount challenges require a proof credential.', {})
126
132
 
127
133
  switch (payload.type) {
128
134
  case 'hash': {
@@ -142,6 +148,38 @@ export function charge<const parameters extends charge.Parameters>(
142
148
  return toReceipt(receipt)
143
149
  }
144
150
 
151
+ case 'proof': {
152
+ if (!isZeroAmount)
153
+ throw new MismatchError(
154
+ 'Proof credentials are only valid for zero-amount challenges.',
155
+ {},
156
+ )
157
+
158
+ const expectedSource = credential.source
159
+ if (!expectedSource)
160
+ throw new MismatchError('Proof credential must include a source.', {})
161
+
162
+ const sourceAddress = expectedSource.split(':').pop() as `0x${string}`
163
+ const resolvedChainId = challenge.request.methodDetails?.chainId ?? chainId!
164
+
165
+ const valid = await verifyTypedData(client, {
166
+ address: sourceAddress,
167
+ domain: Proof.domain(resolvedChainId),
168
+ types: Proof.types,
169
+ primaryType: 'Proof',
170
+ message: Proof.message(challenge.id),
171
+ signature: payload.signature as `0x${string}`,
172
+ })
173
+ if (!valid) throw new MismatchError('Proof signature does not match source.', {})
174
+
175
+ return {
176
+ method: 'tempo',
177
+ status: 'success',
178
+ timestamp: new Date().toISOString(),
179
+ reference: challenge.id,
180
+ } as const
181
+ }
182
+
145
183
  case 'transaction': {
146
184
  const serializedTransaction = payload.signature as Transaction.TransactionSerializedTempo
147
185