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.
Files changed (44) hide show
  1. package/dist/client/Mppx.d.ts +1 -1
  2. package/dist/client/Mppx.d.ts.map +1 -1
  3. package/dist/client/internal/Fetch.d.ts +1 -1
  4. package/dist/client/internal/Fetch.d.ts.map +1 -1
  5. package/dist/client/internal/Fetch.js +76 -11
  6. package/dist/client/internal/Fetch.js.map +1 -1
  7. package/dist/internal/constantTimeEqual.d.ts.map +1 -1
  8. package/dist/internal/constantTimeEqual.js +7 -4
  9. package/dist/internal/constantTimeEqual.js.map +1 -1
  10. package/dist/tempo/client/Charge.d.ts +10 -0
  11. package/dist/tempo/client/Charge.d.ts.map +1 -1
  12. package/dist/tempo/client/Charge.js +23 -9
  13. package/dist/tempo/client/Charge.js.map +1 -1
  14. package/dist/tempo/client/Methods.d.ts +1 -0
  15. package/dist/tempo/client/Methods.d.ts.map +1 -1
  16. package/dist/tempo/internal/auto-swap.d.ts +49 -0
  17. package/dist/tempo/internal/auto-swap.d.ts.map +1 -0
  18. package/dist/tempo/internal/auto-swap.js +89 -0
  19. package/dist/tempo/internal/auto-swap.js.map +1 -0
  20. package/dist/tempo/internal/fee-payer.d.ts +15 -0
  21. package/dist/tempo/internal/fee-payer.d.ts.map +1 -0
  22. package/dist/tempo/internal/fee-payer.js +41 -0
  23. package/dist/tempo/internal/fee-payer.js.map +1 -0
  24. package/dist/tempo/internal/selectors.d.ts +5 -0
  25. package/dist/tempo/internal/selectors.d.ts.map +1 -0
  26. package/dist/tempo/internal/selectors.js +7 -0
  27. package/dist/tempo/internal/selectors.js.map +1 -0
  28. package/dist/tempo/server/Charge.d.ts.map +1 -1
  29. package/dist/tempo/server/Charge.js +8 -6
  30. package/dist/tempo/server/Charge.js.map +1 -1
  31. package/package.json +1 -1
  32. package/src/client/Mppx.test-d.ts +28 -0
  33. package/src/client/Mppx.ts +3 -3
  34. package/src/client/internal/Fetch.test.ts +454 -0
  35. package/src/client/internal/Fetch.ts +89 -14
  36. package/src/internal/constantTimeEqual.ts +6 -4
  37. package/src/tempo/client/Charge.ts +40 -9
  38. package/src/tempo/internal/auto-swap.test.ts +113 -0
  39. package/src/tempo/internal/auto-swap.ts +141 -0
  40. package/src/tempo/internal/fee-payer.test.ts +223 -0
  41. package/src/tempo/internal/fee-payer.ts +53 -0
  42. package/src/tempo/internal/selectors.ts +10 -0
  43. package/src/tempo/server/Charge.test.ts +374 -3
  44. 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
- return async (input, init) => {
34
- const { context, ...fetchInit } = init ?? {}
35
- const response = await fetch(input, fetchInit)
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 fetch(input, {
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 Method.Client<any, infer contextSchema>
68
- ? contextSchema extends z.ZodMiniType
69
- ? z.input<contextSchema>
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
- originalFetch = globalThis.fetch
127
- globalThis.fetch = from(config) as typeof globalThis.fetch
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 (originalFetch) {
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 { createHash, timingSafeEqual } from 'node:crypto'
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 = createHash('sha256').update(a).digest()
6
- const hashB = createHash('sha256').update(b).digest()
7
- return timingSafeEqual(hashA, hashB)
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, 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
+ }