mppx 0.3.9 → 0.3.12

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 (95) hide show
  1. package/README.md +3 -3
  2. package/dist/Challenge.d.ts.map +1 -1
  3. package/dist/Challenge.js +2 -0
  4. package/dist/Challenge.js.map +1 -1
  5. package/dist/Errors.d.ts +0 -2
  6. package/dist/Errors.d.ts.map +1 -1
  7. package/dist/Errors.js +1 -3
  8. package/dist/Errors.js.map +1 -1
  9. package/dist/client/Mppx.d.ts +1 -1
  10. package/dist/client/Mppx.d.ts.map +1 -1
  11. package/dist/client/internal/Fetch.d.ts +1 -1
  12. package/dist/client/internal/Fetch.d.ts.map +1 -1
  13. package/dist/client/internal/Fetch.js +23 -4
  14. package/dist/client/internal/Fetch.js.map +1 -1
  15. package/dist/internal/constantTimeEqual.d.ts.map +1 -1
  16. package/dist/internal/constantTimeEqual.js +4 -6
  17. package/dist/internal/constantTimeEqual.js.map +1 -1
  18. package/dist/internal/env.d.ts +2 -2
  19. package/dist/internal/env.d.ts.map +1 -1
  20. package/dist/internal/env.js +1 -2
  21. package/dist/internal/env.js.map +1 -1
  22. package/dist/middlewares/internal/mppx.d.ts.map +1 -1
  23. package/dist/middlewares/internal/mppx.js +6 -2
  24. package/dist/middlewares/internal/mppx.js.map +1 -1
  25. package/dist/server/Mppx.d.ts +13 -3
  26. package/dist/server/Mppx.d.ts.map +1 -1
  27. package/dist/server/Mppx.js +46 -3
  28. package/dist/server/Mppx.js.map +1 -1
  29. package/dist/tempo/client/Charge.d.ts +10 -0
  30. package/dist/tempo/client/Charge.d.ts.map +1 -1
  31. package/dist/tempo/client/Charge.js +23 -9
  32. package/dist/tempo/client/Charge.js.map +1 -1
  33. package/dist/tempo/client/Methods.d.ts +1 -0
  34. package/dist/tempo/client/Methods.d.ts.map +1 -1
  35. package/dist/tempo/internal/auto-swap.d.ts +49 -0
  36. package/dist/tempo/internal/auto-swap.d.ts.map +1 -0
  37. package/dist/tempo/internal/auto-swap.js +89 -0
  38. package/dist/tempo/internal/auto-swap.js.map +1 -0
  39. package/dist/tempo/internal/fee-payer.d.ts +15 -0
  40. package/dist/tempo/internal/fee-payer.d.ts.map +1 -0
  41. package/dist/tempo/internal/fee-payer.js +41 -0
  42. package/dist/tempo/internal/fee-payer.js.map +1 -0
  43. package/dist/tempo/internal/selectors.d.ts +5 -0
  44. package/dist/tempo/internal/selectors.d.ts.map +1 -0
  45. package/dist/tempo/internal/selectors.js +7 -0
  46. package/dist/tempo/internal/selectors.js.map +1 -0
  47. package/dist/tempo/internal/simulate.d.ts +21 -0
  48. package/dist/tempo/internal/simulate.d.ts.map +1 -0
  49. package/dist/tempo/internal/simulate.js +31 -0
  50. package/dist/tempo/internal/simulate.js.map +1 -0
  51. package/dist/tempo/server/Charge.d.ts +12 -0
  52. package/dist/tempo/server/Charge.d.ts.map +1 -1
  53. package/dist/tempo/server/Charge.js +36 -12
  54. package/dist/tempo/server/Charge.js.map +1 -1
  55. package/dist/tempo/server/Session.d.ts +14 -0
  56. package/dist/tempo/server/Session.d.ts.map +1 -1
  57. package/dist/tempo/server/Session.js +59 -40
  58. package/dist/tempo/server/Session.js.map +1 -1
  59. package/dist/tempo/session/Chain.d.ts +3 -0
  60. package/dist/tempo/session/Chain.d.ts.map +1 -1
  61. package/dist/tempo/session/Chain.js +27 -6
  62. package/dist/tempo/session/Chain.js.map +1 -1
  63. package/package.json +1 -1
  64. package/src/Challenge.ts +2 -0
  65. package/src/Errors.test.ts +43 -18
  66. package/src/Errors.ts +1 -4
  67. package/src/client/Mppx.test-d.ts +28 -0
  68. package/src/client/Mppx.test.ts +1 -0
  69. package/src/client/Mppx.ts +3 -3
  70. package/src/client/internal/Fetch.test.ts +410 -0
  71. package/src/client/internal/Fetch.ts +25 -7
  72. package/src/internal/constantTimeEqual.ts +5 -4
  73. package/src/internal/env.test.ts +2 -2
  74. package/src/internal/env.ts +4 -5
  75. package/src/middlewares/express.test.ts +5 -0
  76. package/src/middlewares/hono.test.ts +5 -0
  77. package/src/middlewares/internal/mppx.ts +5 -2
  78. package/src/middlewares/nextjs.test.ts +5 -0
  79. package/src/proxy/Proxy.test.ts +3 -0
  80. package/src/proxy/services/openai.test.ts +3 -0
  81. package/src/server/Mppx.test.ts +93 -2
  82. package/src/server/Mppx.ts +81 -6
  83. package/src/tempo/client/Charge.ts +40 -9
  84. package/src/tempo/internal/auto-swap.test.ts +113 -0
  85. package/src/tempo/internal/auto-swap.ts +141 -0
  86. package/src/tempo/internal/fee-payer.test.ts +223 -0
  87. package/src/tempo/internal/fee-payer.ts +53 -0
  88. package/src/tempo/internal/selectors.ts +10 -0
  89. package/src/tempo/internal/simulate.ts +49 -0
  90. package/src/tempo/server/Charge.test.ts +436 -3
  91. package/src/tempo/server/Charge.ts +52 -23
  92. package/src/tempo/server/Session.test.ts +49 -0
  93. package/src/tempo/server/Session.ts +76 -34
  94. package/src/tempo/session/Chain.test.ts +36 -0
  95. package/src/tempo/session/Chain.ts +38 -2
@@ -53,7 +53,7 @@ describe('request handler', () => {
53
53
  }).toMatchInlineSnapshot(`
54
54
  {
55
55
  "challengeId": "[challengeId]",
56
- "detail": "Payment is required for "api.example.com".",
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 for "api.example.com".",
309
+ "detail": "Payment is required.",
219
310
  "instance": "[instance]",
220
311
  "status": 402,
221
312
  "title": "Payment Required",
@@ -41,17 +41,43 @@ type EffectiveTransportOf<mi, defaultTransport extends Transport.AnyTransport> =
41
41
  ? defaultTransport
42
42
  : TransportOverrideOf<mi>
43
43
 
44
- type Handlers<
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']]: MethodFn<
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
- handlers[mi.intent] = createMethodFn({
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, falling back to a random key. */
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({ realm, description }),
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)
@@ -1,4 +1,5 @@
1
1
  import type * as Hex from 'ox/Hex'
2
+ import type { Address } from 'viem'
2
3
  import { prepareTransactionRequest, signTransaction } from 'viem/actions'
3
4
  import { tempo as tempo_chain } from 'viem/chains'
4
5
  import { Actions } from 'viem/tempo'
@@ -8,6 +9,7 @@ import * as Account from '../../viem/Account.js'
8
9
  import * as Client from '../../viem/Client.js'
9
10
  import * as z from '../../zod.js'
10
11
  import * as Attribution from '../Attribution.js'
12
+ import * as AutoSwap from '../internal/auto-swap.js'
11
13
  import * as defaults from '../internal/defaults.js'
12
14
  import * as Methods from '../Methods.js'
13
15
 
@@ -36,6 +38,7 @@ export function charge(parameters: charge.Parameters = {}) {
36
38
  return Method.toClient(Methods.charge, {
37
39
  context: z.object({
38
40
  account: z.optional(z.custom<Account.getResolver.Parameters['account']>()),
41
+ autoSwap: z.optional(z.custom<charge.AutoSwap>()),
39
42
  }),
40
43
 
41
44
  async createCredential({ challenge, context }) {
@@ -44,22 +47,41 @@ export function charge(parameters: charge.Parameters = {}) {
44
47
  const account = getAccount(client, context)
45
48
 
46
49
  const { request } = challenge
47
- const { amount, currency, recipient, methodDetails } = request
50
+ const { amount, methodDetails } = request
51
+ const currency = request.currency as Address
52
+ const recipient = request.recipient as Address
48
53
 
49
54
  const memo = methodDetails?.memo
50
55
  ? (methodDetails.memo as Hex.Hex)
51
56
  : Attribution.encode({ serverId: challenge.realm, clientId })
52
57
 
58
+ const transferCall = Actions.token.transfer.call({
59
+ amount: BigInt(amount),
60
+ memo,
61
+ to: recipient,
62
+ token: currency,
63
+ })
64
+
65
+ const autoSwap = AutoSwap.resolve(
66
+ context?.autoSwap ?? parameters.autoSwap,
67
+ AutoSwap.defaultCurrencies,
68
+ )
69
+
70
+ const swapCalls = autoSwap
71
+ ? await AutoSwap.findCalls(client, {
72
+ account: account.address,
73
+ amountOut: BigInt(amount),
74
+ tokenOut: currency,
75
+ tokenIn: autoSwap.tokenIn,
76
+ slippage: autoSwap.slippage,
77
+ })
78
+ : undefined
79
+
80
+ const calls = [...(swapCalls ?? []), transferCall]
81
+
53
82
  const prepared = await prepareTransactionRequest(client, {
54
83
  account,
55
- calls: [
56
- Actions.token.transfer.call({
57
- amount: BigInt(amount),
58
- memo,
59
- to: recipient as Hex.Hex,
60
- token: currency as Hex.Hex,
61
- }),
62
- ],
84
+ calls,
63
85
  ...(methodDetails?.feePayer && { feePayer: true }),
64
86
  nonceKey: 'expiring',
65
87
  } as never)
@@ -77,7 +99,16 @@ export function charge(parameters: charge.Parameters = {}) {
77
99
  }
78
100
 
79
101
  export declare namespace charge {
102
+ type AutoSwap = AutoSwap.resolve.Value
103
+
80
104
  type Parameters = {
105
+ /**
106
+ * Automatically swap from a fallback currency (pathUsd, USDC.e) via the
107
+ * Tempo DEX when the user lacks sufficient balance of the target currency.
108
+ *
109
+ * @default false
110
+ */
111
+ autoSwap?: AutoSwap | undefined
81
112
  /** Client identifier used to derive the client fingerprint in attribution memos. */
82
113
  clientId?: string | undefined
83
114
  } & Account.getResolver.Parameters &
@@ -0,0 +1,113 @@
1
+ import type { Address } from 'viem'
2
+ import { describe, expect, test } from 'vitest'
3
+ import { defaultCurrencies, InsufficientFundsError, resolve } from './auto-swap.js'
4
+
5
+ describe('defaultCurrencies', () => {
6
+ test('default', () => {
7
+ expect(defaultCurrencies).toMatchInlineSnapshot(`
8
+ [
9
+ "0x20c0000000000000000000000000000000000000",
10
+ "0x20C000000000000000000000b9537d11c60E8b50",
11
+ ]
12
+ `)
13
+ })
14
+ })
15
+
16
+ describe('resolve', () => {
17
+ const defaults = defaultCurrencies
18
+
19
+ test('returns false for undefined', () => {
20
+ expect(resolve(undefined, defaults)).toMatchInlineSnapshot(`false`)
21
+ })
22
+
23
+ test('returns false for false', () => {
24
+ expect(resolve(false, defaults)).toMatchInlineSnapshot(`false`)
25
+ })
26
+
27
+ test('true resolves to defaults with 1% slippage', () => {
28
+ expect(resolve(true, defaults)).toMatchInlineSnapshot(`
29
+ {
30
+ "slippage": 1,
31
+ "tokenIn": [
32
+ "0x20c0000000000000000000000000000000000000",
33
+ "0x20C000000000000000000000b9537d11c60E8b50",
34
+ ],
35
+ }
36
+ `)
37
+ })
38
+
39
+ test('empty options resolves to defaults with 1% slippage', () => {
40
+ expect(resolve({}, defaults)).toMatchInlineSnapshot(`
41
+ {
42
+ "slippage": 1,
43
+ "tokenIn": [
44
+ "0x20c0000000000000000000000000000000000000",
45
+ "0x20C000000000000000000000b9537d11c60E8b50",
46
+ ],
47
+ }
48
+ `)
49
+ })
50
+
51
+ test('custom slippage', () => {
52
+ expect(resolve({ slippage: 5 }, defaults)).toMatchInlineSnapshot(`
53
+ {
54
+ "slippage": 5,
55
+ "tokenIn": [
56
+ "0x20c0000000000000000000000000000000000000",
57
+ "0x20C000000000000000000000b9537d11c60E8b50",
58
+ ],
59
+ }
60
+ `)
61
+ })
62
+
63
+ test('custom tokenIn prepends to defaults', () => {
64
+ const custom = '0x0000000000000000000000000000000000000099' as Address
65
+ expect(resolve({ tokenIn: [custom] }, defaults)).toMatchInlineSnapshot(`
66
+ {
67
+ "slippage": 1,
68
+ "tokenIn": [
69
+ "0x0000000000000000000000000000000000000099",
70
+ "0x20c0000000000000000000000000000000000000",
71
+ "0x20C000000000000000000000b9537d11c60E8b50",
72
+ ],
73
+ }
74
+ `)
75
+ })
76
+
77
+ test('custom tokenIn deduplicates against defaults', () => {
78
+ expect(resolve({ tokenIn: [defaults[0]!] }, defaults)).toMatchInlineSnapshot(`
79
+ {
80
+ "slippage": 1,
81
+ "tokenIn": [
82
+ "0x20c0000000000000000000000000000000000000",
83
+ "0x20C000000000000000000000b9537d11c60E8b50",
84
+ ],
85
+ }
86
+ `)
87
+ })
88
+
89
+ test('custom tokenIn + custom slippage', () => {
90
+ const custom = '0x0000000000000000000000000000000000000099' as Address
91
+ expect(resolve({ tokenIn: [custom], slippage: 3 }, defaults)).toMatchInlineSnapshot(`
92
+ {
93
+ "slippage": 3,
94
+ "tokenIn": [
95
+ "0x0000000000000000000000000000000000000099",
96
+ "0x20c0000000000000000000000000000000000000",
97
+ "0x20C000000000000000000000b9537d11c60E8b50",
98
+ ],
99
+ }
100
+ `)
101
+ })
102
+ })
103
+
104
+ describe('InsufficientFundsError', () => {
105
+ test('default', () => {
106
+ const error = new InsufficientFundsError({
107
+ currency: '0x0000000000000000000000000000000000000001',
108
+ })
109
+ expect(error).toMatchInlineSnapshot(
110
+ `[InsufficientFundsError: Insufficient funds: no balance in 0x0000000000000000000000000000000000000001 and no viable swap route from fallback currencies.]`,
111
+ )
112
+ })
113
+ })
@@ -0,0 +1,141 @@
1
+ import type { Address, Client } from 'viem'
2
+ import { isAddressEqual } from 'viem'
3
+ import { readContract } from 'viem/actions'
4
+ import { Actions, Addresses } from 'viem/tempo'
5
+ import * as defaults from './defaults.js'
6
+
7
+ /** Basis-point denominator (100% = 10 000 bps). */
8
+ const bps = 10_000n
9
+
10
+ /** Default fallback currencies for auto-swap, in priority order. */
11
+ export const defaultCurrencies: readonly Address[] = [
12
+ defaults.tokens.pathUsd as Address,
13
+ defaults.tokens.usdc as Address,
14
+ ]
15
+
16
+ /**
17
+ * Finds the optimal swap calls to acquire `amountOut` of `tokenOut`,
18
+ * returning an approve + buy call sequence if a viable route is found.
19
+ *
20
+ * Returns `undefined` if the account already holds enough of `tokenOut`
21
+ * or no viable swap route exists from the given input tokens.
22
+ */
23
+ export async function findCalls(
24
+ client: Client,
25
+ parameters: findCalls.Parameters,
26
+ ): Promise<findCalls.ReturnType> {
27
+ const { account, amountOut, tokenOut, tokenIn, slippage } = parameters
28
+
29
+ const candidates = tokenIn.filter((t) => !isAddressEqual(t, tokenOut))
30
+
31
+ const balanceResults = await Promise.allSettled([
32
+ readContract(client, Actions.token.getBalance.call({ account, token: tokenOut }) as never),
33
+ ...candidates.map((t) =>
34
+ readContract(client, Actions.token.getBalance.call({ account, token: t }) as never),
35
+ ),
36
+ ])
37
+
38
+ // If the account already has enough of the target token, no swap needed.
39
+ const targetBalance = balanceResults[0]!
40
+ if (targetBalance.status === 'fulfilled' && (targetBalance.value as bigint) >= amountOut)
41
+ return undefined
42
+
43
+ // Find first candidate with enough balance to cover a swap.
44
+ for (let i = 0; i < candidates.length; i++) {
45
+ const result = balanceResults[i + 1]!
46
+ if (result.status !== 'fulfilled') continue
47
+
48
+ const balance = result.value as bigint
49
+ if (balance <= 0n) continue
50
+
51
+ const tokenIn = candidates[i]!
52
+
53
+ try {
54
+ const quotedAmountIn = await Actions.dex.getBuyQuote(client as never, {
55
+ tokenIn,
56
+ tokenOut,
57
+ amountOut,
58
+ })
59
+
60
+ if (balance >= quotedAmountIn) {
61
+ const maxAmountIn =
62
+ quotedAmountIn + (quotedAmountIn * BigInt(Math.round(slippage * 100))) / bps
63
+ return [
64
+ Actions.token.approve.call({
65
+ token: tokenIn,
66
+ spender: Addresses.stablecoinDex,
67
+ amount: maxAmountIn,
68
+ }),
69
+ Actions.dex.buy.call({
70
+ tokenIn,
71
+ tokenOut,
72
+ amountOut,
73
+ maxAmountIn,
74
+ }),
75
+ ]
76
+ }
77
+ } catch {}
78
+ }
79
+
80
+ throw new InsufficientFundsError({ currency: tokenOut })
81
+ }
82
+
83
+ export declare namespace findCalls {
84
+ type Parameters = {
85
+ /** Address of the account to check balances for. */
86
+ account: Address
87
+ /** Amount of the target token needed. */
88
+ amountOut: bigint
89
+ /** Candidate input tokens to swap from, in priority order. */
90
+ tokenIn: readonly Address[]
91
+ /** Max slippage tolerance as a percentage (e.g. `1` = 1%). */
92
+ slippage: number
93
+ /** Address of the target token to acquire. */
94
+ tokenOut: Address
95
+ }
96
+
97
+ /** `undefined` when no swap is needed (account has sufficient balance). */
98
+ type ReturnType = readonly object[] | undefined
99
+ }
100
+
101
+ /** Resolves an auto-swap configuration value into concrete currencies and slippage. */
102
+ export function resolve(
103
+ value: resolve.Value | undefined,
104
+ defaultCurrencies: readonly Address[],
105
+ ): resolve.Resolved | false {
106
+ if (!value) return false
107
+ if (value === true) return { tokenIn: defaultCurrencies, slippage: 1 }
108
+ const tokenIn = value.tokenIn
109
+ ? [
110
+ ...value.tokenIn,
111
+ ...defaultCurrencies.filter((d) => !value.tokenIn!.some((c) => isAddressEqual(c, d))),
112
+ ]
113
+ : defaultCurrencies
114
+ return {
115
+ tokenIn,
116
+ slippage: value.slippage ?? 1,
117
+ }
118
+ }
119
+
120
+ export declare namespace resolve {
121
+ type Options = {
122
+ /** Fallback tokens to try swapping from, in priority order. */
123
+ tokenIn?: Address[] | undefined
124
+ /** Max slippage tolerance as a percentage (e.g. `1` = 1%). @default 1 */
125
+ slippage?: number | undefined
126
+ }
127
+
128
+ type Value = boolean | Options
129
+
130
+ type Resolved = { tokenIn: readonly Address[]; slippage: number }
131
+ }
132
+
133
+ export class InsufficientFundsError extends Error {
134
+ override readonly name = 'InsufficientFundsError'
135
+
136
+ constructor({ currency }: { currency: Address }) {
137
+ super(
138
+ `Insufficient funds: no balance in ${currency} and no viable swap route from fallback currencies.`,
139
+ )
140
+ }
141
+ }