mppx 0.3.11 → 0.3.13
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/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 +76 -11
- package/dist/client/internal/Fetch.js.map +1 -1
- package/dist/internal/constantTimeEqual.d.ts.map +1 -1
- package/dist/internal/constantTimeEqual.js +7 -4
- package/dist/internal/constantTimeEqual.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/server/Charge.d.ts.map +1 -1
- package/dist/tempo/server/Charge.js +8 -6
- package/dist/tempo/server/Charge.js.map +1 -1
- package/package.json +1 -1
- package/src/client/Mppx.test-d.ts +28 -0
- package/src/client/Mppx.ts +3 -3
- package/src/client/internal/Fetch.test.ts +454 -0
- package/src/client/internal/Fetch.ts +89 -14
- package/src/internal/constantTimeEqual.ts +6 -4
- 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/server/Charge.test.ts +374 -3
- package/src/tempo/server/Charge.ts +9 -18
|
@@ -2,6 +2,15 @@ import * as Challenge from '../../Challenge.js'
|
|
|
2
2
|
import type * as Method from '../../Method.js'
|
|
3
3
|
import type * as z from '../../zod.js'
|
|
4
4
|
|
|
5
|
+
// We tag wrappers with a global symbol so we can recognize wrappers created by mppx,
|
|
6
|
+
// even across multiple module instances/bundles. This lets restore() avoid clobbering
|
|
7
|
+
// an unrelated fetch installed by user code or another library.
|
|
8
|
+
const MPPX_FETCH_WRAPPER = Symbol.for('mppx.fetch.wrapper')
|
|
9
|
+
|
|
10
|
+
type WrappedFetch = typeof globalThis.fetch & {
|
|
11
|
+
[MPPX_FETCH_WRAPPER]?: typeof globalThis.fetch
|
|
12
|
+
}
|
|
13
|
+
|
|
5
14
|
let originalFetch: typeof globalThis.fetch | undefined
|
|
6
15
|
|
|
7
16
|
/**
|
|
@@ -29,13 +38,20 @@ export function from<const methods extends readonly Method.AnyClient[]>(
|
|
|
29
38
|
config: from.Config<methods>,
|
|
30
39
|
): from.Fetch<methods> {
|
|
31
40
|
const { fetch = globalThis.fetch, methods, onChallenge } = config
|
|
41
|
+
// Always operate on the true underlying fetch to avoid wrapper-on-wrapper stacking,
|
|
42
|
+
// which can duplicate retries and make restore semantics fragile.
|
|
43
|
+
const baseFetch = unwrapFetch(fetch)
|
|
32
44
|
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
const response = await
|
|
45
|
+
const wrappedFetch = async (input: RequestInfo | URL, init?: from.RequestInit<methods>) => {
|
|
46
|
+
// Pass init through untouched to preserve object identity for non-402 responses.
|
|
47
|
+
const response = await baseFetch(input, init)
|
|
36
48
|
|
|
37
49
|
if (response.status !== 402) return response
|
|
38
50
|
|
|
51
|
+
// Only extract context for payment handling after confirming 402.
|
|
52
|
+
const context = (init as Record<string, unknown> | undefined)?.context
|
|
53
|
+
const { context: _, ...fetchInit } = (init ?? {}) as Record<string, unknown>
|
|
54
|
+
|
|
39
55
|
const challenge = Challenge.fromResponse(response)
|
|
40
56
|
|
|
41
57
|
const mi = methods.find((m) => m.name === challenge.method && m.intent === challenge.intent)
|
|
@@ -51,22 +67,25 @@ export function from<const methods extends readonly Method.AnyClient[]>(
|
|
|
51
67
|
})
|
|
52
68
|
: undefined
|
|
53
69
|
const credential = onChallengeCredential ?? (await resolveCredential(challenge, mi, context))
|
|
70
|
+
validateCredentialHeaderValue(credential)
|
|
54
71
|
|
|
55
|
-
return
|
|
72
|
+
return baseFetch(input, {
|
|
56
73
|
...fetchInit,
|
|
57
|
-
headers:
|
|
58
|
-
...fetchInit.headers,
|
|
59
|
-
Authorization: credential,
|
|
60
|
-
},
|
|
74
|
+
headers: withAuthorizationHeader(fetchInit.headers, credential),
|
|
61
75
|
})
|
|
62
76
|
}
|
|
77
|
+
|
|
78
|
+
// Record the wrapped target so future polyfill() / restore() calls can detect origin
|
|
79
|
+
// and safely unwrap only mppx-installed wrappers.
|
|
80
|
+
;(wrappedFetch as WrappedFetch)[MPPX_FETCH_WRAPPER] = baseFetch
|
|
81
|
+
return wrappedFetch as from.Fetch<methods>
|
|
63
82
|
}
|
|
64
83
|
|
|
65
84
|
/** Union of all context types from all methods that have context schemas. */
|
|
66
85
|
type AnyContextFor<methods extends readonly Method.AnyClient[]> = {
|
|
67
|
-
[K in keyof methods]: methods[K] extends
|
|
68
|
-
?
|
|
69
|
-
? z.input<
|
|
86
|
+
[K in keyof methods]: NonNullable<methods[K]['context']> extends infer ctx
|
|
87
|
+
? ctx extends z.ZodMiniType
|
|
88
|
+
? z.input<ctx>
|
|
70
89
|
: undefined
|
|
71
90
|
: undefined
|
|
72
91
|
}[number]
|
|
@@ -123,8 +142,14 @@ export declare namespace from {
|
|
|
123
142
|
export function polyfill<const methods extends readonly Method.AnyClient[]>(
|
|
124
143
|
config: polyfill.Config<methods>,
|
|
125
144
|
): void {
|
|
126
|
-
|
|
127
|
-
|
|
145
|
+
// Defensive guard for runtimes/tests where fetch might be non-configurable.
|
|
146
|
+
const descriptor = Object.getOwnPropertyDescriptor(globalThis, 'fetch')
|
|
147
|
+
if (!descriptor || (!descriptor.writable && !descriptor.set)) {
|
|
148
|
+
throw new Error('globalThis.fetch is not writable')
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
if (!originalFetch) originalFetch = globalThis.fetch
|
|
152
|
+
globalThis.fetch = from({ ...config, fetch: globalThis.fetch }) as typeof globalThis.fetch
|
|
128
153
|
}
|
|
129
154
|
|
|
130
155
|
export declare namespace polyfill {
|
|
@@ -147,12 +172,62 @@ export declare namespace polyfill {
|
|
|
147
172
|
* ```
|
|
148
173
|
*/
|
|
149
174
|
export function restore(): void {
|
|
150
|
-
if
|
|
175
|
+
// Only restore if the current fetch is still an mppx wrapper.
|
|
176
|
+
// If app code replaced fetch after polyfill(), we must not overwrite it.
|
|
177
|
+
if (originalFetch && isWrappedFetch(globalThis.fetch)) {
|
|
151
178
|
globalThis.fetch = originalFetch
|
|
152
179
|
originalFetch = undefined
|
|
153
180
|
}
|
|
154
181
|
}
|
|
155
182
|
|
|
183
|
+
/** @internal Normalizes headers to a plain object for spreading. */
|
|
184
|
+
function normalizeHeaders(headers: unknown): Record<string, string> {
|
|
185
|
+
if (!headers) return {}
|
|
186
|
+
if (headers instanceof Headers) {
|
|
187
|
+
const result: Record<string, string> = {}
|
|
188
|
+
headers.forEach((value, key) => {
|
|
189
|
+
result[key] = value
|
|
190
|
+
})
|
|
191
|
+
return result
|
|
192
|
+
}
|
|
193
|
+
if (Array.isArray(headers)) return Object.fromEntries(headers)
|
|
194
|
+
return headers as Record<string, string>
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/** @internal */
|
|
198
|
+
function withAuthorizationHeader(headers: unknown, credential: string): Record<string, string> {
|
|
199
|
+
const normalized = normalizeHeaders(headers)
|
|
200
|
+
// Remove any existing Authorization header regardless of casing to avoid
|
|
201
|
+
// duplicate/conflicting credentials on retry.
|
|
202
|
+
for (const key of Object.keys(normalized)) {
|
|
203
|
+
if (key.toLowerCase() === 'authorization') delete normalized[key]
|
|
204
|
+
}
|
|
205
|
+
normalized.Authorization = credential
|
|
206
|
+
return normalized
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/** @internal */
|
|
210
|
+
function unwrapFetch(fetch: typeof globalThis.fetch): typeof globalThis.fetch {
|
|
211
|
+
let current = fetch as WrappedFetch
|
|
212
|
+
while (current[MPPX_FETCH_WRAPPER]) {
|
|
213
|
+
current = current[MPPX_FETCH_WRAPPER] as WrappedFetch
|
|
214
|
+
}
|
|
215
|
+
return current as typeof globalThis.fetch
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/** @internal */
|
|
219
|
+
function isWrappedFetch(fetch: typeof globalThis.fetch): fetch is WrappedFetch {
|
|
220
|
+
return Boolean((fetch as WrappedFetch)[MPPX_FETCH_WRAPPER])
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/** @internal */
|
|
224
|
+
function validateCredentialHeaderValue(credential: string): void {
|
|
225
|
+
if (!credential.trim()) throw new Error('Credential header value must be non-empty')
|
|
226
|
+
if (credential.includes('\r') || credential.includes('\n')) {
|
|
227
|
+
throw new Error('Credential header value contains illegal newline characters')
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
156
231
|
/** @internal */
|
|
157
232
|
async function resolveCredential(
|
|
158
233
|
challenge: Challenge.Challenge,
|
|
@@ -1,8 +1,10 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { Hash, Hex } from 'ox'
|
|
2
2
|
|
|
3
3
|
/** Constant-time string comparison to prevent timing attacks. */
|
|
4
4
|
export function constantTimeEqual(a: string, b: string): boolean {
|
|
5
|
-
const hashA =
|
|
6
|
-
const hashB =
|
|
7
|
-
|
|
5
|
+
const hashA = Hash.sha256(Hex.fromString(a))
|
|
6
|
+
const hashB = Hash.sha256(Hex.fromString(b))
|
|
7
|
+
let result = 0
|
|
8
|
+
for (let i = 0; i < hashA.length; i++) result |= hashA.charCodeAt(i) ^ hashB.charCodeAt(i)
|
|
9
|
+
return result === 0
|
|
8
10
|
}
|
|
@@ -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
|
+
}
|