accounts 0.8.6 → 0.8.8
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 +14 -0
- package/dist/core/Schema.d.ts +2 -0
- package/dist/core/Schema.d.ts.map +1 -1
- package/dist/core/Storage.js +1 -1
- package/dist/core/Storage.js.map +1 -1
- package/dist/core/zod/rpc.d.ts +2 -0
- package/dist/core/zod/rpc.d.ts.map +1 -1
- package/dist/core/zod/rpc.js +2 -0
- package/dist/core/zod/rpc.js.map +1 -1
- package/dist/server/internal/handlers/exchange.d.ts.map +1 -1
- package/dist/server/internal/handlers/exchange.js +5 -19
- package/dist/server/internal/handlers/exchange.js.map +1 -1
- package/dist/server/internal/handlers/relay.d.ts +20 -12
- package/dist/server/internal/handlers/relay.d.ts.map +1 -1
- package/dist/server/internal/handlers/relay.js +152 -84
- package/dist/server/internal/handlers/relay.js.map +1 -1
- package/dist/server/internal/kv.d.ts +7 -4
- package/dist/server/internal/kv.d.ts.map +1 -1
- package/dist/server/internal/kv.js +70 -11
- package/dist/server/internal/kv.js.map +1 -1
- package/dist/server/internal/tokenlist.d.ts +53 -0
- package/dist/server/internal/tokenlist.d.ts.map +1 -0
- package/dist/server/internal/tokenlist.js +38 -0
- package/dist/server/internal/tokenlist.js.map +1 -0
- package/package.json +1 -1
- package/src/core/Storage.ts +1 -1
- package/src/core/zod/rpc.ts +2 -0
- package/src/server/internal/handlers/exchange.ts +7 -31
- package/src/server/internal/handlers/relay.test.ts +61 -15
- package/src/server/internal/handlers/relay.ts +184 -94
- package/src/server/internal/kv.test.ts +129 -0
- package/src/server/internal/kv.ts +76 -10
- package/src/server/internal/tokenlist.ts +91 -0
|
@@ -15,15 +15,20 @@ import {
|
|
|
15
15
|
} from 'viem'
|
|
16
16
|
import type { LocalAccount } from 'viem/accounts'
|
|
17
17
|
import { simulateCalls } from 'viem/actions'
|
|
18
|
-
import { tempo, tempoDevnet,
|
|
18
|
+
import { tempo, tempoDevnet, tempoModerato } from 'viem/chains'
|
|
19
19
|
import { Abis, Actions, Addresses, Capabilities, Transaction } from 'viem/tempo'
|
|
20
20
|
|
|
21
21
|
import * as ExecutionError from '../../../core/ExecutionError.js'
|
|
22
22
|
import * as Schema from '../../../core/Schema.js'
|
|
23
23
|
import { type Handler, from } from '../../Handler.js'
|
|
24
|
+
import * as Kv from '../../Kv.js'
|
|
25
|
+
import * as Tokenlist from '../tokenlist.js'
|
|
24
26
|
import * as Sponsorship from './sponsorship.js'
|
|
25
27
|
import * as Utils from './utils.js'
|
|
26
28
|
|
|
29
|
+
/** Default cache TTL in seconds (10 minutes) for the verified tokenlist. */
|
|
30
|
+
const defaultCacheTtl = 10 * 60
|
|
31
|
+
|
|
27
32
|
/**
|
|
28
33
|
* Instantiates a relay handler that proxies `eth_fillTransaction`
|
|
29
34
|
* with wallet-aware enrichment (fee token resolution, simulation,
|
|
@@ -64,16 +69,25 @@ import * as Utils from './utils.js'
|
|
|
64
69
|
*/
|
|
65
70
|
export function relay(options: relay.Options = {}): Handler {
|
|
66
71
|
const {
|
|
72
|
+
cacheTtl = defaultCacheTtl,
|
|
67
73
|
chains = [tempo, tempoModerato, tempoDevnet],
|
|
74
|
+
kv = Kv.memory(),
|
|
68
75
|
onRequest,
|
|
69
76
|
path = '/',
|
|
70
|
-
resolveTokens
|
|
71
|
-
relay.defaultTokens[chainId as keyof typeof relay.defaultTokens] ?? [],
|
|
77
|
+
resolveTokens,
|
|
72
78
|
transports = {},
|
|
73
79
|
...rest
|
|
74
80
|
} = options
|
|
75
81
|
const feePayerOptions = options.feePayer
|
|
76
82
|
|
|
83
|
+
// Resolves the verified tokenlist for `chainId`, sharing the KV cache with
|
|
84
|
+
// `Handler.exchange` so a single `kv: Kv.cloudflare(env.KV)` covers both.
|
|
85
|
+
// The relay only needs addresses, so map down from the full metadata.
|
|
86
|
+
const getTokens = async (chainId: number): Promise<readonly Address[]> => {
|
|
87
|
+
const tokens = await Tokenlist.fetch(chainId, kv, { cacheTtl, resolver: resolveTokens })
|
|
88
|
+
return tokens.map((t) => t.address)
|
|
89
|
+
}
|
|
90
|
+
|
|
77
91
|
const features = {
|
|
78
92
|
autoSwap: options.autoSwap ?? options.features === 'all',
|
|
79
93
|
feeTokenResolution: options.resolveTokens ?? options.features === 'all',
|
|
@@ -149,16 +163,29 @@ export function relay(options: relay.Options = {}): Handler {
|
|
|
149
163
|
|
|
150
164
|
// Lazily resolve a swap source token when autoSwap needs one.
|
|
151
165
|
const resolveFeeTokenForSwap = from
|
|
152
|
-
? (insufficientToken: Address) =>
|
|
166
|
+
? async (insufficientToken: Address) =>
|
|
153
167
|
resolveFeeToken(client, {
|
|
154
168
|
account: from,
|
|
155
169
|
feeToken: undefined,
|
|
156
|
-
tokens: (
|
|
170
|
+
tokens: (await getTokens(chainId)).filter(
|
|
157
171
|
(t) => t.toLowerCase() !== insufficientToken.toLowerCase(),
|
|
158
172
|
),
|
|
159
173
|
})
|
|
160
174
|
: undefined
|
|
161
175
|
|
|
176
|
+
// When no sponsor will pay, prefer fee tokens the user actually
|
|
177
|
+
// holds. Extend the configured token list with any TIP20 token
|
|
178
|
+
// the transaction is calling — typically the token being
|
|
179
|
+
// transferred — so a user transferring USDC.e can pay gas in
|
|
180
|
+
// USDC.e even when the configured list defaults to pathUSD.
|
|
181
|
+
const configuredTokens = await getTokens(chainId)
|
|
182
|
+
const unsponsoredTokens = [
|
|
183
|
+
...configuredTokens,
|
|
184
|
+
...callTargetTokens(baseTx).filter(
|
|
185
|
+
(t) => !configuredTokens.some((rt) => rt.toLowerCase() === t.toLowerCase()),
|
|
186
|
+
),
|
|
187
|
+
]
|
|
188
|
+
|
|
162
189
|
// When the app provides its own fee payer URL, route the fill
|
|
163
190
|
// through that service so it can sign the transaction.
|
|
164
191
|
const fillClient = externalFeePayerUrl
|
|
@@ -205,14 +232,30 @@ export function relay(options: relay.Options = {}): Handler {
|
|
|
205
232
|
})
|
|
206
233
|
filled = prepared
|
|
207
234
|
} else {
|
|
235
|
+
// Resolve a fee token for the unsponsored fallback so the
|
|
236
|
+
// node doesn't pick one the user has zero balance of.
|
|
237
|
+
const unsponsoredFeeToken = features.feeTokenResolution
|
|
238
|
+
? await resolveFeeToken(client, {
|
|
239
|
+
account: from,
|
|
240
|
+
feeToken: requestFeeToken,
|
|
241
|
+
tokens: unsponsoredTokens,
|
|
242
|
+
})
|
|
243
|
+
: requestFeeToken
|
|
244
|
+
const unsponsoredTx = {
|
|
245
|
+
...baseTx,
|
|
246
|
+
...(unsponsoredFeeToken ? { feeToken: unsponsoredFeeToken } : {}),
|
|
247
|
+
}
|
|
208
248
|
const fillOptions = {
|
|
209
249
|
autoSwap,
|
|
210
|
-
feeToken,
|
|
211
250
|
resolveFeeToken: resolveFeeTokenForSwap,
|
|
212
251
|
}
|
|
213
252
|
const [sponsoredFill, unsponsoredFill] = await Promise.all([
|
|
214
|
-
fill(fillClient, { ...fillOptions, transaction: sponsoredTx }),
|
|
215
|
-
fill(client, {
|
|
253
|
+
fill(fillClient, { ...fillOptions, feeToken, transaction: sponsoredTx }),
|
|
254
|
+
fill(client, {
|
|
255
|
+
...fillOptions,
|
|
256
|
+
feeToken: unsponsoredFeeToken,
|
|
257
|
+
transaction: unsponsoredTx,
|
|
258
|
+
}),
|
|
216
259
|
])
|
|
217
260
|
sponsored = await Sponsorship.shouldSponsor({
|
|
218
261
|
sender: from,
|
|
@@ -220,6 +263,7 @@ export function relay(options: relay.Options = {}): Handler {
|
|
|
220
263
|
validate: feePayerOptions!.validate,
|
|
221
264
|
})
|
|
222
265
|
filled = sponsored ? sponsoredFill : unsponsoredFill
|
|
266
|
+
if (!sponsored) feeToken = unsponsoredFeeToken
|
|
223
267
|
}
|
|
224
268
|
} else {
|
|
225
269
|
// Path C: no sponsorship configured — resolve fee token, fill once.
|
|
@@ -227,7 +271,7 @@ export function relay(options: relay.Options = {}): Handler {
|
|
|
227
271
|
? await resolveFeeToken(client, {
|
|
228
272
|
account: from,
|
|
229
273
|
feeToken: requestFeeToken,
|
|
230
|
-
tokens:
|
|
274
|
+
tokens: unsponsoredTokens,
|
|
231
275
|
})
|
|
232
276
|
: requestFeeToken
|
|
233
277
|
const transaction = { ...baseTx, ...(feeToken ? { feeToken } : {}) }
|
|
@@ -243,7 +287,8 @@ export function relay(options: relay.Options = {}): Handler {
|
|
|
243
287
|
const swap = 'swap' in filled ? filled.swap : undefined
|
|
244
288
|
if (!feeToken)
|
|
245
289
|
feeToken =
|
|
246
|
-
(transaction_filled.feeToken as Address | undefined) ??
|
|
290
|
+
(transaction_filled.feeToken as Address | undefined) ??
|
|
291
|
+
(await getTokens(chainId))[0]
|
|
247
292
|
|
|
248
293
|
// Parallelize: simulate, fee payer signing, and autoSwap metadata.
|
|
249
294
|
const alreadySigned =
|
|
@@ -448,45 +493,6 @@ export function relay(options: relay.Options = {}): Handler {
|
|
|
448
493
|
}
|
|
449
494
|
|
|
450
495
|
export namespace relay {
|
|
451
|
-
/** Default token lists per chain ID for fee token resolution. */
|
|
452
|
-
// TODO: extract from tokenlist workspace.
|
|
453
|
-
export const defaultTokens = {
|
|
454
|
-
[tempoMainnet.id]: [
|
|
455
|
-
'0x20c0000000000000000000000000000000000000', // pathUSD
|
|
456
|
-
'0x20c000000000000000000000b9537d11c60e8b50', // USDC.e
|
|
457
|
-
'0x20c0000000000000000000001621e21f71cf12fb', // EURC.e
|
|
458
|
-
'0x20c00000000000000000000014f22ca97301eb73', // USDT0
|
|
459
|
-
'0x20c0000000000000000000003554d28269e0f3c2', // frxUSD
|
|
460
|
-
'0x20c0000000000000000000000520792dcccccccc', // cUSD
|
|
461
|
-
'0x20c0000000000000000000008ee4fcff88888888', // stcUSD
|
|
462
|
-
'0x20c0000000000000000000005c0bac7cef389a11', // GUSD
|
|
463
|
-
'0x20c0000000000000000000007f7ba549dd0251b9', // rUSD
|
|
464
|
-
'0x20c000000000000000000000aeed2ec36a54d0e5', // wsrUSD
|
|
465
|
-
'0x20c0000000000000000000009a4a4b17e0dc6651', // EURAU
|
|
466
|
-
'0x20c000000000000000000000383a23bacb546ab9', // reUSD
|
|
467
|
-
],
|
|
468
|
-
[tempoModerato.id]: [
|
|
469
|
-
'0x20c0000000000000000000000000000000000000', // pathUSD
|
|
470
|
-
'0x20c0000000000000000000000000000000000001', // alphaUSD
|
|
471
|
-
'0x20c0000000000000000000000000000000000002', // betaUSD
|
|
472
|
-
'0x20c0000000000000000000000000000000000003', // thetaUSD
|
|
473
|
-
'0x20c0000000000000000000009e8d7eb59b783726', // USDC.e
|
|
474
|
-
'0x20c000000000000000000000d72572838bbee59c', // EURC.e
|
|
475
|
-
],
|
|
476
|
-
[tempoDevnet.id]: [
|
|
477
|
-
'0x20c0000000000000000000000000000000000000', // pathUSD
|
|
478
|
-
'0x20c0000000000000000000000000000000000001', // alphaUSD
|
|
479
|
-
'0x20c0000000000000000000000000000000000002', // betaUSD
|
|
480
|
-
'0x20c0000000000000000000000000000000000003', // thetaUSD
|
|
481
|
-
],
|
|
482
|
-
[tempoLocalnet.id]: [
|
|
483
|
-
'0x20c0000000000000000000000000000000000000', // pathUSD
|
|
484
|
-
'0x20c0000000000000000000000000000000000001', // alphaUSD
|
|
485
|
-
'0x20c0000000000000000000000000000000000002', // betaUSD
|
|
486
|
-
'0x20c0000000000000000000000000000000000003', // thetaUSD
|
|
487
|
-
],
|
|
488
|
-
} as const
|
|
489
|
-
|
|
490
496
|
export type Options = from.Options & {
|
|
491
497
|
/**
|
|
492
498
|
* Auto-swap options.
|
|
@@ -498,6 +504,11 @@ export namespace relay {
|
|
|
498
504
|
slippage?: number | undefined
|
|
499
505
|
}
|
|
500
506
|
| undefined
|
|
507
|
+
/**
|
|
508
|
+
* TTL in seconds for cached `resolveTokens` results.
|
|
509
|
+
* @default 600 (10 minutes)
|
|
510
|
+
*/
|
|
511
|
+
cacheTtl?: number | undefined
|
|
501
512
|
/**
|
|
502
513
|
* Supported chains. The handler resolves the client based on the
|
|
503
514
|
* `chainId` in the incoming transaction.
|
|
@@ -526,11 +537,21 @@ export namespace relay {
|
|
|
526
537
|
}
|
|
527
538
|
| undefined
|
|
528
539
|
/**
|
|
529
|
-
*
|
|
530
|
-
*
|
|
531
|
-
*
|
|
540
|
+
* Kv store used to cache `resolveTokens` results across requests.
|
|
541
|
+
* Provide `Kv.cloudflare(env.KV)` for cross-instance caching, or omit
|
|
542
|
+
* for an in-process LRU.
|
|
543
|
+
* @default Kv.memory()
|
|
532
544
|
*/
|
|
533
|
-
|
|
545
|
+
kv?: Kv.Kv | undefined
|
|
546
|
+
/**
|
|
547
|
+
* Resolves the list of known tokens for a chain. The relay checks
|
|
548
|
+
* `balanceOf` for each token and picks the one with the highest balance
|
|
549
|
+
* during fee token resolution.
|
|
550
|
+
* @default Fetches `https://tokenlist.tempo.xyz/list/:chainId`
|
|
551
|
+
*/
|
|
552
|
+
resolveTokens?:
|
|
553
|
+
| ((chainId: number) => readonly Tokenlist.Token[] | Promise<readonly Tokenlist.Token[]>)
|
|
554
|
+
| undefined
|
|
534
555
|
/**
|
|
535
556
|
* Relay features.
|
|
536
557
|
*
|
|
@@ -564,6 +585,51 @@ async function fill(
|
|
|
564
585
|
const format = (value: Record<string, unknown>) =>
|
|
565
586
|
value.type === '0x76' ? value : Utils.formatFillTransactionRequest(client, value)
|
|
566
587
|
|
|
588
|
+
// Re-fill the transaction with prepended swap calls so the user can
|
|
589
|
+
// mint the missing `insufficientToken` (typically the fee token) from
|
|
590
|
+
// a token they already hold. Returns null if no source token is
|
|
591
|
+
// available or autoSwap is disabled.
|
|
592
|
+
async function fillWithSwap(insufficientToken: Address, deficit: bigint) {
|
|
593
|
+
if (!autoSwap) return null
|
|
594
|
+
const sourceToken =
|
|
595
|
+
feeToken && feeToken.toLowerCase() !== insufficientToken.toLowerCase()
|
|
596
|
+
? feeToken
|
|
597
|
+
: await options.resolveFeeToken?.(insufficientToken)
|
|
598
|
+
if (!sourceToken || sourceToken.toLowerCase() === insufficientToken.toLowerCase()) return null
|
|
599
|
+
|
|
600
|
+
const maxAmountIn = deficit + (deficit * BigInt(Math.round(autoSwap.slippage * 1000))) / 1000n
|
|
601
|
+
const originalCalls = (request.calls as Call[] | undefined) ?? []
|
|
602
|
+
const swapCalls = buildSwapCalls(sourceToken, insufficientToken, deficit, maxAmountIn)
|
|
603
|
+
|
|
604
|
+
const result = await client.request({
|
|
605
|
+
method: 'eth_fillTransaction',
|
|
606
|
+
params: [
|
|
607
|
+
format({
|
|
608
|
+
...request,
|
|
609
|
+
calls: [...swapCalls, ...originalCalls],
|
|
610
|
+
}) as never,
|
|
611
|
+
],
|
|
612
|
+
})
|
|
613
|
+
const sponsor = (result as Record<string, any>).capabilities?.sponsor as
|
|
614
|
+
| { address: Address; name?: string; url?: string }
|
|
615
|
+
| undefined
|
|
616
|
+
const mergedTx = mergeCallsFromRequest(result.tx as Record<string, unknown>, {
|
|
617
|
+
...request,
|
|
618
|
+
calls: [...swapCalls, ...originalCalls],
|
|
619
|
+
})
|
|
620
|
+
return {
|
|
621
|
+
transaction: Utils.normalizeTempoTransaction(mergedTx),
|
|
622
|
+
sponsor,
|
|
623
|
+
swap: {
|
|
624
|
+
calls: swapCalls,
|
|
625
|
+
tokenIn: sourceToken,
|
|
626
|
+
tokenOut: insufficientToken,
|
|
627
|
+
amountOut: deficit,
|
|
628
|
+
maxAmountIn,
|
|
629
|
+
},
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
|
|
567
633
|
try {
|
|
568
634
|
const formatted = format(request)
|
|
569
635
|
const result = await client.request({
|
|
@@ -602,6 +668,46 @@ async function fill(
|
|
|
602
668
|
// them in from the original request before normalizing — otherwise the
|
|
603
669
|
// typed envelope built for sponsorship signing throws CallsEmptyError.
|
|
604
670
|
const mergedTx = mergeCallsFromRequest(result.tx as Record<string, unknown>, request)
|
|
671
|
+
|
|
672
|
+
// The node's `eth_fillTransaction` may pick a fee token the user
|
|
673
|
+
// doesn't yet hold (e.g. one this transaction will mint via a
|
|
674
|
+
// swap). Tempo's gas check runs before the calls execute, so the
|
|
675
|
+
// tx would revert at broadcast with `have 0 want N`. Validate the
|
|
676
|
+
// sender's pre-tx balance against the computed gas cost and, if
|
|
677
|
+
// short, autoSwap a token they do hold into the fee token.
|
|
678
|
+
if (autoSwap && !swap) {
|
|
679
|
+
const fromAddress = request.from as Address | undefined
|
|
680
|
+
const resolvedFeeToken = ((mergedTx.feeToken as Address | undefined) ?? feeToken) as
|
|
681
|
+
| Address
|
|
682
|
+
| undefined
|
|
683
|
+
const gas = mergedTx.gas ? BigInt(mergedTx.gas as `0x${string}`) : 0n
|
|
684
|
+
const maxFeePerGas = mergedTx.maxFeePerGas
|
|
685
|
+
? BigInt(mergedTx.maxFeePerGas as `0x${string}`)
|
|
686
|
+
: 0n
|
|
687
|
+
if (fromAddress && resolvedFeeToken && gas > 0n && maxFeePerGas > 0n) {
|
|
688
|
+
const [balance, metadata] = await Promise.all([
|
|
689
|
+
Actions.token
|
|
690
|
+
.getBalance(client, { account: fromAddress, token: resolvedFeeToken })
|
|
691
|
+
.catch(() => 0n),
|
|
692
|
+
resolveTokenMetadata(client, resolvedFeeToken).catch(() => undefined),
|
|
693
|
+
])
|
|
694
|
+
if (metadata) {
|
|
695
|
+
const requiredFee =
|
|
696
|
+
(gas * maxFeePerGas) / 10n ** BigInt(Math.max(0, 18 - metadata.decimals))
|
|
697
|
+
if (balance < requiredFee) {
|
|
698
|
+
// Best-effort: if the swap itself can't be filled (e.g. the
|
|
699
|
+
// source token also has insufficient balance), fall through
|
|
700
|
+
// and return the original tx with the resolved feeToken
|
|
701
|
+
// rather than failing the whole request.
|
|
702
|
+
const swapResult = await fillWithSwap(resolvedFeeToken, requiredFee - balance).catch(
|
|
703
|
+
() => null,
|
|
704
|
+
)
|
|
705
|
+
if (swapResult) return swapResult
|
|
706
|
+
}
|
|
707
|
+
}
|
|
708
|
+
}
|
|
709
|
+
}
|
|
710
|
+
|
|
605
711
|
return {
|
|
606
712
|
transaction: Utils.normalizeTempoTransaction(mergedTx),
|
|
607
713
|
sponsor,
|
|
@@ -617,47 +723,9 @@ async function fill(
|
|
|
617
723
|
const [available, required, token] = revert.args
|
|
618
724
|
if (typeof available === 'undefined' || typeof required === 'undefined' || !token) throw error
|
|
619
725
|
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
feeToken && feeToken.toLowerCase() !== token.toLowerCase()
|
|
624
|
-
? feeToken
|
|
625
|
-
: await options.resolveFeeToken?.(token as Address)
|
|
626
|
-
if (!sourceToken || sourceToken.toLowerCase() === token.toLowerCase()) throw error
|
|
627
|
-
|
|
628
|
-
const deficit = required - available
|
|
629
|
-
const maxAmountIn = deficit + (deficit * BigInt(Math.round(autoSwap.slippage * 1000))) / 1000n
|
|
630
|
-
|
|
631
|
-
const originalCalls = (request.calls as Call[] | undefined) ?? []
|
|
632
|
-
const swapCalls = buildSwapCalls(sourceToken, token, deficit, maxAmountIn)
|
|
633
|
-
|
|
634
|
-
const result = await client.request({
|
|
635
|
-
method: 'eth_fillTransaction',
|
|
636
|
-
params: [
|
|
637
|
-
format({
|
|
638
|
-
...request,
|
|
639
|
-
calls: [...swapCalls, ...originalCalls],
|
|
640
|
-
}) as never,
|
|
641
|
-
],
|
|
642
|
-
})
|
|
643
|
-
const sponsor = (result as Record<string, any>).capabilities?.sponsor as
|
|
644
|
-
| { address: Address; name?: string; url?: string }
|
|
645
|
-
| undefined
|
|
646
|
-
const mergedTx = mergeCallsFromRequest(result.tx as Record<string, unknown>, {
|
|
647
|
-
...request,
|
|
648
|
-
calls: [...swapCalls, ...originalCalls],
|
|
649
|
-
})
|
|
650
|
-
return {
|
|
651
|
-
transaction: Utils.normalizeTempoTransaction(mergedTx),
|
|
652
|
-
sponsor,
|
|
653
|
-
swap: {
|
|
654
|
-
calls: swapCalls,
|
|
655
|
-
tokenIn: sourceToken,
|
|
656
|
-
tokenOut: token,
|
|
657
|
-
amountOut: deficit,
|
|
658
|
-
maxAmountIn,
|
|
659
|
-
},
|
|
660
|
-
}
|
|
726
|
+
const swapResult = await fillWithSwap(token as Address, required - available)
|
|
727
|
+
if (!swapResult) throw error
|
|
728
|
+
return swapResult
|
|
661
729
|
}
|
|
662
730
|
}
|
|
663
731
|
|
|
@@ -713,6 +781,28 @@ async function resolveFeeToken(
|
|
|
713
781
|
if (best) return best.address
|
|
714
782
|
}
|
|
715
783
|
|
|
784
|
+
/**
|
|
785
|
+
* Extracts unique TIP20 token addresses (Tempo `0x20c0…` prefix) that the
|
|
786
|
+
* transaction's calls target. Used as fallback fee-token candidates so a
|
|
787
|
+
* user transferring a token they hold can pay gas in that token even when
|
|
788
|
+
* the configured fee-token list defaults to one they don't hold.
|
|
789
|
+
*/
|
|
790
|
+
function callTargetTokens(transaction: Record<string, unknown>): readonly Address[] {
|
|
791
|
+
const calls = transaction.calls as readonly { to?: Address }[] | undefined
|
|
792
|
+
if (!calls) return []
|
|
793
|
+
const out: Address[] = []
|
|
794
|
+
const seen = new Set<string>()
|
|
795
|
+
for (const c of calls) {
|
|
796
|
+
if (!c.to) continue
|
|
797
|
+
const lower = c.to.toLowerCase()
|
|
798
|
+
if (!lower.startsWith('0x20c0')) continue
|
|
799
|
+
if (seen.has(lower)) continue
|
|
800
|
+
seen.add(lower)
|
|
801
|
+
out.push(c.to)
|
|
802
|
+
}
|
|
803
|
+
return out
|
|
804
|
+
}
|
|
805
|
+
|
|
716
806
|
// TODO: cleanup/remove
|
|
717
807
|
function extractCalls(transaction: Record<string, unknown>): readonly Call[] {
|
|
718
808
|
const calls = transaction.calls as readonly Call[] | undefined
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import { describe, expect, test } from 'vp/test'
|
|
2
|
+
|
|
3
|
+
import * as Kv from '../Kv.js'
|
|
4
|
+
import { cached } from './kv.js'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Wraps a memory Kv and records the number of `get`/`set` calls per key —
|
|
8
|
+
* lets the tests below assert that L1 cache hits short-circuit L2 reads.
|
|
9
|
+
*/
|
|
10
|
+
function counting() {
|
|
11
|
+
const inner = Kv.memory()
|
|
12
|
+
const calls = { get: 0, set: 0 }
|
|
13
|
+
const kv: Kv.Kv = {
|
|
14
|
+
async get(key) {
|
|
15
|
+
calls.get++
|
|
16
|
+
return inner.get(key)
|
|
17
|
+
},
|
|
18
|
+
async set(key, value) {
|
|
19
|
+
calls.set++
|
|
20
|
+
return inner.set(key, value)
|
|
21
|
+
},
|
|
22
|
+
async delete(key) {
|
|
23
|
+
return inner.delete(key)
|
|
24
|
+
},
|
|
25
|
+
}
|
|
26
|
+
return { calls, kv }
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
describe('cached', () => {
|
|
30
|
+
test('behavior: caches the result of fn for the configured ttl', async () => {
|
|
31
|
+
const { calls, kv } = counting()
|
|
32
|
+
let invocations = 0
|
|
33
|
+
const fn = async () => ++invocations
|
|
34
|
+
|
|
35
|
+
expect(await cached(kv, 'k', fn, { ttl: 60 })).toBe(1)
|
|
36
|
+
expect(await cached(kv, 'k', fn, { ttl: 60 })).toBe(1)
|
|
37
|
+
expect(invocations).toBe(1)
|
|
38
|
+
// First call writes through to L2; subsequent reads stay in L1.
|
|
39
|
+
expect(calls.set).toBe(1)
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
test('behavior: re-fetches after the entry expires', async () => {
|
|
43
|
+
const { kv } = counting()
|
|
44
|
+
let invocations = 0
|
|
45
|
+
const fn = async () => ++invocations
|
|
46
|
+
|
|
47
|
+
// ttl: 0 expires immediately (Date.now() + 0 is not strictly >).
|
|
48
|
+
expect(await cached(kv, 'k', fn, { ttl: 0 })).toBe(1)
|
|
49
|
+
expect(await cached(kv, 'k', fn, { ttl: 0 })).toBe(2)
|
|
50
|
+
expect(invocations).toBe(2)
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
test('behavior: L1 cache short-circuits L2 reads', async () => {
|
|
54
|
+
const { calls, kv } = counting()
|
|
55
|
+
await cached(kv, 'k', async () => 1, { ttl: 60 })
|
|
56
|
+
expect(calls.get).toBe(1) // miss → reads L2 once
|
|
57
|
+
await cached(kv, 'k', async () => 2, { ttl: 60 })
|
|
58
|
+
await cached(kv, 'k', async () => 3, { ttl: 60 })
|
|
59
|
+
// Subsequent calls hit L1 — no extra L2 reads.
|
|
60
|
+
expect(calls.get).toBe(1)
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
test('behavior: coalesces concurrent calls for the same key', async () => {
|
|
64
|
+
const { kv } = counting()
|
|
65
|
+
let invocations = 0
|
|
66
|
+
const fn = async () => {
|
|
67
|
+
invocations++
|
|
68
|
+
// Force the second call to start before fn() resolves.
|
|
69
|
+
await new Promise((r) => setTimeout(r, 10))
|
|
70
|
+
return invocations
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const [a, b, c] = await Promise.all([
|
|
74
|
+
cached(kv, 'k', fn, { ttl: 60 }),
|
|
75
|
+
cached(kv, 'k', fn, { ttl: 60 }),
|
|
76
|
+
cached(kv, 'k', fn, { ttl: 60 }),
|
|
77
|
+
])
|
|
78
|
+
|
|
79
|
+
expect(invocations).toBe(1)
|
|
80
|
+
expect([a, b, c]).toEqual([1, 1, 1])
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
test('behavior: isolates L1 cache per kv instance', async () => {
|
|
84
|
+
const a = counting()
|
|
85
|
+
const b = counting()
|
|
86
|
+
let invocations = 0
|
|
87
|
+
const fn = async () => ++invocations
|
|
88
|
+
|
|
89
|
+
await cached(a.kv, 'k', fn, { ttl: 60 })
|
|
90
|
+
await cached(b.kv, 'k', fn, { ttl: 60 })
|
|
91
|
+
expect(invocations).toBe(2)
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
test('behavior: falls back to fn when L2 read throws', async () => {
|
|
95
|
+
const failing: Kv.Kv = {
|
|
96
|
+
async get() {
|
|
97
|
+
throw new Error('boom')
|
|
98
|
+
},
|
|
99
|
+
async set() {},
|
|
100
|
+
async delete() {},
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
expect(await cached(failing, 'k', async () => 'value', { ttl: 60 })).toBe('value')
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
test('behavior: does not throw when L2 write fails', async () => {
|
|
107
|
+
const failing: Kv.Kv = {
|
|
108
|
+
async get() {
|
|
109
|
+
return null as never
|
|
110
|
+
},
|
|
111
|
+
async set() {
|
|
112
|
+
throw new Error('boom')
|
|
113
|
+
},
|
|
114
|
+
async delete() {},
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
expect(await cached(failing, 'k', async () => 'value', { ttl: 60 })).toBe('value')
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
test('behavior: caches forever when ttl is omitted', async () => {
|
|
121
|
+
const { kv } = counting()
|
|
122
|
+
let invocations = 0
|
|
123
|
+
const fn = async () => ++invocations
|
|
124
|
+
|
|
125
|
+
expect(await cached(kv, 'k', fn)).toBe(1)
|
|
126
|
+
expect(await cached(kv, 'k', fn)).toBe(1)
|
|
127
|
+
expect(invocations).toBe(1)
|
|
128
|
+
})
|
|
129
|
+
})
|
|
@@ -1,10 +1,33 @@
|
|
|
1
1
|
import type * as Kv from '../Kv.js'
|
|
2
2
|
|
|
3
|
+
type Entry = { expiresAt: number; value: unknown }
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Per-`kv`-instance in-memory L1 cache. Lives for the lifetime of the worker
|
|
7
|
+
* (or process) and short-circuits the L2 `kv.get` on hot keys so a request
|
|
8
|
+
* with N callers that share the same `cached()` key only pays for one
|
|
9
|
+
* remote read.
|
|
10
|
+
*
|
|
11
|
+
* `WeakMap` keys mean dropping a `kv` reference releases its L1 entries
|
|
12
|
+
* automatically — no need to thread a lifecycle hook through callers.
|
|
13
|
+
*/
|
|
14
|
+
const memoryStore = new WeakMap<Kv.Kv, Map<string, Entry>>()
|
|
15
|
+
|
|
3
16
|
/**
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
|
|
17
|
+
* Per-`kv`-instance in-flight dedupe table. Multiple concurrent
|
|
18
|
+
* `cached(kv, key, fn)` calls for the same key share a single `fn()`
|
|
19
|
+
* invocation instead of stampeding the upstream.
|
|
20
|
+
*/
|
|
21
|
+
const inflightStore = new WeakMap<Kv.Kv, Map<string, Promise<unknown>>>()
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Reads `key` from a two-tier cache (in-memory L1, `kv`-backed L2). If absent
|
|
25
|
+
* or expired in both tiers, calls `fn`, stores the result with a
|
|
26
|
+
* `Date.now() + ttl * 1000` expiry in both tiers, and returns it. Defaults
|
|
27
|
+
* to caching forever (no expiry). Kv read/write failures are swallowed so
|
|
28
|
+
* cache misses never break the caller.
|
|
29
|
+
*
|
|
30
|
+
* Concurrent calls for the same key share a single `fn()` invocation.
|
|
8
31
|
*/
|
|
9
32
|
export async function cached<value>(
|
|
10
33
|
kv: Kv.Kv,
|
|
@@ -14,13 +37,38 @@ export async function cached<value>(
|
|
|
14
37
|
): Promise<value> {
|
|
15
38
|
const { ttl = Infinity } = options
|
|
16
39
|
|
|
17
|
-
|
|
18
|
-
|
|
40
|
+
// L1: in-memory.
|
|
41
|
+
const memory = memoryFor(kv)
|
|
42
|
+
const local = memory.get(key)
|
|
43
|
+
if (local && local.expiresAt > Date.now()) return local.value as value
|
|
44
|
+
|
|
45
|
+
// Coalesce concurrent calls for the same key.
|
|
46
|
+
const inflight = inflightFor(kv)
|
|
47
|
+
const pending = inflight.get(key) as Promise<value> | undefined
|
|
48
|
+
if (pending) return pending
|
|
19
49
|
|
|
20
|
-
const
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
50
|
+
const promise = (async () => {
|
|
51
|
+
// L2: kv.
|
|
52
|
+
const entry = await kv.get<Entry | null>(key).catch(() => null)
|
|
53
|
+
if (entry && entry.expiresAt > Date.now()) {
|
|
54
|
+
memory.set(key, entry)
|
|
55
|
+
return entry.value as value
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const value = await fn()
|
|
59
|
+
const expiresAt = ttl === Infinity ? Infinity : Date.now() + ttl * 1000
|
|
60
|
+
const fresh: Entry = { expiresAt, value }
|
|
61
|
+
memory.set(key, fresh)
|
|
62
|
+
await kv.set(key, fresh).catch(() => {})
|
|
63
|
+
return value
|
|
64
|
+
})()
|
|
65
|
+
|
|
66
|
+
inflight.set(key, promise)
|
|
67
|
+
try {
|
|
68
|
+
return await promise
|
|
69
|
+
} finally {
|
|
70
|
+
inflight.delete(key)
|
|
71
|
+
}
|
|
24
72
|
}
|
|
25
73
|
|
|
26
74
|
export declare namespace cached {
|
|
@@ -33,3 +81,21 @@ export declare namespace cached {
|
|
|
33
81
|
ttl?: number | undefined
|
|
34
82
|
}
|
|
35
83
|
}
|
|
84
|
+
|
|
85
|
+
function memoryFor(kv: Kv.Kv) {
|
|
86
|
+
let map = memoryStore.get(kv)
|
|
87
|
+
if (!map) {
|
|
88
|
+
map = new Map()
|
|
89
|
+
memoryStore.set(kv, map)
|
|
90
|
+
}
|
|
91
|
+
return map
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function inflightFor(kv: Kv.Kv) {
|
|
95
|
+
let map = inflightStore.get(kv)
|
|
96
|
+
if (!map) {
|
|
97
|
+
map = new Map()
|
|
98
|
+
inflightStore.set(kv, map)
|
|
99
|
+
}
|
|
100
|
+
return map
|
|
101
|
+
}
|