mppx 0.8.13 → 0.8.15

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 (156) hide show
  1. package/CHANGELOG.md +37 -0
  2. package/dist/Errors.d.ts +10 -0
  3. package/dist/Errors.d.ts.map +1 -1
  4. package/dist/Errors.js +11 -2
  5. package/dist/Errors.js.map +1 -1
  6. package/dist/Method.d.ts +105 -3
  7. package/dist/Method.d.ts.map +1 -1
  8. package/dist/Method.js +102 -2
  9. package/dist/Method.js.map +1 -1
  10. package/dist/cli/validate/payment.d.ts.map +1 -1
  11. package/dist/cli/validate/payment.js +18 -4
  12. package/dist/cli/validate/payment.js.map +1 -1
  13. package/dist/client/internal/Fetch.d.ts.map +1 -1
  14. package/dist/client/internal/Fetch.js +98 -5
  15. package/dist/client/internal/Fetch.js.map +1 -1
  16. package/dist/client/internal/MethodChallenge.d.ts +19 -0
  17. package/dist/client/internal/MethodChallenge.d.ts.map +1 -0
  18. package/dist/client/internal/MethodChallenge.js +15 -0
  19. package/dist/client/internal/MethodChallenge.js.map +1 -0
  20. package/dist/client/internal/MethodResponse.d.ts +15 -0
  21. package/dist/client/internal/MethodResponse.d.ts.map +1 -1
  22. package/dist/client/internal/MethodResponse.js +10 -0
  23. package/dist/client/internal/MethodResponse.js.map +1 -1
  24. package/dist/server/Mppx.d.ts +23 -4
  25. package/dist/server/Mppx.d.ts.map +1 -1
  26. package/dist/server/Mppx.js +91 -82
  27. package/dist/server/Mppx.js.map +1 -1
  28. package/dist/stripe/server/internal/html.gen.d.ts +1 -1
  29. package/dist/stripe/server/internal/html.gen.d.ts.map +1 -1
  30. package/dist/stripe/server/internal/html.gen.js +1 -1
  31. package/dist/stripe/server/internal/html.gen.js.map +1 -1
  32. package/dist/tempo/client/Methods.d.ts +1 -0
  33. package/dist/tempo/client/Methods.d.ts.map +1 -1
  34. package/dist/tempo/internal/fee-payer.d.ts +1 -0
  35. package/dist/tempo/internal/fee-payer.d.ts.map +1 -1
  36. package/dist/tempo/internal/fee-payer.js +7 -2
  37. package/dist/tempo/internal/fee-payer.js.map +1 -1
  38. package/dist/tempo/legacy/client/ChannelOps.d.ts.map +1 -1
  39. package/dist/tempo/legacy/client/ChannelOps.js +2 -1
  40. package/dist/tempo/legacy/client/ChannelOps.js.map +1 -1
  41. package/dist/tempo/server/Charge.d.ts +46 -77
  42. package/dist/tempo/server/Charge.d.ts.map +1 -1
  43. package/dist/tempo/server/Charge.js +289 -209
  44. package/dist/tempo/server/Charge.js.map +1 -1
  45. package/dist/tempo/server/Methods.d.ts +12 -1
  46. package/dist/tempo/server/Methods.d.ts.map +1 -1
  47. package/dist/tempo/server/Methods.js +4 -0
  48. package/dist/tempo/server/Methods.js.map +1 -1
  49. package/dist/tempo/server/Relay.d.ts +57 -0
  50. package/dist/tempo/server/Relay.d.ts.map +1 -0
  51. package/dist/tempo/server/Relay.js +179 -0
  52. package/dist/tempo/server/Relay.js.map +1 -0
  53. package/dist/tempo/server/SponsorBudget.d.ts +59 -0
  54. package/dist/tempo/server/SponsorBudget.d.ts.map +1 -0
  55. package/dist/tempo/server/SponsorBudget.js +150 -0
  56. package/dist/tempo/server/SponsorBudget.js.map +1 -0
  57. package/dist/tempo/server/internal/html.gen.d.ts +1 -1
  58. package/dist/tempo/server/internal/html.gen.d.ts.map +1 -1
  59. package/dist/tempo/server/internal/html.gen.js +1 -1
  60. package/dist/tempo/server/internal/html.gen.js.map +1 -1
  61. package/dist/tempo/session/client/ChannelOps.d.ts +7 -1
  62. package/dist/tempo/session/client/ChannelOps.d.ts.map +1 -1
  63. package/dist/tempo/session/client/ChannelOps.js +9 -4
  64. package/dist/tempo/session/client/ChannelOps.js.map +1 -1
  65. package/dist/tempo/session/client/CredentialState.d.ts +5 -1
  66. package/dist/tempo/session/client/CredentialState.d.ts.map +1 -1
  67. package/dist/tempo/session/client/CredentialState.js +37 -19
  68. package/dist/tempo/session/client/CredentialState.js.map +1 -1
  69. package/dist/tempo/session/client/Runtime.d.ts +8 -0
  70. package/dist/tempo/session/client/Runtime.d.ts.map +1 -1
  71. package/dist/tempo/session/client/Runtime.js +17 -0
  72. package/dist/tempo/session/client/Runtime.js.map +1 -1
  73. package/dist/tempo/session/client/Session.d.ts +15 -5
  74. package/dist/tempo/session/client/Session.d.ts.map +1 -1
  75. package/dist/tempo/session/client/Session.js +216 -45
  76. package/dist/tempo/session/client/Session.js.map +1 -1
  77. package/dist/tempo/session/client/SessionManager.d.ts +8 -1
  78. package/dist/tempo/session/client/SessionManager.d.ts.map +1 -1
  79. package/dist/tempo/session/client/SessionManager.js +114 -44
  80. package/dist/tempo/session/client/SessionManager.js.map +1 -1
  81. package/dist/tempo/session/client/Transports.d.ts +16 -6
  82. package/dist/tempo/session/client/Transports.d.ts.map +1 -1
  83. package/dist/tempo/session/client/Transports.js +84 -37
  84. package/dist/tempo/session/client/Transports.js.map +1 -1
  85. package/dist/tempo/session/precompile/Chain.d.ts.map +1 -1
  86. package/dist/tempo/session/precompile/Chain.js +96 -8
  87. package/dist/tempo/session/precompile/Chain.js.map +1 -1
  88. package/dist/tempo/session/precompile/Channel.d.ts +2 -0
  89. package/dist/tempo/session/precompile/Channel.d.ts.map +1 -1
  90. package/dist/tempo/session/precompile/Channel.js +4 -0
  91. package/dist/tempo/session/precompile/Channel.js.map +1 -1
  92. package/dist/tempo/session/server/RequestState.js +3 -1
  93. package/dist/tempo/session/server/RequestState.js.map +1 -1
  94. package/dist/tempo/session/server/Ws.d.ts.map +1 -1
  95. package/dist/tempo/session/server/Ws.js +3 -0
  96. package/dist/tempo/session/server/Ws.js.map +1 -1
  97. package/dist/tempo/subscription/KeyAuthorization.d.ts +1 -829
  98. package/dist/tempo/subscription/KeyAuthorization.d.ts.map +1 -1
  99. package/dist/tempo/subscription/KeyAuthorization.js +14 -3
  100. package/dist/tempo/subscription/KeyAuthorization.js.map +1 -1
  101. package/dist/viem/Client.d.ts.map +1 -1
  102. package/dist/viem/Client.js +6 -1
  103. package/dist/viem/Client.js.map +1 -1
  104. package/package.json +2 -2
  105. package/src/Errors.test.ts +23 -0
  106. package/src/Errors.ts +21 -2
  107. package/src/Method.test.ts +102 -1
  108. package/src/Method.ts +241 -5
  109. package/src/cli/validate/payment.ts +18 -4
  110. package/src/cli/validate.test.ts +16 -0
  111. package/src/client/Mppx.test-d.ts +3 -3
  112. package/src/client/internal/Fetch.test.ts +77 -0
  113. package/src/client/internal/Fetch.ts +122 -11
  114. package/src/client/internal/MethodChallenge.ts +35 -0
  115. package/src/client/internal/MethodResponse.ts +28 -0
  116. package/src/server/Mppx.test-d.ts +2 -0
  117. package/src/server/Mppx.test.ts +205 -0
  118. package/src/server/Mppx.ts +167 -103
  119. package/src/stripe/server/internal/html.gen.ts +1 -1
  120. package/src/tempo/PublicExports.test-d.ts +20 -0
  121. package/src/tempo/internal/fee-payer.ts +7 -3
  122. package/src/tempo/legacy/client/ChannelOps.test.ts +7 -0
  123. package/src/tempo/legacy/client/ChannelOps.ts +2 -1
  124. package/src/tempo/server/Charge.test.ts +337 -63
  125. package/src/tempo/server/Charge.ts +443 -267
  126. package/src/tempo/server/Methods.ts +11 -0
  127. package/src/tempo/server/Relay.test.ts +677 -0
  128. package/src/tempo/server/Relay.ts +297 -0
  129. package/src/tempo/server/SponsorBudget.test.ts +154 -0
  130. package/src/tempo/server/SponsorBudget.ts +219 -0
  131. package/src/tempo/server/internal/html.gen.ts +1 -1
  132. package/src/tempo/session/README.md +7 -0
  133. package/src/tempo/session/client/ChannelOps.test.ts +17 -2
  134. package/src/tempo/session/client/ChannelOps.ts +12 -4
  135. package/src/tempo/session/client/CredentialState.ts +61 -20
  136. package/src/tempo/session/client/Runtime.test.ts +12 -0
  137. package/src/tempo/session/client/Runtime.ts +22 -0
  138. package/src/tempo/session/client/Session.test.ts +506 -7
  139. package/src/tempo/session/client/Session.ts +279 -54
  140. package/src/tempo/session/client/SessionManager.test.ts +318 -13
  141. package/src/tempo/session/client/SessionManager.ts +132 -42
  142. package/src/tempo/session/client/Transports.test.ts +54 -1
  143. package/src/tempo/session/client/Transports.ts +137 -57
  144. package/src/tempo/session/precompile/Chain.integration.test.ts +19 -1
  145. package/src/tempo/session/precompile/Chain.test.ts +197 -10
  146. package/src/tempo/session/precompile/Chain.ts +102 -8
  147. package/src/tempo/session/precompile/Channel.test.ts +9 -0
  148. package/src/tempo/session/precompile/Channel.ts +5 -0
  149. package/src/tempo/session/server/RequestState.test.ts +8 -1
  150. package/src/tempo/session/server/RequestState.ts +2 -2
  151. package/src/tempo/session/server/Session.test.ts +66 -4
  152. package/src/tempo/session/server/Ws.ts +3 -0
  153. package/src/tempo/subscription/KeyAuthorization.test.ts +70 -1
  154. package/src/tempo/subscription/KeyAuthorization.ts +14 -3
  155. package/src/viem/Client.test.ts +18 -1
  156. package/src/viem/Client.ts +7 -1
@@ -47,6 +47,17 @@ function testAccount() {
47
47
  return privateKeyToAccount(generatePrivateKey())
48
48
  }
49
49
 
50
+ function sponsoredFeeExposure(credential: string) {
51
+ const payload = Credential.deserialize<{ signature: string; type: string }>(credential).payload
52
+ if (payload.type !== 'transaction') throw new Error('expected transaction credential')
53
+ const transaction = Transaction.deserialize(
54
+ payload.signature as Parameters<typeof Transaction.deserialize>[0],
55
+ )
56
+ if (transaction.gas === undefined || transaction.maxFeePerGas === undefined)
57
+ throw new Error('expected sponsored transaction fee fields')
58
+ return transaction.gas * transaction.maxFeePerGas
59
+ }
60
+
50
61
  // TODO: Remove once the minimum viem version is >=2.54.0, which uses client-first Tempo call builders.
51
62
  function tokenTransferCall(parameters: viem_token.transfer.Args) {
52
63
  const call = Actions.token.transfer.call as unknown as {
@@ -376,17 +387,10 @@ describe('tempo', () => {
376
387
  expect(await dedupStore.get(`tenant:mppx:charge:${receipt.transactionHash}`)).not.toBeNull()
377
388
  expect(await dedupStore.get(`mppx:charge:${receipt.transactionHash}`)).toBeNull()
378
389
 
379
- const response2 = await fetch(httpServer.url)
380
- expect(response2.status).toBe(402)
381
-
382
- const challenge2 = Challenge.fromResponse(response2, {
383
- methods: [tempo_client.charge()],
384
- })
385
-
386
390
  const mixedCaseHash = `0x${receipt.transactionHash.slice(2).toUpperCase()}` as Hex.Hex
387
391
 
388
392
  const credential2 = Credential.from({
389
- challenge: challenge2,
393
+ challenge: challenge1,
390
394
  payload: { hash: mixedCaseHash, type: 'hash' as const },
391
395
  })
392
396
 
@@ -462,15 +466,8 @@ describe('tempo', () => {
462
466
  }
463
467
 
464
468
  // Replay with lowercase — should be rejected
465
- const response2 = await fetch(httpServer.url)
466
- expect(response2.status).toBe(402)
467
-
468
- const challenge2 = Challenge.fromResponse(response2, {
469
- methods: [tempo_client.charge()],
470
- })
471
-
472
469
  const credential2 = Credential.from({
473
- challenge: challenge2,
470
+ challenge: challenge1,
474
471
  payload: { hash: receipt.transactionHash.toLowerCase() as Hex.Hex, type: 'hash' as const },
475
472
  })
476
473
 
@@ -702,17 +699,10 @@ describe('tempo', () => {
702
699
  expect(response.status).toBe(200)
703
700
  }
704
701
 
705
- const response2 = await fetch(httpServer.url)
706
- expect(response2.status).toBe(402)
707
-
708
- const challenge2 = Challenge.fromResponse(response2, {
709
- methods: [tempo_client.charge()],
710
- })
711
-
712
702
  const mixedCaseHash = `0x${receipt.transactionHash.slice(2).toUpperCase()}` as Hex.Hex
713
703
 
714
704
  const credential2 = Credential.from({
715
- challenge: challenge2,
705
+ challenge: challenge1,
716
706
  payload: { hash: mixedCaseHash, type: 'hash' as const },
717
707
  })
718
708
 
@@ -786,15 +776,8 @@ describe('tempo', () => {
786
776
  expect(response.status).toBe(200)
787
777
  }
788
778
 
789
- const response2 = await fetch(httpServer.url)
790
- expect(response2.status).toBe(402)
791
-
792
- const challenge2 = Challenge.fromResponse(response2, {
793
- methods: [tempo_client.charge()],
794
- })
795
-
796
779
  const credential2 = Credential.from({
797
- challenge: challenge2,
780
+ challenge: challenge1,
798
781
  payload: { hash: receipt.transactionHash.toLowerCase() as Hex.Hex, type: 'hash' as const },
799
782
  })
800
783
 
@@ -1131,16 +1114,10 @@ describe('tempo', () => {
1131
1114
  expect(pullAuthResponse.status).toBe(200)
1132
1115
 
1133
1116
  const pullReceipt = Receipt.fromResponse(pullAuthResponse)
1134
-
1135
- const replayChallengeResponse = await fetch(httpServer.url)
1136
- expect(replayChallengeResponse.status).toBe(402)
1137
-
1138
- const replayChallenge = Challenge.fromResponse(replayChallengeResponse, {
1139
- methods: [tempo_client.charge()],
1140
- })
1117
+ const pullCredential = Credential.deserialize(pullCredentialSerialized)
1141
1118
 
1142
1119
  const replayCredential = Credential.from({
1143
- challenge: replayChallenge,
1120
+ challenge: pullCredential.challenge,
1144
1121
  payload: { hash: pullReceipt.reference as Hex.Hex, type: 'hash' as const },
1145
1122
  })
1146
1123
 
@@ -1546,6 +1523,62 @@ describe('tempo', () => {
1546
1523
  httpServer.close()
1547
1524
  })
1548
1525
 
1526
+ test('behavior: validates fee-payer pull credential before settlement', async () => {
1527
+ const chargeServer = Mppx_server.create({
1528
+ methods: [
1529
+ tempo_server.charge({
1530
+ getClient() {
1531
+ return client
1532
+ },
1533
+ currency: asset,
1534
+ account: accounts[0],
1535
+ feePayer: accounts[0],
1536
+ store: Store.memory(),
1537
+ }),
1538
+ ],
1539
+ realm,
1540
+ secretKey,
1541
+ })
1542
+ const mppx = Mppx_client.create({
1543
+ polyfill: false,
1544
+ methods: [
1545
+ tempo_client({
1546
+ account: accounts[1],
1547
+ getClient() {
1548
+ return client
1549
+ },
1550
+ }),
1551
+ ],
1552
+ })
1553
+
1554
+ const httpServer = await Http.createServer(async (req, res) => {
1555
+ const result = await Mppx_server.toNodeListener(
1556
+ chargeServer.charge({
1557
+ amount: '1',
1558
+ currency: asset,
1559
+ recipient: accounts[0].address,
1560
+ }),
1561
+ )(req, res)
1562
+ if (result.status === 402) return
1563
+ res.end('OK')
1564
+ })
1565
+
1566
+ const response = await fetch(httpServer.url)
1567
+ expect(response.status).toBe(402)
1568
+
1569
+ const credential = await mppx.createCredential(response)
1570
+ const validation = await chargeServer.validateCredential(credential)
1571
+
1572
+ expect(validation.details).toMatchObject({
1573
+ mode: 'pull',
1574
+ sender: accounts[1].address.toLowerCase(),
1575
+ })
1576
+
1577
+ const receipt = await chargeServer.verifyCredential(credential)
1578
+ expect(receipt.status).toBe('success')
1579
+ httpServer.close()
1580
+ })
1581
+
1549
1582
  test('behavior: fee payer simulates before broadcasting in confirmation mode', async () => {
1550
1583
  const rpcMethods: string[] = []
1551
1584
  const interceptingClient = createClient({
@@ -1917,26 +1950,26 @@ describe('tempo', () => {
1917
1950
  httpServer.close()
1918
1951
  })
1919
1952
 
1920
- test('behavior: fee payer rejects concurrent in-flight transactions from one sender', async () => {
1921
- let releaseSimulation!: () => void
1922
- let resolveSimulationStarted!: () => void
1923
- const simulationStarted = new Promise<void>((resolve) => {
1924
- resolveSimulationStarted = resolve
1953
+ test('behavior: fee payer allows concurrent expiring-nonce transactions from one sender', async () => {
1954
+ let releaseBroadcast!: () => void
1955
+ let resolveBroadcastStarted!: () => void
1956
+ const broadcastStarted = new Promise<void>((resolve) => {
1957
+ resolveBroadcastStarted = resolve
1925
1958
  })
1926
- const releaseSimulationPromise = new Promise<void>((resolve) => {
1927
- releaseSimulation = resolve
1959
+ const releaseBroadcastPromise = new Promise<void>((resolve) => {
1960
+ releaseBroadcast = resolve
1928
1961
  })
1929
- let heldFirstSimulation = false
1962
+ let heldFirstBroadcast = false
1930
1963
 
1931
1964
  const interceptingClient = createClient({
1932
1965
  account: accounts[0],
1933
1966
  chain: client.chain,
1934
1967
  transport: custom({
1935
1968
  async request(args: any) {
1936
- if (args.method === 'eth_call' && !heldFirstSimulation) {
1937
- heldFirstSimulation = true
1938
- resolveSimulationStarted()
1939
- await releaseSimulationPromise
1969
+ if (args.method === 'eth_sendRawTransactionSync' && !heldFirstBroadcast) {
1970
+ heldFirstBroadcast = true
1971
+ resolveBroadcastStarted()
1972
+ await releaseBroadcastPromise
1940
1973
  }
1941
1974
  return client.transport.request(args)
1942
1975
  },
@@ -1998,30 +2031,206 @@ describe('tempo', () => {
1998
2031
  ])
1999
2032
 
2000
2033
  const first = fetch(httpServer.url, { headers: { Authorization: credential1 } })
2001
- await simulationStarted
2002
- expect(
2003
- await sponsoredStore.get(
2004
- `tenant:mppx:charge:sponsor:${chain.id}:${accounts[1].address.toLowerCase()}`,
2005
- ),
2006
- ).not.toBeNull()
2034
+ await broadcastStarted
2035
+ const sponsorBudgetKey =
2036
+ `mppx:charge:sponsor-budget:${chain.id}:${accounts[0].address.toLowerCase()}` as const
2037
+ expect(await sponsoredStore.get(sponsorBudgetKey)).not.toBeNull()
2007
2038
  expect(
2008
2039
  await sponsoredStore.get(
2009
- `mppx:charge:sponsor:${chain.id}:${accounts[1].address.toLowerCase()}`,
2040
+ `tenant:mppx:charge:sponsor-budget:${chain.id}:${accounts[0].address.toLowerCase()}`,
2010
2041
  ),
2011
2042
  ).toBeNull()
2012
2043
  const second = fetch(httpServer.url, { headers: { Authorization: credential2 } })
2013
2044
 
2014
2045
  try {
2015
2046
  const secondResponse = await second
2016
- expect(secondResponse.status).toBe(402)
2017
- const body = (await secondResponse.json()) as { detail: string }
2018
- expect(body.detail).toContain('Sponsored transaction from this sender is already in flight')
2047
+ expect(secondResponse.status).toBe(200)
2019
2048
  } finally {
2020
- releaseSimulation()
2049
+ releaseBroadcast()
2021
2050
  }
2022
2051
 
2023
2052
  const firstResponse = await first
2024
2053
  expect(firstResponse.status).toBe(200)
2054
+ expect(await sponsoredStore.get(sponsorBudgetKey)).toBeNull()
2055
+
2056
+ httpServer.close()
2057
+ })
2058
+
2059
+ test('behavior: fee payer waits for atomic sponsor budget capacity', async () => {
2060
+ let releaseBroadcast!: () => void
2061
+ let resolveBroadcastStarted!: () => void
2062
+ const broadcastStarted = new Promise<void>((resolve) => {
2063
+ resolveBroadcastStarted = resolve
2064
+ })
2065
+ const releaseBroadcastPromise = new Promise<void>((resolve) => {
2066
+ releaseBroadcast = resolve
2067
+ })
2068
+ let heldFirstBroadcast = false
2069
+
2070
+ const interceptingClient = createClient({
2071
+ account: accounts[0],
2072
+ chain: client.chain,
2073
+ transport: custom({
2074
+ async request(args: any) {
2075
+ if (args.method === 'eth_sendRawTransactionSync' && !heldFirstBroadcast) {
2076
+ heldFirstBroadcast = true
2077
+ resolveBroadcastStarted()
2078
+ await releaseBroadcastPromise
2079
+ }
2080
+ return client.transport.request(args)
2081
+ },
2082
+ }),
2083
+ })
2084
+
2085
+ const feePayerPolicy: { maxInFlightTotalFee?: bigint } = {}
2086
+ const sponsoredStore = Store.memory()
2087
+ const serverWithBudget = Mppx_server.create({
2088
+ methods: [
2089
+ tempo_server.charge({
2090
+ feePayerPolicy,
2091
+ getClient() {
2092
+ return interceptingClient
2093
+ },
2094
+ currency: asset,
2095
+ account: accounts[0],
2096
+ store: sponsoredStore,
2097
+ }),
2098
+ ],
2099
+ realm,
2100
+ secretKey,
2101
+ })
2102
+
2103
+ const mppx1 = Mppx_client.create({
2104
+ polyfill: false,
2105
+ methods: [
2106
+ tempo_client({
2107
+ account: accounts[1],
2108
+ getClient() {
2109
+ return client
2110
+ },
2111
+ }),
2112
+ ],
2113
+ })
2114
+ const mppx2 = Mppx_client.create({
2115
+ polyfill: false,
2116
+ methods: [
2117
+ tempo_client({
2118
+ account: accounts[2],
2119
+ getClient() {
2120
+ return client
2121
+ },
2122
+ }),
2123
+ ],
2124
+ })
2125
+
2126
+ const httpServer = await Http.createServer(async (req, res) => {
2127
+ const result = await Mppx_server.toNodeListener(
2128
+ serverWithBudget.charge({
2129
+ feePayer: accounts[0],
2130
+ amount: '1',
2131
+ currency: asset,
2132
+ recipient: accounts[0].address,
2133
+ }),
2134
+ )(req, res)
2135
+ if (result.status === 402) return
2136
+ res.end('OK')
2137
+ })
2138
+
2139
+ const [challengeResponse1, challengeResponse2] = await Promise.all([
2140
+ fetch(httpServer.url),
2141
+ fetch(httpServer.url),
2142
+ ])
2143
+ const [credential1, credential2] = await Promise.all([
2144
+ mppx1.createCredential(challengeResponse1),
2145
+ mppx2.createCredential(challengeResponse2),
2146
+ ])
2147
+ feePayerPolicy.maxInFlightTotalFee = [
2148
+ sponsoredFeeExposure(credential1),
2149
+ sponsoredFeeExposure(credential2),
2150
+ ].reduce((maximum, exposure) => (exposure > maximum ? exposure : maximum))
2151
+
2152
+ const first = fetch(httpServer.url, { headers: { Authorization: credential1 } })
2153
+ await broadcastStarted
2154
+ const second = fetch(httpServer.url, { headers: { Authorization: credential2 } })
2155
+ try {
2156
+ const secondBeforeRelease = await Promise.race([
2157
+ second.then(() => 'settled' as const),
2158
+ new Promise<'waiting'>((resolve) => setTimeout(() => resolve('waiting'), 50)),
2159
+ ])
2160
+ expect(secondBeforeRelease).toBe('waiting')
2161
+ } finally {
2162
+ releaseBroadcast()
2163
+ }
2164
+ const [firstResponse, secondResponse] = await Promise.all([first, second])
2165
+ expect(firstResponse.status).toBe(200)
2166
+ expect(secondResponse.status).toBe(200)
2167
+ expect(
2168
+ await sponsoredStore.get(
2169
+ `mppx:charge:sponsor-budget:${chain.id}:${accounts[0].address.toLowerCase()}`,
2170
+ ),
2171
+ ).toBeNull()
2172
+
2173
+ httpServer.close()
2174
+ })
2175
+
2176
+ test('behavior: fee payer reclaims expired sponsor budget reservations', async () => {
2177
+ const sponsoredStore = Store.memory()
2178
+ const sponsorBudgetKey =
2179
+ `mppx:charge:sponsor-budget:${chain.id}:${accounts[0].address.toLowerCase()}` as const
2180
+ await sponsoredStore.put(sponsorBudgetKey, {
2181
+ reservations: {
2182
+ stale: {
2183
+ expiresAt: Date.now() - 1,
2184
+ fee: '500000000000000000',
2185
+ leaseUntil: Date.now() - 1,
2186
+ owner: 'stale-worker',
2187
+ phase: 'pending',
2188
+ transactionHash: `0x${'01'.repeat(32)}`,
2189
+ },
2190
+ },
2191
+ version: 1,
2192
+ })
2193
+ const serverWithStaleBudget = Mppx_server.create({
2194
+ methods: [
2195
+ tempo_server.charge({
2196
+ getClient() {
2197
+ return client
2198
+ },
2199
+ currency: asset,
2200
+ account: accounts[0],
2201
+ store: sponsoredStore,
2202
+ }),
2203
+ ],
2204
+ realm,
2205
+ secretKey,
2206
+ })
2207
+ const mppx = Mppx_client.create({
2208
+ polyfill: false,
2209
+ methods: [
2210
+ tempo_client({
2211
+ account: accounts[1],
2212
+ getClient() {
2213
+ return client
2214
+ },
2215
+ }),
2216
+ ],
2217
+ })
2218
+ const httpServer = await Http.createServer(async (req, res) => {
2219
+ const result = await Mppx_server.toNodeListener(
2220
+ serverWithStaleBudget.charge({
2221
+ feePayer: accounts[0],
2222
+ amount: '1',
2223
+ currency: asset,
2224
+ recipient: accounts[0].address,
2225
+ }),
2226
+ )(req, res)
2227
+ if (result.status === 402) return
2228
+ res.end('OK')
2229
+ })
2230
+
2231
+ const response = await mppx.fetch(httpServer.url)
2232
+ expect(response.status).toBe(200)
2233
+ expect(await sponsoredStore.get(sponsorBudgetKey)).toBeNull()
2025
2234
 
2026
2235
  httpServer.close()
2027
2236
  })
@@ -2782,6 +2991,71 @@ describe('tempo', () => {
2782
2991
 
2783
2992
  httpServer.close()
2784
2993
  })
2994
+
2995
+ test('retains sponsored exposure until receipt reconciliation', async () => {
2996
+ const sponsoredStore = Store.memory()
2997
+ const serverNoWait = Mppx_server.create({
2998
+ methods: [
2999
+ tempo_server.charge({
3000
+ getClient() {
3001
+ return client
3002
+ },
3003
+ currency: asset,
3004
+ account: accounts[0],
3005
+ store: sponsoredStore,
3006
+ waitForConfirmation: false,
3007
+ }),
3008
+ ],
3009
+ realm,
3010
+ secretKey,
3011
+ })
3012
+ const mppx = Mppx_client.create({
3013
+ polyfill: false,
3014
+ methods: [
3015
+ tempo_client({
3016
+ account: accounts[1],
3017
+ getClient() {
3018
+ return client
3019
+ },
3020
+ }),
3021
+ ],
3022
+ })
3023
+ const httpServer = await Http.createServer(async (req, res) => {
3024
+ const result = await Mppx_server.toNodeListener(
3025
+ serverNoWait.charge({
3026
+ feePayer: accounts[0],
3027
+ amount: '1',
3028
+ currency: asset,
3029
+ recipient: accounts[0].address,
3030
+ }),
3031
+ )(req, res)
3032
+ if (result.status === 402) return
3033
+ res.end('OK')
3034
+ })
3035
+
3036
+ const challengeResponse = await fetch(httpServer.url)
3037
+ const credential = await mppx.createCredential(challengeResponse)
3038
+ const response = await fetch(httpServer.url, {
3039
+ headers: { Authorization: credential },
3040
+ })
3041
+
3042
+ expect(response.status).toBe(200)
3043
+ const receipt = Receipt.fromResponse(response)
3044
+ const sponsorBudgetKey =
3045
+ `mppx:charge:sponsor-budget:${chain.id}:${accounts[0].address.toLowerCase()}` as const
3046
+ expect(await sponsoredStore.get(sponsorBudgetKey)).toMatchObject({
3047
+ reservations: {
3048
+ [receipt.reference]: {
3049
+ owner: expect.any(String),
3050
+ phase: 'pending',
3051
+ transactionHash: receipt.reference,
3052
+ },
3053
+ },
3054
+ version: 1,
3055
+ })
3056
+
3057
+ httpServer.close()
3058
+ })
2785
3059
  })
2786
3060
 
2787
3061
  describe('intent: charge; type: proof (zero-dollar auth)', () => {