accounts 0.19.0 → 0.19.1
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/CHANGELOG.md +8 -0
- package/dist/server/internal/handlers/feeLiquidity.d.ts +28 -0
- package/dist/server/internal/handlers/feeLiquidity.d.ts.map +1 -0
- package/dist/server/internal/handlers/feeLiquidity.js +80 -0
- package/dist/server/internal/handlers/feeLiquidity.js.map +1 -0
- package/dist/server/internal/handlers/relay.d.ts.map +1 -1
- package/dist/server/internal/handlers/relay.js +87 -44
- package/dist/server/internal/handlers/relay.js.map +1 -1
- package/package.json +5 -5
- package/src/core/AccessKey.test.ts +1 -1
- package/src/core/Provider.localnet.test.ts +1 -1
- package/src/core/adapters/privy.test.ts +1 -1
- package/src/server/internal/handlers/feeLiquidity.localnet.test.ts +398 -0
- package/src/server/internal/handlers/feeLiquidity.ts +117 -0
- package/src/server/internal/handlers/relay.localnet.test.ts +44 -8
- package/src/server/internal/handlers/relay.ts +94 -44
|
@@ -0,0 +1,398 @@
|
|
|
1
|
+
import {
|
|
2
|
+
encodeFunctionData,
|
|
3
|
+
http as viem_http,
|
|
4
|
+
isAddressEqual,
|
|
5
|
+
parseUnits,
|
|
6
|
+
toFunctionSelector,
|
|
7
|
+
} from 'viem'
|
|
8
|
+
import { generatePrivateKey } from 'viem/accounts'
|
|
9
|
+
import { fillTransaction, sendTransactionSync } from 'viem/actions'
|
|
10
|
+
import { Abis, Account, Actions, Addresses } from 'viem/tempo'
|
|
11
|
+
import { afterEach, expect, test } from 'vp/test'
|
|
12
|
+
|
|
13
|
+
import {
|
|
14
|
+
accounts,
|
|
15
|
+
addresses,
|
|
16
|
+
chain as localnetChain,
|
|
17
|
+
getClient,
|
|
18
|
+
rpcUrl,
|
|
19
|
+
} from '../../../../test/config.js'
|
|
20
|
+
import { createServer, type Server } from '../../../../test/utils.js'
|
|
21
|
+
import * as Kv from '../../Kv.js'
|
|
22
|
+
import * as FeeLiquidity from './feeLiquidity.js'
|
|
23
|
+
import { relay } from './relay.js'
|
|
24
|
+
|
|
25
|
+
const servers: Server[] = []
|
|
26
|
+
const chain = { ...localnetChain, hardfork: 't5' as const }
|
|
27
|
+
const balanceSelector = toFunctionSelector('balanceOf(address)').slice(2)
|
|
28
|
+
const poolSelector = toFunctionSelector('getPool(address,address)').slice(2)
|
|
29
|
+
|
|
30
|
+
afterEach(async () => {
|
|
31
|
+
await Promise.all(servers.splice(0).map((server) => server.closeAsync()))
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
async function fixture() {
|
|
35
|
+
const rpc = getClient({ account: accounts[0]! })
|
|
36
|
+
const sender = Account.fromSecp256k1(generatePrivateKey())
|
|
37
|
+
const { token } = await Actions.token.createSync(rpc, {
|
|
38
|
+
name: 'Localnet MACH',
|
|
39
|
+
symbol: 'MACH',
|
|
40
|
+
currency: 'USD',
|
|
41
|
+
quoteToken: addresses.alphaUsd,
|
|
42
|
+
})
|
|
43
|
+
await sendTransactionSync(rpc, {
|
|
44
|
+
feeToken: Addresses.pathUsd,
|
|
45
|
+
calls: [
|
|
46
|
+
Actions.token.grantRoles.call({ token, role: 'issuer', to: rpc.account.address }),
|
|
47
|
+
Actions.token.mint.call({ token, amount: parseUnits('500', 6), to: sender.address }),
|
|
48
|
+
Actions.token.mint.call({
|
|
49
|
+
token: addresses.alphaUsd,
|
|
50
|
+
amount: parseUnits('100', 6),
|
|
51
|
+
to: sender.address,
|
|
52
|
+
}),
|
|
53
|
+
],
|
|
54
|
+
})
|
|
55
|
+
await sendTransactionSync(rpc, {
|
|
56
|
+
account: sender,
|
|
57
|
+
feeToken: addresses.alphaUsd,
|
|
58
|
+
calls: [Actions.fee.setUserToken.call({ token })],
|
|
59
|
+
throwOnReceiptRevert: true,
|
|
60
|
+
})
|
|
61
|
+
const calls = [
|
|
62
|
+
Actions.token.transfer.call({
|
|
63
|
+
token: addresses.alphaUsd,
|
|
64
|
+
to: accounts[7]!.address,
|
|
65
|
+
amount: 1n,
|
|
66
|
+
}),
|
|
67
|
+
]
|
|
68
|
+
const tokens = [token, addresses.alphaUsd].map((address) => ({
|
|
69
|
+
address,
|
|
70
|
+
decimals: 6,
|
|
71
|
+
name: 'Localnet USD',
|
|
72
|
+
symbol: 'USD',
|
|
73
|
+
}))
|
|
74
|
+
return { rpc, sender, token, calls, tokens }
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function observe(
|
|
78
|
+
options: {
|
|
79
|
+
beforeBalance?: (() => Promise<void>) | undefined
|
|
80
|
+
beforeProducer?: (() => Promise<void>) | undefined
|
|
81
|
+
onProducer?: (() => void) | undefined
|
|
82
|
+
onPool?: (() => void) | undefined
|
|
83
|
+
} = {},
|
|
84
|
+
) {
|
|
85
|
+
const events: string[] = []
|
|
86
|
+
const pools: string[] = []
|
|
87
|
+
let balances = 0
|
|
88
|
+
const transport = viem_http(rpcUrl, {
|
|
89
|
+
retryCount: 0,
|
|
90
|
+
timeout: 5_000,
|
|
91
|
+
fetchFn: async (url, init) => {
|
|
92
|
+
const body = JSON.parse(init?.body as string) as
|
|
93
|
+
| { method: string; params?: { data?: string }[] }
|
|
94
|
+
| { method: string; params?: { data?: string }[] }[]
|
|
95
|
+
const requests = Array.isArray(body) ? body : [body]
|
|
96
|
+
let balance = false
|
|
97
|
+
let pool = false
|
|
98
|
+
let producer = false
|
|
99
|
+
for (const request of requests) {
|
|
100
|
+
if (request.method === 'eth_getBlockByNumber') producer = true
|
|
101
|
+
if (request.method === 'eth_fillTransaction') events.push('fill')
|
|
102
|
+
if (request.method !== 'eth_call') continue
|
|
103
|
+
const data = request.params?.[0]?.data?.toLowerCase() ?? ''
|
|
104
|
+
const matches = data.match(new RegExp(balanceSelector, 'g')) ?? []
|
|
105
|
+
if (matches.length > 0) {
|
|
106
|
+
balances += matches.length
|
|
107
|
+
balance = true
|
|
108
|
+
events.push('balance')
|
|
109
|
+
}
|
|
110
|
+
for (const match of data.matchAll(new RegExp(`${poolSelector}[0-9a-f]{128}`, 'g'))) {
|
|
111
|
+
pool = true
|
|
112
|
+
pools.push(match[0])
|
|
113
|
+
events.push('pool')
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
if (producer) {
|
|
117
|
+
await options.beforeProducer?.()
|
|
118
|
+
events.push('producer')
|
|
119
|
+
options.onProducer?.()
|
|
120
|
+
}
|
|
121
|
+
if (pool) options.onPool?.()
|
|
122
|
+
if (balance) await options.beforeBalance?.()
|
|
123
|
+
const response = await fetch(url, init)
|
|
124
|
+
if (balance) events.push('balance-complete')
|
|
125
|
+
return response
|
|
126
|
+
},
|
|
127
|
+
})
|
|
128
|
+
return {
|
|
129
|
+
transport,
|
|
130
|
+
events,
|
|
131
|
+
pools,
|
|
132
|
+
get balances() {
|
|
133
|
+
return balances
|
|
134
|
+
},
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
async function serve(options: relay.Options) {
|
|
139
|
+
const server = await createServer(relay({ autoSwap: false, ...options }).listener)
|
|
140
|
+
servers.push(server)
|
|
141
|
+
return getClient({ transport: viem_http(server.url, { retryCount: 0 }) })
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
test('fee liquidity: producer discovery overlaps real pending balance RPCs', async () => {
|
|
145
|
+
const state = await fixture()
|
|
146
|
+
const balance = Promise.withResolvers<void>()
|
|
147
|
+
const producer = Promise.withResolvers<void>()
|
|
148
|
+
const observed = observe({
|
|
149
|
+
beforeBalance: () => {
|
|
150
|
+
balance.resolve()
|
|
151
|
+
return producer.promise
|
|
152
|
+
},
|
|
153
|
+
beforeProducer: () => balance.promise,
|
|
154
|
+
onProducer: () => producer.resolve(),
|
|
155
|
+
})
|
|
156
|
+
const client = await serve({
|
|
157
|
+
chains: [chain],
|
|
158
|
+
features: 'all',
|
|
159
|
+
resolveTokens: () => state.tokens,
|
|
160
|
+
transports: { [chain.id]: observed.transport },
|
|
161
|
+
})
|
|
162
|
+
const { transaction } = await fillTransaction(client, {
|
|
163
|
+
account: state.sender.address,
|
|
164
|
+
calls: state.calls,
|
|
165
|
+
})
|
|
166
|
+
const receipt = await sendTransactionSync(getClient(), {
|
|
167
|
+
type: 'tempo',
|
|
168
|
+
calls: transaction.calls!,
|
|
169
|
+
feeToken: transaction.feeToken!,
|
|
170
|
+
gas: transaction.gas,
|
|
171
|
+
maxFeePerGas: transaction.maxFeePerGas,
|
|
172
|
+
maxPriorityFeePerGas: transaction.maxPriorityFeePerGas,
|
|
173
|
+
nonce: transaction.nonce,
|
|
174
|
+
account: state.sender,
|
|
175
|
+
})
|
|
176
|
+
expect({
|
|
177
|
+
overlaps: observed.events.indexOf('producer') < observed.events.indexOf('balance-complete'),
|
|
178
|
+
balanceStarted: observed.events.indexOf('balance') < observed.events.indexOf('producer'),
|
|
179
|
+
skippedMach: isAddressEqual(transaction.feeToken!, addresses.alphaUsd),
|
|
180
|
+
balances: observed.balances,
|
|
181
|
+
status: receipt.status,
|
|
182
|
+
}).toMatchInlineSnapshot(`
|
|
183
|
+
{
|
|
184
|
+
"balanceStarted": true,
|
|
185
|
+
"balances": 2,
|
|
186
|
+
"overlaps": true,
|
|
187
|
+
"skippedMach": true,
|
|
188
|
+
"status": "success",
|
|
189
|
+
}
|
|
190
|
+
`)
|
|
191
|
+
})
|
|
192
|
+
|
|
193
|
+
test('fee liquidity: pool reads overlap real pending balances and are reused across retries', async () => {
|
|
194
|
+
const state = await fixture()
|
|
195
|
+
const pool = Promise.withResolvers<void>()
|
|
196
|
+
const observed = observe({ beforeBalance: () => pool.promise, onPool: () => pool.resolve() })
|
|
197
|
+
const client = await serve({
|
|
198
|
+
chains: [chain],
|
|
199
|
+
features: 'all',
|
|
200
|
+
resolveTokens: () => state.tokens,
|
|
201
|
+
transports: { [chain.id]: observed.transport },
|
|
202
|
+
})
|
|
203
|
+
const { transaction } = await fillTransaction(client, {
|
|
204
|
+
account: state.sender.address,
|
|
205
|
+
calls: state.calls,
|
|
206
|
+
})
|
|
207
|
+
const direct = encodeFunctionData({
|
|
208
|
+
abi: Abis.feeAmm,
|
|
209
|
+
functionName: 'getPool',
|
|
210
|
+
args: [state.token, Addresses.pathUsd],
|
|
211
|
+
})
|
|
212
|
+
.slice(2)
|
|
213
|
+
.toLowerCase()
|
|
214
|
+
expect({
|
|
215
|
+
overlaps: observed.events.indexOf('pool') < observed.events.indexOf('balance-complete'),
|
|
216
|
+
balanceStarted: observed.events.indexOf('balance') < observed.events.indexOf('pool'),
|
|
217
|
+
directReads: observed.pools.filter((call) => call === direct).length,
|
|
218
|
+
balances: observed.balances,
|
|
219
|
+
skippedMach: isAddressEqual(transaction.feeToken!, addresses.alphaUsd),
|
|
220
|
+
}).toMatchInlineSnapshot(`
|
|
221
|
+
{
|
|
222
|
+
"balanceStarted": true,
|
|
223
|
+
"balances": 2,
|
|
224
|
+
"directReads": 1,
|
|
225
|
+
"overlaps": true,
|
|
226
|
+
"skippedMach": true,
|
|
227
|
+
}
|
|
228
|
+
`)
|
|
229
|
+
})
|
|
230
|
+
|
|
231
|
+
test('fee liquidity: reads an unlisted on-chain preference without rediscovering balances', async () => {
|
|
232
|
+
const state = await fixture()
|
|
233
|
+
const observed = observe()
|
|
234
|
+
const client = await serve({
|
|
235
|
+
chains: [chain],
|
|
236
|
+
features: 'all',
|
|
237
|
+
resolveTokens: () =>
|
|
238
|
+
state.tokens.filter((token) => isAddressEqual(token.address, addresses.alphaUsd)),
|
|
239
|
+
transports: { [chain.id]: observed.transport },
|
|
240
|
+
})
|
|
241
|
+
const { transaction } = await fillTransaction(client, {
|
|
242
|
+
account: state.sender.address,
|
|
243
|
+
calls: state.calls,
|
|
244
|
+
})
|
|
245
|
+
expect({
|
|
246
|
+
balances: observed.balances,
|
|
247
|
+
skippedMach: isAddressEqual(transaction.feeToken!, addresses.alphaUsd),
|
|
248
|
+
}).toMatchInlineSnapshot(`
|
|
249
|
+
{
|
|
250
|
+
"balances": 2,
|
|
251
|
+
"skippedMach": true,
|
|
252
|
+
}
|
|
253
|
+
`)
|
|
254
|
+
})
|
|
255
|
+
|
|
256
|
+
test('fee liquidity: explicit fee tokens do not trigger user balance or reserve discovery', async () => {
|
|
257
|
+
const state = await fixture()
|
|
258
|
+
const observed = observe()
|
|
259
|
+
const client = await serve({
|
|
260
|
+
chains: [chain],
|
|
261
|
+
features: 'all',
|
|
262
|
+
resolveTokens: () => state.tokens,
|
|
263
|
+
transports: { [chain.id]: observed.transport },
|
|
264
|
+
})
|
|
265
|
+
const { transaction } = await fillTransaction(client, {
|
|
266
|
+
account: state.sender.address,
|
|
267
|
+
calls: state.calls,
|
|
268
|
+
feeToken: addresses.alphaUsd,
|
|
269
|
+
capabilities: { balanceDiffs: false },
|
|
270
|
+
})
|
|
271
|
+
expect({
|
|
272
|
+
balances: observed.balances,
|
|
273
|
+
pools: observed.pools.length,
|
|
274
|
+
preserved: isAddressEqual(transaction.feeToken!, addresses.alphaUsd),
|
|
275
|
+
}).toMatchInlineSnapshot(`
|
|
276
|
+
{
|
|
277
|
+
"balances": 0,
|
|
278
|
+
"pools": 0,
|
|
279
|
+
"preserved": true,
|
|
280
|
+
}
|
|
281
|
+
`)
|
|
282
|
+
})
|
|
283
|
+
|
|
284
|
+
test('fee liquidity: sponsored fills do not probe user balances or pools', async () => {
|
|
285
|
+
const state = await fixture()
|
|
286
|
+
const observed = observe()
|
|
287
|
+
const client = await serve({
|
|
288
|
+
chains: [chain],
|
|
289
|
+
features: 'all',
|
|
290
|
+
feePayer: { account: accounts[0]!, feeToken: Addresses.pathUsd },
|
|
291
|
+
resolveTokens: () => state.tokens,
|
|
292
|
+
transports: { [chain.id]: observed.transport },
|
|
293
|
+
})
|
|
294
|
+
const { transaction } = await fillTransaction(client, {
|
|
295
|
+
account: state.sender.address,
|
|
296
|
+
calls: state.calls,
|
|
297
|
+
capabilities: { balanceDiffs: false },
|
|
298
|
+
})
|
|
299
|
+
expect({
|
|
300
|
+
balances: observed.balances,
|
|
301
|
+
pools: observed.pools.length,
|
|
302
|
+
sponsorToken: isAddressEqual(transaction.feeToken!, Addresses.pathUsd),
|
|
303
|
+
}).toMatchInlineSnapshot(`
|
|
304
|
+
{
|
|
305
|
+
"balances": 0,
|
|
306
|
+
"pools": 0,
|
|
307
|
+
"sponsorToken": true,
|
|
308
|
+
}
|
|
309
|
+
`)
|
|
310
|
+
})
|
|
311
|
+
|
|
312
|
+
test('fee liquidity: reserve snapshots stay request-scoped after on-chain pool funding', async () => {
|
|
313
|
+
const state = await fixture()
|
|
314
|
+
const observed = observe()
|
|
315
|
+
const client = getClient({ transport: observed.transport })
|
|
316
|
+
const check = await FeeLiquidity.prepare(client, { token: state.token })
|
|
317
|
+
expect(await check(2057n)).toMatchInlineSnapshot(`false`)
|
|
318
|
+
await Actions.amm.mintSync(state.rpc, {
|
|
319
|
+
feeToken: Addresses.pathUsd,
|
|
320
|
+
userTokenAddress: state.token,
|
|
321
|
+
validatorTokenAddress: Addresses.pathUsd,
|
|
322
|
+
validatorTokenAmount: 2050n,
|
|
323
|
+
to: state.rpc.account.address,
|
|
324
|
+
})
|
|
325
|
+
const reads = observed.pools.length
|
|
326
|
+
expect({ stale: await check(2057n), rereads: observed.pools.length - reads })
|
|
327
|
+
.toMatchInlineSnapshot(`
|
|
328
|
+
{
|
|
329
|
+
"rereads": 0,
|
|
330
|
+
"stale": false,
|
|
331
|
+
}
|
|
332
|
+
`)
|
|
333
|
+
const fresh = await FeeLiquidity.prepare(client, { token: state.token })
|
|
334
|
+
expect({ covered: await fresh(2057n), insufficient: await fresh(2058n) }).toMatchInlineSnapshot(`
|
|
335
|
+
{
|
|
336
|
+
"covered": true,
|
|
337
|
+
"insufficient": false,
|
|
338
|
+
}
|
|
339
|
+
`)
|
|
340
|
+
})
|
|
341
|
+
|
|
342
|
+
test('fee liquidity: a real two-hop pool settles the selected fee token at broadcast', async () => {
|
|
343
|
+
const state = await fixture()
|
|
344
|
+
await Actions.amm.mintSync(state.rpc, {
|
|
345
|
+
feeToken: Addresses.pathUsd,
|
|
346
|
+
userTokenAddress: state.token,
|
|
347
|
+
validatorTokenAddress: addresses.alphaUsd,
|
|
348
|
+
validatorTokenAmount: parseUnits('1', 6),
|
|
349
|
+
to: state.rpc.account.address,
|
|
350
|
+
})
|
|
351
|
+
const client = await serve({
|
|
352
|
+
chains: [chain],
|
|
353
|
+
features: 'all',
|
|
354
|
+
resolveTokens: () => state.tokens,
|
|
355
|
+
transports: { [chain.id]: viem_http(rpcUrl) },
|
|
356
|
+
})
|
|
357
|
+
const { transaction } = await fillTransaction(client, {
|
|
358
|
+
account: state.sender.address,
|
|
359
|
+
calls: state.calls,
|
|
360
|
+
})
|
|
361
|
+
const receipt = await sendTransactionSync(getClient(), {
|
|
362
|
+
account: state.sender,
|
|
363
|
+
type: 'tempo',
|
|
364
|
+
calls: transaction.calls!,
|
|
365
|
+
feeToken: transaction.feeToken!,
|
|
366
|
+
gas: transaction.gas,
|
|
367
|
+
maxFeePerGas: transaction.maxFeePerGas,
|
|
368
|
+
maxPriorityFeePerGas: transaction.maxPriorityFeePerGas,
|
|
369
|
+
nonce: transaction.nonce,
|
|
370
|
+
})
|
|
371
|
+
expect({
|
|
372
|
+
selectedMach: isAddressEqual(transaction.feeToken!, state.token),
|
|
373
|
+
status: receipt.status,
|
|
374
|
+
}).toMatchInlineSnapshot(`
|
|
375
|
+
{
|
|
376
|
+
"selectedMach": true,
|
|
377
|
+
"status": "success",
|
|
378
|
+
}
|
|
379
|
+
`)
|
|
380
|
+
})
|
|
381
|
+
|
|
382
|
+
test('fee liquidity: validator tokens need no pool reads', async () => {
|
|
383
|
+
const observed = observe()
|
|
384
|
+
const client = getClient({ transport: observed.transport })
|
|
385
|
+
const kv = Kv.memory()
|
|
386
|
+
const checks = await Promise.all([
|
|
387
|
+
FeeLiquidity.prepare(client, { token: Addresses.pathUsd, kv }),
|
|
388
|
+
FeeLiquidity.prepare(client, { token: addresses.alphaUsd, kv }),
|
|
389
|
+
])
|
|
390
|
+
expect({ validator: await checks[0]!(206n), alpha: await checks[1]!(206n) })
|
|
391
|
+
.toMatchInlineSnapshot(`
|
|
392
|
+
{
|
|
393
|
+
"alpha": true,
|
|
394
|
+
"validator": true,
|
|
395
|
+
}
|
|
396
|
+
`)
|
|
397
|
+
expect(observed.events.filter((event) => event === 'producer').length).toMatchInlineSnapshot(`10`)
|
|
398
|
+
})
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { type Address, type Client, isAddressEqual, zeroAddress } from 'viem'
|
|
2
|
+
import { getBlock, readContract } from 'viem/actions'
|
|
3
|
+
import { Abis, Actions, Addresses, Hardfork } from 'viem/tempo'
|
|
4
|
+
|
|
5
|
+
import type * as Kv from '../../Kv.js'
|
|
6
|
+
import { cached } from '../kv.js'
|
|
7
|
+
|
|
8
|
+
/** Number of recent block producers the node's pool admission considers. */
|
|
9
|
+
const producerWindow = 10
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Checks whether the fee AMM can settle the fee for at least one recent block
|
|
13
|
+
* producer's preferred token. Mirrors the node's pool admission check, so a
|
|
14
|
+
* token is only rejected here when the node would reject it too.
|
|
15
|
+
*/
|
|
16
|
+
export async function has(client: Client, options: has.Options): Promise<boolean> {
|
|
17
|
+
const check = await prepare(client, options)
|
|
18
|
+
return check(options.amount)
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Reads fee reserves ahead of filling and returns a reusable maximum-fee check. */
|
|
22
|
+
export async function prepare(client: Client, options: prepare.Options) {
|
|
23
|
+
const { kv, token } = options
|
|
24
|
+
const validatorTokens = await getValidatorTokens(client, { kv })
|
|
25
|
+
if (validatorTokens.some((validatorToken) => isAddressEqual(validatorToken, token)))
|
|
26
|
+
return async (_amount: bigint) => true
|
|
27
|
+
|
|
28
|
+
const hardfork = (client.chain as { hardfork?: string } | undefined)?.hardfork
|
|
29
|
+
const twoHop = !hardfork || !Hardfork.lt(hardfork, 't5')
|
|
30
|
+
|
|
31
|
+
const [direct, quote] = await Promise.all([
|
|
32
|
+
Promise.all(
|
|
33
|
+
validatorTokens.map((validatorToken) =>
|
|
34
|
+
Actions.amm.getPool(client, { userToken: token, validatorToken }),
|
|
35
|
+
),
|
|
36
|
+
),
|
|
37
|
+
twoHop
|
|
38
|
+
? readContract(client, { address: token, abi: Abis.tip20, functionName: 'quoteToken' })
|
|
39
|
+
: undefined,
|
|
40
|
+
])
|
|
41
|
+
const targets = quote
|
|
42
|
+
? validatorTokens.filter((validatorToken) => !isAddressEqual(validatorToken, quote))
|
|
43
|
+
: []
|
|
44
|
+
const route =
|
|
45
|
+
quote && quote !== zeroAddress && targets.length > 0
|
|
46
|
+
? Promise.all([
|
|
47
|
+
Actions.amm.getPool(client, { userToken: token, validatorToken: quote }),
|
|
48
|
+
...targets.map((validatorToken) =>
|
|
49
|
+
Actions.amm.getPool(client, { userToken: quote, validatorToken }),
|
|
50
|
+
),
|
|
51
|
+
])
|
|
52
|
+
: undefined
|
|
53
|
+
void route?.catch(() => {})
|
|
54
|
+
return async (amount: bigint) => {
|
|
55
|
+
const output = (amount * 9970n) / 10000n
|
|
56
|
+
if (direct.some((pool) => pool.reserveValidatorToken >= output)) return true
|
|
57
|
+
if (!route) return false
|
|
58
|
+
const [first, ...second] = await route
|
|
59
|
+
if (first!.reserveValidatorToken < output) return false
|
|
60
|
+
return second.some((pool) => pool.reserveValidatorToken >= (output * 9970n) / 10000n)
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Parameters for prefetching a token's fee reserves. */
|
|
65
|
+
export declare namespace prepare {
|
|
66
|
+
/** Fee token and producer-preference cache. */
|
|
67
|
+
type Options = Pick<has.Options, 'token' | 'kv'>
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Parameters for the fee-liquidity check. */
|
|
71
|
+
export declare namespace has {
|
|
72
|
+
/** Fee token and maximum fee, in TIP-20 base units. */
|
|
73
|
+
type Options = {
|
|
74
|
+
/** User's fee token. */
|
|
75
|
+
token: Address
|
|
76
|
+
/** Maximum transaction fee, rounded up to microdollars. */
|
|
77
|
+
amount: bigint
|
|
78
|
+
/** Caches the recent producers' fee tokens across fills. */
|
|
79
|
+
kv?: Kv.Kv | undefined
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Resolves the unique fee tokens preferred by the producers of the latest blocks. */
|
|
84
|
+
async function getValidatorTokens(
|
|
85
|
+
client: Client,
|
|
86
|
+
options: { kv?: Kv.Kv | undefined },
|
|
87
|
+
): Promise<readonly Address[]> {
|
|
88
|
+
async function load() {
|
|
89
|
+
const latest = await getBlock(client)
|
|
90
|
+
const number = latest.number ?? 0n
|
|
91
|
+
const previous = await Promise.all(
|
|
92
|
+
Array.from({ length: producerWindow - 1 }, (_, index) => number - BigInt(index + 1))
|
|
93
|
+
.filter((blockNumber) => blockNumber >= 0n)
|
|
94
|
+
.map((blockNumber) => getBlock(client, { blockNumber })),
|
|
95
|
+
)
|
|
96
|
+
const validators = unique([latest, ...previous].map((block) => block.miner))
|
|
97
|
+
const tokens = await Promise.all(
|
|
98
|
+
validators.map(async (validator) => {
|
|
99
|
+
const preference = await Actions.fee.getValidatorToken(client, { validator })
|
|
100
|
+
return preference?.address ?? Addresses.pathUsd
|
|
101
|
+
}),
|
|
102
|
+
)
|
|
103
|
+
return unique(tokens)
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Producer preferences rarely change, and a stale entry only delays picking
|
|
107
|
+
// up a new producer's token until the cache expires.
|
|
108
|
+
const { kv } = options
|
|
109
|
+
if (!kv) return load()
|
|
110
|
+
return cached(kv, `fee.validatorTokens:${client.chain?.id ?? 0}`, load, { ttl: 60 })
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function unique(addresses: readonly Address[]): Address[] {
|
|
114
|
+
const seen = new Map<string, Address>()
|
|
115
|
+
for (const address of addresses) seen.set(address.toLowerCase(), address)
|
|
116
|
+
return [...seen.values()]
|
|
117
|
+
}
|
|
@@ -938,7 +938,11 @@ describe('behavior: capabilities', () => {
|
|
|
938
938
|
amount: parseUnits('1000', 6),
|
|
939
939
|
to: sender.address,
|
|
940
940
|
})
|
|
941
|
-
await
|
|
941
|
+
await sendTransactionSync(getClient({ account: sender }), {
|
|
942
|
+
feeToken: addresses.alphaUsd,
|
|
943
|
+
calls: [Actions.fee.setUserToken.call({ token: addresses.alphaUsd })],
|
|
944
|
+
throwOnReceiptRevert: true,
|
|
945
|
+
})
|
|
942
946
|
|
|
943
947
|
const buyAmount = parseUnits('10', 6)
|
|
944
948
|
const result = await fillTransaction(client, {
|
|
@@ -965,6 +969,9 @@ describe('behavior: capabilities', () => {
|
|
|
965
969
|
const meta = result.capabilities
|
|
966
970
|
|
|
967
971
|
const diffs = findDiffs(meta?.balanceDiffs, sender.address)!
|
|
972
|
+
expect(result.transaction.feeToken).toMatchInlineSnapshot(
|
|
973
|
+
`"0x20c0000000000000000000000000000000000001"`,
|
|
974
|
+
)
|
|
968
975
|
expect(diffs).toHaveLength(1)
|
|
969
976
|
|
|
970
977
|
const quoteDiff = diffs[0]!
|
|
@@ -1698,7 +1705,7 @@ describe('behavior: fee token resolution', () => {
|
|
|
1698
1705
|
expect(transaction.feeToken).toBe(preferredToken)
|
|
1699
1706
|
})
|
|
1700
1707
|
|
|
1701
|
-
test('behavior:
|
|
1708
|
+
test('behavior: skips highest-balance token until its fee pool has liquidity', async () => {
|
|
1702
1709
|
// Create a fresh TIP20 token "betaUsd" so we can test highest-balance
|
|
1703
1710
|
// selection without depending on a pre-deployed second token.
|
|
1704
1711
|
const freshAccount = accounts[5]!
|
|
@@ -1751,10 +1758,23 @@ describe('behavior: fee token resolution', () => {
|
|
|
1751
1758
|
account: freshAccount.address,
|
|
1752
1759
|
calls: [transferCall()],
|
|
1753
1760
|
})
|
|
1754
|
-
|
|
1761
|
+
// betaUsd has the larger balance but no fee pool yet.
|
|
1762
|
+
expect(transaction.feeToken?.toLowerCase()).toBe(addresses.alphaUsd.toLowerCase())
|
|
1755
1763
|
|
|
1756
|
-
|
|
1757
|
-
|
|
1764
|
+
await Actions.amm.mintSync(rpc, {
|
|
1765
|
+
account: accounts[0]!,
|
|
1766
|
+
feeToken: Addresses.pathUsd,
|
|
1767
|
+
userTokenAddress: betaUsd,
|
|
1768
|
+
validatorTokenAddress: Addresses.pathUsd,
|
|
1769
|
+
validatorTokenAmount: parseUnits('1', 6),
|
|
1770
|
+
to: accounts[0]!.address,
|
|
1771
|
+
})
|
|
1772
|
+
const funded = await fillTransaction(customClient, {
|
|
1773
|
+
account: freshAccount.address,
|
|
1774
|
+
calls: [transferCall()],
|
|
1775
|
+
})
|
|
1776
|
+
customServer.close()
|
|
1777
|
+
expect(funded.transaction.feeToken?.toLowerCase()).toBe(betaUsd.toLowerCase())
|
|
1758
1778
|
})
|
|
1759
1779
|
|
|
1760
1780
|
test('behavior: falls back to pathUSD when no preference or balances', async () => {
|
|
@@ -1970,13 +1990,16 @@ describe('behavior: mainnet autoSwap with USDC.e → PathUSD', () => {
|
|
|
1970
1990
|
const mainnetUsdce = '0x20c000000000000000000000b9537d11c60e8b50' as const
|
|
1971
1991
|
const mainnetPathUsd = '0x20c0000000000000000000000000000000000000' as const
|
|
1972
1992
|
const mainnetSender = '0xb472f3ca15f34Db22d43FA503043F1e6541AC085' as const
|
|
1993
|
+
const mainnetRpcUrl = 'https://rpc.presto.tempo.xyz'
|
|
1994
|
+
// Keep the swap small: this runs against live mainnet DEX liquidity.
|
|
1995
|
+
const amount = parseUnits('0.001', 6)
|
|
1973
1996
|
|
|
1974
1997
|
beforeAll(async () => {
|
|
1975
1998
|
server = await createServer(
|
|
1976
1999
|
relay({
|
|
1977
2000
|
chains: [tempo],
|
|
1978
2001
|
features: 'all',
|
|
1979
|
-
transports: { [tempo.id]: http(
|
|
2002
|
+
transports: { [tempo.id]: http(mainnetRpcUrl) },
|
|
1980
2003
|
}).listener,
|
|
1981
2004
|
)
|
|
1982
2005
|
mainnetClient = getClient({ chain: tempo, transport: http(server.url) })
|
|
@@ -1986,14 +2009,27 @@ describe('behavior: mainnet autoSwap with USDC.e → PathUSD', () => {
|
|
|
1986
2009
|
server.close()
|
|
1987
2010
|
})
|
|
1988
2011
|
|
|
1989
|
-
test('behavior: auto-swaps USDC.e → PathUSD when sender has USDC.e but no PathUSD', async () => {
|
|
2012
|
+
test('behavior: auto-swaps USDC.e → PathUSD when sender has USDC.e but no PathUSD', async (context) => {
|
|
2013
|
+
// Skip rather than fail when live mainnet DEX liquidity can't cover the swap.
|
|
2014
|
+
const quotable = await Actions.dex
|
|
2015
|
+
.getBuyQuote(getClient({ chain: tempo, transport: http(mainnetRpcUrl) }), {
|
|
2016
|
+
amountOut: amount,
|
|
2017
|
+
tokenIn: mainnetUsdce,
|
|
2018
|
+
tokenOut: mainnetPathUsd,
|
|
2019
|
+
})
|
|
2020
|
+
.then(
|
|
2021
|
+
() => true,
|
|
2022
|
+
() => false,
|
|
2023
|
+
)
|
|
2024
|
+
if (!quotable) context.skip('mainnet DEX has insufficient USDC.e → PathUSD liquidity')
|
|
2025
|
+
|
|
1990
2026
|
const result = await fillTransaction(mainnetClient, {
|
|
1991
2027
|
account: mainnetSender,
|
|
1992
2028
|
calls: [
|
|
1993
2029
|
Actions.token.transfer.call({
|
|
1994
2030
|
token: mainnetPathUsd,
|
|
1995
2031
|
to: recipient.address,
|
|
1996
|
-
amount
|
|
2032
|
+
amount,
|
|
1997
2033
|
}),
|
|
1998
2034
|
],
|
|
1999
2035
|
})
|