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.
- package/README.md +3 -3
- package/dist/Challenge.d.ts.map +1 -1
- package/dist/Challenge.js +2 -0
- package/dist/Challenge.js.map +1 -1
- package/dist/Errors.d.ts +0 -2
- package/dist/Errors.d.ts.map +1 -1
- package/dist/Errors.js +1 -3
- package/dist/Errors.js.map +1 -1
- package/dist/client/Mppx.d.ts +1 -1
- package/dist/client/Mppx.d.ts.map +1 -1
- package/dist/client/internal/Fetch.d.ts +1 -1
- package/dist/client/internal/Fetch.d.ts.map +1 -1
- package/dist/client/internal/Fetch.js +23 -4
- package/dist/client/internal/Fetch.js.map +1 -1
- package/dist/internal/constantTimeEqual.d.ts.map +1 -1
- package/dist/internal/constantTimeEqual.js +4 -6
- package/dist/internal/constantTimeEqual.js.map +1 -1
- package/dist/internal/env.d.ts +2 -2
- package/dist/internal/env.d.ts.map +1 -1
- package/dist/internal/env.js +1 -2
- package/dist/internal/env.js.map +1 -1
- package/dist/middlewares/internal/mppx.d.ts.map +1 -1
- package/dist/middlewares/internal/mppx.js +6 -2
- package/dist/middlewares/internal/mppx.js.map +1 -1
- package/dist/server/Mppx.d.ts +13 -3
- package/dist/server/Mppx.d.ts.map +1 -1
- package/dist/server/Mppx.js +46 -3
- package/dist/server/Mppx.js.map +1 -1
- package/dist/tempo/client/Charge.d.ts +10 -0
- package/dist/tempo/client/Charge.d.ts.map +1 -1
- package/dist/tempo/client/Charge.js +23 -9
- package/dist/tempo/client/Charge.js.map +1 -1
- package/dist/tempo/client/Methods.d.ts +1 -0
- package/dist/tempo/client/Methods.d.ts.map +1 -1
- package/dist/tempo/internal/auto-swap.d.ts +49 -0
- package/dist/tempo/internal/auto-swap.d.ts.map +1 -0
- package/dist/tempo/internal/auto-swap.js +89 -0
- package/dist/tempo/internal/auto-swap.js.map +1 -0
- package/dist/tempo/internal/fee-payer.d.ts +15 -0
- package/dist/tempo/internal/fee-payer.d.ts.map +1 -0
- package/dist/tempo/internal/fee-payer.js +41 -0
- package/dist/tempo/internal/fee-payer.js.map +1 -0
- package/dist/tempo/internal/selectors.d.ts +5 -0
- package/dist/tempo/internal/selectors.d.ts.map +1 -0
- package/dist/tempo/internal/selectors.js +7 -0
- package/dist/tempo/internal/selectors.js.map +1 -0
- package/dist/tempo/internal/simulate.d.ts +21 -0
- package/dist/tempo/internal/simulate.d.ts.map +1 -0
- package/dist/tempo/internal/simulate.js +31 -0
- package/dist/tempo/internal/simulate.js.map +1 -0
- package/dist/tempo/server/Charge.d.ts +12 -0
- package/dist/tempo/server/Charge.d.ts.map +1 -1
- package/dist/tempo/server/Charge.js +36 -12
- package/dist/tempo/server/Charge.js.map +1 -1
- package/dist/tempo/server/Session.d.ts +14 -0
- package/dist/tempo/server/Session.d.ts.map +1 -1
- package/dist/tempo/server/Session.js +59 -40
- package/dist/tempo/server/Session.js.map +1 -1
- package/dist/tempo/session/Chain.d.ts +3 -0
- package/dist/tempo/session/Chain.d.ts.map +1 -1
- package/dist/tempo/session/Chain.js +27 -6
- package/dist/tempo/session/Chain.js.map +1 -1
- package/package.json +1 -1
- package/src/Challenge.ts +2 -0
- package/src/Errors.test.ts +43 -18
- package/src/Errors.ts +1 -4
- package/src/client/Mppx.test-d.ts +28 -0
- package/src/client/Mppx.test.ts +1 -0
- package/src/client/Mppx.ts +3 -3
- package/src/client/internal/Fetch.test.ts +410 -0
- package/src/client/internal/Fetch.ts +25 -7
- package/src/internal/constantTimeEqual.ts +5 -4
- package/src/internal/env.test.ts +2 -2
- package/src/internal/env.ts +4 -5
- package/src/middlewares/express.test.ts +5 -0
- package/src/middlewares/hono.test.ts +5 -0
- package/src/middlewares/internal/mppx.ts +5 -2
- package/src/middlewares/nextjs.test.ts +5 -0
- package/src/proxy/Proxy.test.ts +3 -0
- package/src/proxy/services/openai.test.ts +3 -0
- package/src/server/Mppx.test.ts +93 -2
- package/src/server/Mppx.ts +81 -6
- package/src/tempo/client/Charge.ts +40 -9
- package/src/tempo/internal/auto-swap.test.ts +113 -0
- package/src/tempo/internal/auto-swap.ts +141 -0
- package/src/tempo/internal/fee-payer.test.ts +223 -0
- package/src/tempo/internal/fee-payer.ts +53 -0
- package/src/tempo/internal/selectors.ts +10 -0
- package/src/tempo/internal/simulate.ts +49 -0
- package/src/tempo/server/Charge.test.ts +436 -3
- package/src/tempo/server/Charge.ts +52 -23
- package/src/tempo/server/Session.test.ts +49 -0
- package/src/tempo/server/Session.ts +76 -34
- package/src/tempo/session/Chain.test.ts +36 -0
- package/src/tempo/session/Chain.ts +38 -2
package/src/server/Mppx.test.ts
CHANGED
|
@@ -53,7 +53,7 @@ describe('request handler', () => {
|
|
|
53
53
|
}).toMatchInlineSnapshot(`
|
|
54
54
|
{
|
|
55
55
|
"challengeId": "[challengeId]",
|
|
56
|
-
"detail": "Payment is required
|
|
56
|
+
"detail": "Payment is required.",
|
|
57
57
|
"instance": "[instance]",
|
|
58
58
|
"status": 402,
|
|
59
59
|
"title": "Payment Required",
|
|
@@ -138,6 +138,97 @@ describe('request handler', () => {
|
|
|
138
138
|
`)
|
|
139
139
|
})
|
|
140
140
|
|
|
141
|
+
test('returns 402 when credential is from a different route (cross-route scope confusion)', async () => {
|
|
142
|
+
const handler = Mppx.create({ methods: [method], realm, secretKey })
|
|
143
|
+
|
|
144
|
+
// Get a challenge from the "cheap" route
|
|
145
|
+
const cheapHandle = handler.charge({
|
|
146
|
+
amount: '1',
|
|
147
|
+
currency: asset,
|
|
148
|
+
expires: new Date(Date.now() + 60_000).toISOString(),
|
|
149
|
+
recipient: accounts[0].address,
|
|
150
|
+
})
|
|
151
|
+
const cheapResult = await cheapHandle(new Request('https://example.com/cheap'))
|
|
152
|
+
expect(cheapResult.status).toBe(402)
|
|
153
|
+
if (cheapResult.status !== 402) throw new Error()
|
|
154
|
+
|
|
155
|
+
const cheapChallenge = Challenge.fromResponse(cheapResult.challenge)
|
|
156
|
+
|
|
157
|
+
// Build a credential from the cheap challenge
|
|
158
|
+
const credential = Credential.from({
|
|
159
|
+
challenge: cheapChallenge,
|
|
160
|
+
payload: { signature: '0x123', type: 'transaction' },
|
|
161
|
+
})
|
|
162
|
+
|
|
163
|
+
// Present it at the "expensive" route
|
|
164
|
+
const expensiveHandle = handler.charge({
|
|
165
|
+
amount: '1000000',
|
|
166
|
+
currency: asset,
|
|
167
|
+
expires: new Date(Date.now() + 60_000).toISOString(),
|
|
168
|
+
recipient: accounts[0].address,
|
|
169
|
+
})
|
|
170
|
+
const result = await expensiveHandle(
|
|
171
|
+
new Request('https://example.com/expensive', {
|
|
172
|
+
headers: { Authorization: Credential.serialize(credential) },
|
|
173
|
+
}),
|
|
174
|
+
)
|
|
175
|
+
|
|
176
|
+
expect(result.status).toBe(402)
|
|
177
|
+
if (result.status !== 402) throw new Error()
|
|
178
|
+
|
|
179
|
+
const body = (await result.challenge.json()) as { detail: string }
|
|
180
|
+
expect(body.detail).toContain('does not match')
|
|
181
|
+
})
|
|
182
|
+
|
|
183
|
+
test('returns 402 when credential challenge is expired', async () => {
|
|
184
|
+
const pastExpires = new Date(Date.now() - 60_000).toISOString()
|
|
185
|
+
|
|
186
|
+
const handle = Mppx.create({ methods: [method], realm, secretKey }).charge({
|
|
187
|
+
amount: '1000',
|
|
188
|
+
currency: asset,
|
|
189
|
+
expires: pastExpires,
|
|
190
|
+
recipient: accounts[0].address,
|
|
191
|
+
})
|
|
192
|
+
|
|
193
|
+
// Get a fresh challenge (which has the expired timestamp baked in)
|
|
194
|
+
const firstResult = await handle(new Request('https://example.com/resource'))
|
|
195
|
+
expect(firstResult.status).toBe(402)
|
|
196
|
+
if (firstResult.status !== 402) throw new Error()
|
|
197
|
+
|
|
198
|
+
const challenge = Challenge.fromResponse(firstResult.challenge)
|
|
199
|
+
|
|
200
|
+
const credential = Credential.from({
|
|
201
|
+
challenge,
|
|
202
|
+
payload: { signature: '0x123', type: 'transaction' },
|
|
203
|
+
})
|
|
204
|
+
|
|
205
|
+
const result = await handle(
|
|
206
|
+
new Request('https://example.com/resource', {
|
|
207
|
+
headers: { Authorization: Credential.serialize(credential) },
|
|
208
|
+
}),
|
|
209
|
+
)
|
|
210
|
+
|
|
211
|
+
expect(result.status).toBe(402)
|
|
212
|
+
if (result.status !== 402) throw new Error()
|
|
213
|
+
|
|
214
|
+
const body = (await result.challenge.json()) as object
|
|
215
|
+
expect({
|
|
216
|
+
...body,
|
|
217
|
+
challengeId: '[challengeId]',
|
|
218
|
+
detail: '[detail]',
|
|
219
|
+
instance: '[instance]',
|
|
220
|
+
}).toMatchInlineSnapshot(`
|
|
221
|
+
{
|
|
222
|
+
"challengeId": "[challengeId]",
|
|
223
|
+
"detail": "[detail]",
|
|
224
|
+
"instance": "[instance]",
|
|
225
|
+
"status": 402,
|
|
226
|
+
"title": "Payment Expired",
|
|
227
|
+
"type": "https://paymentauth.org/problems/payment-expired",
|
|
228
|
+
}
|
|
229
|
+
`)
|
|
230
|
+
expect((body as { detail: string }).detail).toContain('Payment expired at')
|
|
231
|
+
})
|
|
141
232
|
test('returns 402 when payload schema validation fails', async () => {
|
|
142
233
|
const handle = Mppx.create({ methods: [method], realm, secretKey }).charge({
|
|
143
234
|
amount: '1000',
|
|
@@ -215,7 +306,7 @@ describe('request handler (node)', () => {
|
|
|
215
306
|
}).toMatchInlineSnapshot(`
|
|
216
307
|
{
|
|
217
308
|
"challengeId": "[challengeId]",
|
|
218
|
-
"detail": "Payment is required
|
|
309
|
+
"detail": "Payment is required.",
|
|
219
310
|
"instance": "[instance]",
|
|
220
311
|
"status": 402,
|
|
221
312
|
"title": "Payment Required",
|
package/src/server/Mppx.ts
CHANGED
|
@@ -41,17 +41,43 @@ type EffectiveTransportOf<mi, defaultTransport extends Transport.AnyTransport> =
|
|
|
41
41
|
? defaultTransport
|
|
42
42
|
: TransportOverrideOf<mi>
|
|
43
43
|
|
|
44
|
-
|
|
44
|
+
/** True when exactly one method has the given intent (no name collision). */
|
|
45
|
+
type IsUniqueIntent<methods extends readonly Method.AnyServer[], intent extends string> = Extract<
|
|
46
|
+
methods[number],
|
|
47
|
+
{ intent: intent }
|
|
48
|
+
> extends infer M
|
|
49
|
+
? M extends M
|
|
50
|
+
? [Exclude<Extract<methods[number], { intent: intent }>, M>] extends [never]
|
|
51
|
+
? true
|
|
52
|
+
: false
|
|
53
|
+
: never
|
|
54
|
+
: never
|
|
55
|
+
|
|
56
|
+
/** Only includes shorthand intent keys when the intent is unique across methods. */
|
|
57
|
+
type UniqueIntentHandlers<
|
|
45
58
|
methods extends readonly Method.AnyServer[],
|
|
46
59
|
transport extends Transport.AnyTransport,
|
|
47
60
|
> = {
|
|
48
|
-
[method_name in methods[number]['intent']
|
|
61
|
+
[method_name in methods[number]['intent'] as IsUniqueIntent<methods, method_name> extends true
|
|
62
|
+
? method_name
|
|
63
|
+
: never]: MethodFn<
|
|
49
64
|
Extract<methods[number], { intent: method_name }>,
|
|
50
65
|
EffectiveTransportOf<Extract<methods[number], { intent: method_name }>, transport>,
|
|
51
66
|
NonNullable<Extract<methods[number], { intent: method_name }>['defaults']>
|
|
52
67
|
>
|
|
53
68
|
}
|
|
54
69
|
|
|
70
|
+
type Handlers<
|
|
71
|
+
methods extends readonly Method.AnyServer[],
|
|
72
|
+
transport extends Transport.AnyTransport,
|
|
73
|
+
> = {
|
|
74
|
+
[mi in methods[number] as `${mi['name']}/${mi['intent']}`]: MethodFn<
|
|
75
|
+
mi,
|
|
76
|
+
EffectiveTransportOf<mi, transport>,
|
|
77
|
+
NonNullable<mi['defaults']>
|
|
78
|
+
>
|
|
79
|
+
} & UniqueIntentHandlers<methods, transport>
|
|
80
|
+
|
|
55
81
|
/**
|
|
56
82
|
* Creates a server-side payment handler from methods.
|
|
57
83
|
*
|
|
@@ -73,17 +99,25 @@ export function create<
|
|
|
73
99
|
const transport extends Transport.AnyTransport = Transport.Http,
|
|
74
100
|
>(config: create.Config<methods, transport>): Mppx<methods, transport> {
|
|
75
101
|
const {
|
|
76
|
-
realm = Env.get('realm'),
|
|
102
|
+
realm = Env.get('realm') ?? 'MPP Payment',
|
|
77
103
|
secretKey = Env.get('secretKey'),
|
|
78
104
|
transport = Transport.http() as transport,
|
|
79
105
|
} = config
|
|
80
106
|
|
|
107
|
+
if (!secretKey) {
|
|
108
|
+
throw new Error(
|
|
109
|
+
'Missing secret key. Set the MPP_SECRET_KEY environment variable or pass `secretKey` to Mppx.create().',
|
|
110
|
+
)
|
|
111
|
+
}
|
|
112
|
+
|
|
81
113
|
const methods = config.methods.flat() as unknown as FlattenMethods<methods>
|
|
82
114
|
|
|
83
115
|
const handlers: Record<string, unknown> = {}
|
|
116
|
+
const intentCount: Record<string, number> = {}
|
|
84
117
|
|
|
85
118
|
for (const mi of methods) {
|
|
86
|
-
|
|
119
|
+
intentCount[mi.intent] = (intentCount[mi.intent] ?? 0) + 1
|
|
120
|
+
handlers[`${mi.name}/${mi.intent}`] = createMethodFn({
|
|
87
121
|
defaults: mi.defaults,
|
|
88
122
|
method: mi,
|
|
89
123
|
realm,
|
|
@@ -95,6 +129,11 @@ export function create<
|
|
|
95
129
|
})
|
|
96
130
|
}
|
|
97
131
|
|
|
132
|
+
// Also set shorthand intent key when there's no collision
|
|
133
|
+
for (const mi of methods) {
|
|
134
|
+
if (intentCount[mi.intent] === 1) handlers[mi.intent] = handlers[`${mi.name}/${mi.intent}`]
|
|
135
|
+
}
|
|
136
|
+
|
|
98
137
|
return { methods, realm: realm as string, transport, ...handlers } as never
|
|
99
138
|
}
|
|
100
139
|
|
|
@@ -107,7 +146,7 @@ export declare namespace create {
|
|
|
107
146
|
methods: methods
|
|
108
147
|
/** Server realm (e.g., hostname). Auto-detected from environment variables (`MPP_REALM`, `VERCEL_URL`, `RAILWAY_PUBLIC_DOMAIN`, `RENDER_EXTERNAL_HOSTNAME`, `HOST`, `HOSTNAME`), falling back to `"localhost"`. */
|
|
109
148
|
realm?: string | undefined
|
|
110
|
-
/** Secret key for HMAC-bound challenge IDs for stateless verification. Auto-detected from `MPP_SECRET_KEY` environment variable
|
|
149
|
+
/** Secret key for HMAC-bound challenge IDs for stateless verification. Auto-detected from `MPP_SECRET_KEY` environment variable. Throws if neither provided nor set. */
|
|
111
150
|
secretKey?: string | undefined
|
|
112
151
|
/** Transport to use. @default Transport.http() */
|
|
113
152
|
transport?: transport | undefined
|
|
@@ -185,7 +224,7 @@ function createMethodFn(parameters: createMethodFn.Parameters): createMethodFn.R
|
|
|
185
224
|
const response = await transport.respondChallenge({
|
|
186
225
|
challenge,
|
|
187
226
|
input,
|
|
188
|
-
error: new Errors.PaymentRequiredError({
|
|
227
|
+
error: new Errors.PaymentRequiredError({ description }),
|
|
189
228
|
})
|
|
190
229
|
return { challenge: response, status: 402 }
|
|
191
230
|
}
|
|
@@ -204,6 +243,42 @@ function createMethodFn(parameters: createMethodFn.Parameters): createMethodFn.R
|
|
|
204
243
|
return { challenge: response, status: 402 }
|
|
205
244
|
}
|
|
206
245
|
|
|
246
|
+
// Verify the credential's challenge matches this route's configured
|
|
247
|
+
// request. Prevents cross-route scope confusion where a credential
|
|
248
|
+
// issued for a cheap route is presented at an expensive route.
|
|
249
|
+
{
|
|
250
|
+
const routeReq = challenge.request as Record<string, unknown>
|
|
251
|
+
const echoedReq = credential.challenge.request as Record<string, unknown>
|
|
252
|
+
for (const field of ['amount', 'currency', 'recipient'] as const) {
|
|
253
|
+
if (
|
|
254
|
+
routeReq[field] !== undefined &&
|
|
255
|
+
echoedReq[field] !== undefined &&
|
|
256
|
+
String(routeReq[field]) !== String(echoedReq[field])
|
|
257
|
+
) {
|
|
258
|
+
const response = await transport.respondChallenge({
|
|
259
|
+
challenge,
|
|
260
|
+
input,
|
|
261
|
+
error: new Errors.InvalidChallengeError({
|
|
262
|
+
id: credential.challenge.id,
|
|
263
|
+
reason: `credential ${field} does not match this route's requirements`,
|
|
264
|
+
}),
|
|
265
|
+
})
|
|
266
|
+
return { challenge: response, status: 402 }
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// Reject expired credentials
|
|
272
|
+
if (credential.challenge.expires && new Date(credential.challenge.expires) < new Date()) {
|
|
273
|
+
const response = await transport.respondChallenge({
|
|
274
|
+
challenge,
|
|
275
|
+
input,
|
|
276
|
+
error: new Errors.PaymentExpiredError({
|
|
277
|
+
expires: credential.challenge.expires,
|
|
278
|
+
}),
|
|
279
|
+
})
|
|
280
|
+
return { challenge: response, status: 402 }
|
|
281
|
+
}
|
|
207
282
|
// Validate payload structure against method schema
|
|
208
283
|
try {
|
|
209
284
|
method.schema.credential.payload.parse(credential.payload)
|
|
@@ -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,
|
|
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
|
+
}
|