mppx 0.3.11 → 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 (40) 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 +23 -4
  6. package/dist/client/internal/Fetch.js.map +1 -1
  7. package/dist/tempo/client/Charge.d.ts +10 -0
  8. package/dist/tempo/client/Charge.d.ts.map +1 -1
  9. package/dist/tempo/client/Charge.js +23 -9
  10. package/dist/tempo/client/Charge.js.map +1 -1
  11. package/dist/tempo/client/Methods.d.ts +1 -0
  12. package/dist/tempo/client/Methods.d.ts.map +1 -1
  13. package/dist/tempo/internal/auto-swap.d.ts +49 -0
  14. package/dist/tempo/internal/auto-swap.d.ts.map +1 -0
  15. package/dist/tempo/internal/auto-swap.js +89 -0
  16. package/dist/tempo/internal/auto-swap.js.map +1 -0
  17. package/dist/tempo/internal/fee-payer.d.ts +15 -0
  18. package/dist/tempo/internal/fee-payer.d.ts.map +1 -0
  19. package/dist/tempo/internal/fee-payer.js +41 -0
  20. package/dist/tempo/internal/fee-payer.js.map +1 -0
  21. package/dist/tempo/internal/selectors.d.ts +5 -0
  22. package/dist/tempo/internal/selectors.d.ts.map +1 -0
  23. package/dist/tempo/internal/selectors.js +7 -0
  24. package/dist/tempo/internal/selectors.js.map +1 -0
  25. package/dist/tempo/server/Charge.d.ts.map +1 -1
  26. package/dist/tempo/server/Charge.js +8 -6
  27. package/dist/tempo/server/Charge.js.map +1 -1
  28. package/package.json +1 -1
  29. package/src/client/Mppx.test-d.ts +28 -0
  30. package/src/client/Mppx.ts +3 -3
  31. package/src/client/internal/Fetch.test.ts +410 -0
  32. package/src/client/internal/Fetch.ts +25 -7
  33. package/src/tempo/client/Charge.ts +40 -9
  34. package/src/tempo/internal/auto-swap.test.ts +113 -0
  35. package/src/tempo/internal/auto-swap.ts +141 -0
  36. package/src/tempo/internal/fee-payer.test.ts +223 -0
  37. package/src/tempo/internal/fee-payer.ts +53 -0
  38. package/src/tempo/internal/selectors.ts +10 -0
  39. package/src/tempo/server/Charge.test.ts +374 -3
  40. package/src/tempo/server/Charge.ts +9 -18
@@ -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
+ }
@@ -0,0 +1,223 @@
1
+ import { encodeFunctionData } from 'viem'
2
+ import { Abis, Addresses } from 'viem/tempo'
3
+ import { describe, expect, test } from 'vitest'
4
+ import { callScopes, FeePayerValidationError, validateCalls } from './fee-payer.js'
5
+ import * as Selectors from './selectors.js'
6
+
7
+ const details = { amount: '1', currency: '0x01', recipient: '0x02' }
8
+ const bogus = '0x0000000000000000000000000000000000000001' as const
9
+
10
+ describe('callScopes', () => {
11
+ test('has 4 allowed patterns', () => {
12
+ expect(callScopes).toHaveLength(4)
13
+ })
14
+
15
+ test('patterns use correct selectors', () => {
16
+ expect(callScopes).toEqual([
17
+ [Selectors.transfer],
18
+ [Selectors.transferWithMemo],
19
+ [Selectors.approve, Selectors.swapExactAmountOut, Selectors.transfer],
20
+ [Selectors.approve, Selectors.swapExactAmountOut, Selectors.transferWithMemo],
21
+ ])
22
+ })
23
+ })
24
+
25
+ describe('validateCalls', () => {
26
+ test('accepts single transfer', () => {
27
+ expect(() =>
28
+ validateCalls(
29
+ [
30
+ {
31
+ data: encodeFunctionData({
32
+ abi: Abis.tip20,
33
+ functionName: 'transfer',
34
+ args: [bogus, 100n],
35
+ }),
36
+ },
37
+ ],
38
+ details,
39
+ ),
40
+ ).not.toThrow()
41
+ })
42
+
43
+ test('accepts approve + buy + transfer', () => {
44
+ const swapSelector = Selectors.swapExactAmountOut
45
+ expect(() =>
46
+ validateCalls(
47
+ [
48
+ {
49
+ data: encodeFunctionData({
50
+ abi: Abis.tip20,
51
+ functionName: 'approve',
52
+ args: [Addresses.stablecoinDex, 100n],
53
+ }),
54
+ },
55
+ {
56
+ to: Addresses.stablecoinDex,
57
+ data: `${swapSelector}${'00'.repeat(128)}` as `0x${string}`,
58
+ },
59
+ {
60
+ data: encodeFunctionData({
61
+ abi: Abis.tip20,
62
+ functionName: 'transfer',
63
+ args: [bogus, 100n],
64
+ }),
65
+ },
66
+ ],
67
+ details,
68
+ ),
69
+ ).not.toThrow()
70
+ })
71
+
72
+ test('error: rejects empty calls', () => {
73
+ expect(() => validateCalls([], details)).toThrow(FeePayerValidationError)
74
+ })
75
+
76
+ test('error: rejects unknown selector', () => {
77
+ expect(() => validateCalls([{ data: '0xdeadbeef' as `0x${string}` }], details)).toThrow(
78
+ 'disallowed call pattern',
79
+ )
80
+ })
81
+
82
+ test('error: rejects extra calls beyond allowed patterns', () => {
83
+ const swapSelector = Selectors.swapExactAmountOut
84
+ expect(() =>
85
+ validateCalls(
86
+ [
87
+ {
88
+ data: encodeFunctionData({
89
+ abi: Abis.tip20,
90
+ functionName: 'approve',
91
+ args: [Addresses.stablecoinDex, 100n],
92
+ }),
93
+ },
94
+ {
95
+ to: Addresses.stablecoinDex,
96
+ data: `${swapSelector}${'00'.repeat(128)}` as `0x${string}`,
97
+ },
98
+ {
99
+ data: encodeFunctionData({
100
+ abi: Abis.tip20,
101
+ functionName: 'transfer',
102
+ args: [bogus, 100n],
103
+ }),
104
+ },
105
+ {
106
+ data: encodeFunctionData({
107
+ abi: Abis.tip20,
108
+ functionName: 'transfer',
109
+ args: [bogus, 100n],
110
+ }),
111
+ },
112
+ ],
113
+ details,
114
+ ),
115
+ ).toThrow('disallowed call pattern')
116
+ })
117
+
118
+ test('error: rejects wrong order (transfer before approve + buy)', () => {
119
+ const swapSelector = Selectors.swapExactAmountOut
120
+ expect(() =>
121
+ validateCalls(
122
+ [
123
+ {
124
+ data: encodeFunctionData({
125
+ abi: Abis.tip20,
126
+ functionName: 'transfer',
127
+ args: [bogus, 100n],
128
+ }),
129
+ },
130
+ {
131
+ data: encodeFunctionData({
132
+ abi: Abis.tip20,
133
+ functionName: 'approve',
134
+ args: [Addresses.stablecoinDex, 100n],
135
+ }),
136
+ },
137
+ {
138
+ to: Addresses.stablecoinDex,
139
+ data: `${swapSelector}${'00'.repeat(128)}` as `0x${string}`,
140
+ },
141
+ ],
142
+ details,
143
+ ),
144
+ ).toThrow('disallowed call pattern')
145
+ })
146
+
147
+ test('error: rejects approve with non-DEX spender', () => {
148
+ const swapSelector = Selectors.swapExactAmountOut
149
+ expect(() =>
150
+ validateCalls(
151
+ [
152
+ {
153
+ data: encodeFunctionData({
154
+ abi: Abis.tip20,
155
+ functionName: 'approve',
156
+ args: [bogus, 100n],
157
+ }),
158
+ },
159
+ {
160
+ to: Addresses.stablecoinDex,
161
+ data: `${swapSelector}${'00'.repeat(128)}` as `0x${string}`,
162
+ },
163
+ {
164
+ data: encodeFunctionData({
165
+ abi: Abis.tip20,
166
+ functionName: 'transfer',
167
+ args: [bogus, 100n],
168
+ }),
169
+ },
170
+ ],
171
+ details,
172
+ ),
173
+ ).toThrow('approve spender is not the DEX')
174
+ })
175
+
176
+ test('error: rejects buy targeting non-DEX address', () => {
177
+ const swapSelector = Selectors.swapExactAmountOut
178
+ expect(() =>
179
+ validateCalls(
180
+ [
181
+ {
182
+ data: encodeFunctionData({
183
+ abi: Abis.tip20,
184
+ functionName: 'approve',
185
+ args: [Addresses.stablecoinDex, 100n],
186
+ }),
187
+ },
188
+ { to: bogus, data: `${swapSelector}${'00'.repeat(128)}` as `0x${string}` },
189
+ {
190
+ data: encodeFunctionData({
191
+ abi: Abis.tip20,
192
+ functionName: 'transfer',
193
+ args: [bogus, 100n],
194
+ }),
195
+ },
196
+ ],
197
+ details,
198
+ ),
199
+ ).toThrow('buy target is not the DEX')
200
+ })
201
+
202
+ test('error: rejects approve + buy without transfer', () => {
203
+ const swapSelector = Selectors.swapExactAmountOut
204
+ expect(() =>
205
+ validateCalls(
206
+ [
207
+ {
208
+ data: encodeFunctionData({
209
+ abi: Abis.tip20,
210
+ functionName: 'approve',
211
+ args: [Addresses.stablecoinDex, 100n],
212
+ }),
213
+ },
214
+ {
215
+ to: Addresses.stablecoinDex,
216
+ data: `${swapSelector}${'00'.repeat(128)}` as `0x${string}`,
217
+ },
218
+ ],
219
+ details,
220
+ ),
221
+ ).toThrow('disallowed call pattern')
222
+ })
223
+ })
@@ -0,0 +1,53 @@
1
+ import { decodeFunctionData, isAddressEqual } from 'viem'
2
+ import { Abis, Addresses } from 'viem/tempo'
3
+ import * as Selectors from './selectors.js'
4
+
5
+ /**
6
+ * Allowed call patterns for fee-payer sponsored transactions.
7
+ * Each inner array is an ordered list of function selectors.
8
+ */
9
+ export const callScopes = [
10
+ [Selectors.transfer],
11
+ [Selectors.transferWithMemo],
12
+ [Selectors.approve, Selectors.swapExactAmountOut, Selectors.transfer],
13
+ [Selectors.approve, Selectors.swapExactAmountOut, Selectors.transferWithMemo],
14
+ ]
15
+
16
+ /** Validates that a set of transaction calls matches an allowed fee-payer pattern. */
17
+ export function validateCalls(
18
+ calls: readonly { data?: `0x${string}` | undefined; to?: `0x${string}` | undefined }[],
19
+ details: Record<string, string>,
20
+ ) {
21
+ const callSelectors = calls.map((c) => c.data?.slice(0, 10))
22
+ const allowed = callScopes.some(
23
+ (pattern) =>
24
+ pattern.length === callSelectors.length &&
25
+ pattern.every((sel, i) => sel === callSelectors[i]),
26
+ )
27
+ if (!allowed)
28
+ throw new FeePayerValidationError('disallowed call pattern in fee-payer transaction', details)
29
+
30
+ // Validate approve spender and buy target are the DEX.
31
+ const approveCall = calls.find((c) => c.data?.slice(0, 10) === Selectors.approve)
32
+ if (approveCall) {
33
+ const { args } = decodeFunctionData({ abi: Abis.tip20, data: approveCall.data! })
34
+ if (!isAddressEqual((args as [`0x${string}`])[0]!, Addresses.stablecoinDex))
35
+ throw new FeePayerValidationError('approve spender is not the DEX', details)
36
+ }
37
+ const buyCall = calls.find((c) => c.data?.slice(0, 10) === Selectors.swapExactAmountOut)
38
+ if (buyCall && (!buyCall.to || !isAddressEqual(buyCall.to, Addresses.stablecoinDex)))
39
+ throw new FeePayerValidationError('buy target is not the DEX', details)
40
+ }
41
+
42
+ export class FeePayerValidationError extends Error {
43
+ override readonly name = 'FeePayerValidationError'
44
+
45
+ constructor(reason: string, details: Record<string, string>) {
46
+ super(
47
+ [
48
+ `Invalid transaction: ${reason}`,
49
+ ...Object.entries(details).map(([k, v]) => ` - ${k}: ${v}`),
50
+ ].join('\n'),
51
+ )
52
+ }
53
+ }
@@ -0,0 +1,10 @@
1
+ import { AbiItem } from 'ox'
2
+ import { Abis } from 'viem/tempo'
3
+
4
+ export const approve = /*#__PURE__*/ AbiItem.getSelector(Abis.tip20, 'approve')
5
+ export const transfer = /*#__PURE__*/ AbiItem.getSelector(Abis.tip20, 'transfer')
6
+ export const transferWithMemo = /*#__PURE__*/ AbiItem.getSelector(Abis.tip20, 'transferWithMemo')
7
+ export const swapExactAmountOut = /*#__PURE__*/ AbiItem.getSelector(
8
+ Abis.stablecoinDex,
9
+ 'swapExactAmountOut',
10
+ )