tapimo 0.5.0 → 0.5.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.
Files changed (135) hide show
  1. package/dist/App.d.ts +4879 -4870
  2. package/dist/App.d.ts.map +1 -1
  3. package/dist/App.js +20 -2
  4. package/dist/App.js.map +1 -1
  5. package/dist/Assets.js +3 -3
  6. package/dist/Assets.js.map +1 -1
  7. package/dist/Scope.d.ts.map +1 -1
  8. package/dist/Scope.js +1 -2
  9. package/dist/Scope.js.map +1 -1
  10. package/dist/Webhooks.d.ts +1 -1
  11. package/dist/Webhooks.d.ts.map +1 -1
  12. package/dist/Webhooks.js +5 -10
  13. package/dist/Webhooks.js.map +1 -1
  14. package/dist/admin/App.d.ts +6 -34
  15. package/dist/admin/App.d.ts.map +1 -1
  16. package/dist/admin/apps/api-keys.d.ts +3 -3
  17. package/dist/admin/apps/api-keys.d.ts.map +1 -1
  18. package/dist/admin/apps/api-keys.js +4 -1
  19. package/dist/admin/apps/api-keys.js.map +1 -1
  20. package/dist/admin/apps/scopes.d.ts +6 -34
  21. package/dist/admin/apps/scopes.d.ts.map +1 -1
  22. package/dist/admin/apps/scopes.js +2 -2
  23. package/dist/admin/apps/scopes.js.map +1 -1
  24. package/dist/admin/apps/verified-tokens.d.ts +116 -116
  25. package/dist/admin/ui.generated.d.ts +1 -1
  26. package/dist/admin/ui.generated.js +33 -33
  27. package/dist/admin/ui.generated.js.map +1 -1
  28. package/dist/apps/data/App.d.ts +3199 -3206
  29. package/dist/apps/data/App.d.ts.map +1 -1
  30. package/dist/apps/data/App.js +25 -38
  31. package/dist/apps/data/App.js.map +1 -1
  32. package/dist/apps/data/FundingQuoteProviders.d.ts +76 -3
  33. package/dist/apps/data/FundingQuoteProviders.d.ts.map +1 -1
  34. package/dist/apps/data/FundingQuoteProviders.js +276 -2
  35. package/dist/apps/data/FundingQuoteProviders.js.map +1 -1
  36. package/dist/apps/data/routes/activities.d.ts +245 -245
  37. package/dist/apps/data/routes/addresses.d.ts +206 -206
  38. package/dist/apps/data/routes/balances.d.ts +46 -46
  39. package/dist/apps/data/routes/blocks.d.ts +123 -123
  40. package/dist/apps/data/routes/coingecko.d.ts +169 -169
  41. package/dist/apps/data/routes/funding.d.ts +230 -146
  42. package/dist/apps/data/routes/funding.d.ts.map +1 -1
  43. package/dist/apps/data/routes/funding.js +23 -12
  44. package/dist/apps/data/routes/funding.js.map +1 -1
  45. package/dist/apps/data/routes/indexer.d.ts +34 -34
  46. package/dist/apps/data/routes/indexer.d.ts.map +1 -1
  47. package/dist/apps/data/routes/indexer.js +9 -2
  48. package/dist/apps/data/routes/indexer.js.map +1 -1
  49. package/dist/apps/data/routes/receipts.d.ts +228 -228
  50. package/dist/apps/data/routes/tokenlist.d.ts +36 -36
  51. package/dist/apps/data/routes/transactions.d.ts +281 -281
  52. package/dist/apps/data/routes/transfers.d.ts +58 -58
  53. package/dist/apps/data/routes/verified-tokens.d.ts +86 -86
  54. package/dist/apps/management/App.d.ts +1042 -1042
  55. package/dist/apps/management/routes/api-keys.d.ts +120 -120
  56. package/dist/apps/management/routes/billing.d.ts +211 -211
  57. package/dist/apps/management/routes/invitations.d.ts +132 -132
  58. package/dist/apps/management/routes/invite-links.d.ts +62 -62
  59. package/dist/apps/management/routes/me.d.ts +17 -17
  60. package/dist/apps/management/routes/members.d.ts +80 -80
  61. package/dist/apps/management/routes/orgs.d.ts +96 -96
  62. package/dist/apps/management/routes/projects.d.ts +61 -61
  63. package/dist/apps/management/routes/scopes.d.ts +11 -11
  64. package/dist/apps/management/routes/usage.d.ts +79 -79
  65. package/dist/apps/mcp.js +2 -2
  66. package/dist/apps/mcp.js.map +1 -1
  67. package/dist/apps/relay/App.d.ts +2 -2
  68. package/dist/apps/relay/App.js +2 -2
  69. package/dist/db/tables/sponsoredTransactions.d.ts.map +1 -1
  70. package/dist/db/tables/sponsoredTransactions.js +6 -2
  71. package/dist/db/tables/sponsoredTransactions.js.map +1 -1
  72. package/dist/internal/Auth.d.ts.map +1 -1
  73. package/dist/internal/Auth.js +8 -0
  74. package/dist/internal/Auth.js.map +1 -1
  75. package/dist/internal/FundingQuotes.d.ts +170 -56
  76. package/dist/internal/FundingQuotes.d.ts.map +1 -1
  77. package/dist/internal/FundingQuotes.js +91 -5
  78. package/dist/internal/FundingQuotes.js.map +1 -1
  79. package/dist/internal/Log.d.ts +18 -12
  80. package/dist/internal/Log.d.ts.map +1 -1
  81. package/dist/internal/Log.js +2 -0
  82. package/dist/internal/Log.js.map +1 -1
  83. package/dist/internal/Tidx.d.ts +9 -3
  84. package/dist/internal/Tidx.d.ts.map +1 -1
  85. package/dist/internal/Tidx.js +13 -7
  86. package/dist/internal/Tidx.js.map +1 -1
  87. package/dist/internal/Viem.d.ts +14 -1
  88. package/dist/internal/Viem.d.ts.map +1 -1
  89. package/dist/internal/Viem.js +19 -1
  90. package/dist/internal/Viem.js.map +1 -1
  91. package/package.json +2 -2
  92. package/src/ApiKeys.test.ts +2 -0
  93. package/src/App.test.ts +13 -2
  94. package/src/App.ts +31 -5
  95. package/src/Assets.ts +3 -3
  96. package/src/Client.test-d.ts +3 -2
  97. package/src/Scope.ts +4 -2
  98. package/src/Webhooks.ts +5 -10
  99. package/src/admin/apps/api-keys.test.ts +27 -0
  100. package/src/admin/apps/api-keys.ts +8 -1
  101. package/src/admin/apps/scopes.test.ts +7 -2
  102. package/src/admin/apps/scopes.ts +2 -2
  103. package/src/admin/ui/src/components/Dialog.tsx +8 -2
  104. package/src/admin/ui/src/lib/scopes.test.ts +19 -0
  105. package/src/admin/ui/src/lib/scopes.ts +12 -0
  106. package/src/admin/ui/src/routes/api-keys.tsx +128 -27
  107. package/src/admin/ui.generated.ts +33 -33
  108. package/src/apps/composition.test.ts +77 -0
  109. package/src/apps/data/App.ts +25 -45
  110. package/src/apps/data/FundingQuoteProviders.test.ts +431 -5
  111. package/src/apps/data/FundingQuoteProviders.ts +386 -4
  112. package/src/apps/data/routes/funding.test.ts +174 -7
  113. package/src/apps/data/routes/funding.ts +27 -12
  114. package/src/apps/data/routes/indexer.test.ts +82 -20
  115. package/src/apps/data/routes/indexer.ts +8 -2
  116. package/src/apps/management/Billing.test.ts +2 -2
  117. package/src/apps/mcp.test.ts +18 -0
  118. package/src/apps/mcp.ts +2 -2
  119. package/src/apps/relay/App.test.ts +6 -5
  120. package/src/apps/relay/App.ts +2 -2
  121. package/src/apps/relay/Sponsorships.test.ts +2 -2
  122. package/src/db/Db.test.ts +5 -5
  123. package/src/db/tables/billingSettings.test.ts +2 -2
  124. package/src/db/tables/organizations.test.ts +2 -2
  125. package/src/db/tables/sponsoredTransactions.test.ts +19 -2
  126. package/src/db/tables/sponsoredTransactions.ts +21 -14
  127. package/src/db/tables/stripeCustomers.test.ts +2 -2
  128. package/src/internal/Auth.ts +10 -0
  129. package/src/internal/FundingQuotes.test.ts +97 -3
  130. package/src/internal/FundingQuotes.ts +104 -6
  131. package/src/internal/Log.ts +21 -14
  132. package/src/internal/Tidx.test.ts +48 -0
  133. package/src/internal/Tidx.ts +21 -7
  134. package/src/internal/Viem.test.ts +46 -0
  135. package/src/internal/Viem.ts +34 -1
@@ -1,23 +1,166 @@
1
+ import * as z from 'zod/mini'
2
+
1
3
  import * as FundingQuotes from '../../internal/FundingQuotes.js'
2
4
 
3
5
  type Fetch = typeof globalThis.fetch
4
6
 
5
7
  const defaultFetch: Fetch = (input, init) => globalThis.fetch(input, init)
6
8
 
9
+ const defaultAcrossBaseUrl = 'https://app.across.to/api'
7
10
  const defaultRelayBaseUrl = 'https://api.relay.link'
11
+ const defaultRhinoBaseUrl = 'https://api.rhino.fi/bridge'
12
+ const providerOperations = {
13
+ across: 'swap/approval',
14
+ relay: 'quote/v2',
15
+ rhino: 'quote/bridge-swap/public',
16
+ } as const
17
+
18
+ namespace schema {
19
+ const IntegerString = z.string().check(z.regex(/^\d+$/))
20
+
21
+ const AcrossToken = z.object({
22
+ address: z.string(),
23
+ chainId: z.number().check(z.int(), z.nonnegative()),
24
+ decimals: z.number().check(z.int(), z.nonnegative()),
25
+ })
26
+
27
+ export const AcrossQuote = z.object({
28
+ amountType: z.literal('exactInput'),
29
+ expectedFillTime: z.number().check(z.int(), z.nonnegative()),
30
+ expectedOutputAmount: IntegerString,
31
+ inputAmount: IntegerString,
32
+ inputToken: AcrossToken,
33
+ minOutputAmount: IntegerString,
34
+ outputToken: AcrossToken,
35
+ quoteExpiryTimestamp: z.number().check(z.int(), z.nonnegative()),
36
+ })
37
+
38
+ export const RhinoQuote = z.object({
39
+ _tag: z.literal('bridge'),
40
+ chainIn: z.string(),
41
+ chainOut: z.string(),
42
+ estimatedDuration: z.optional(z.nullable(z.number().check(z.nonnegative()))),
43
+ payAmount: z.string().check(z.regex(/^\d+(\.\d+)?$/)),
44
+ receiveAmount: z.string().check(z.regex(/^\d+(\.\d+)?$/)),
45
+ token: z.string(),
46
+ })
47
+ }
8
48
 
9
49
  /** Creates the live funding quote adapters configured for this deployment. */
10
50
  export function createAdapters(options: createAdapters.Options = {}) {
11
- return (
12
- options.relay ? { relay: relay(options.relay) } : {}
13
- ) satisfies FundingQuotes.ProviderAdapters
51
+ return {
52
+ ...(options.across ? { across: across(options.across) } : {}),
53
+ ...(options.relay ? { relay: relay(options.relay) } : {}),
54
+ ...(options.rhino ? { rhino: rhino(options.rhino) } : {}),
55
+ } satisfies FundingQuotes.ProviderAdapters
14
56
  }
15
57
 
16
58
  export declare namespace createAdapters {
17
59
  /** Provider adapter factory options. Omit a provider to leave it unconfigured. */
18
60
  type Options = {
61
+ /** Across quote adapter options. */
62
+ across?: across.Options | false | undefined
19
63
  /** Relay quote adapter options. */
20
64
  relay?: relay.Options | false | undefined
65
+ /** Rhino quote adapter options. */
66
+ rhino?: rhino.Options | false | undefined
67
+ }
68
+ }
69
+
70
+ /** Resolves all-or-none Across deployment configuration. */
71
+ export function configureAcross(options: configureAcross.Options = {}): across.Options | undefined {
72
+ const configured = [options.apiKey, options.integratorId, options.userAddress].filter(Boolean)
73
+ if (configured.length === 0) return undefined
74
+ if (!options.apiKey || !options.integratorId || !options.userAddress)
75
+ throw new ProviderConfigurationError()
76
+ return {
77
+ apiKey: options.apiKey,
78
+ integratorId: options.integratorId,
79
+ userAddress: options.userAddress,
80
+ }
81
+ }
82
+
83
+ export declare namespace configureAcross {
84
+ /** Optional deployment bindings required together to enable Across. */
85
+ type Options = {
86
+ /** Across API key binding. */
87
+ apiKey?: string | undefined
88
+ /** Across integrator id binding. */
89
+ integratorId?: string | undefined
90
+ /** Across quote-user binding. */
91
+ userAddress?: string | undefined
92
+ }
93
+ }
94
+
95
+ /** Creates an Across quote adapter backed by `/swap/approval`. */
96
+ export function across(options: across.Options): FundingQuotes.ProviderAdapter {
97
+ assertAcrossConfiguration(options)
98
+ const baseUrl = normalizeBaseUrl(options.baseUrl ?? defaultAcrossBaseUrl)
99
+ const fetcher = options.fetch ?? defaultFetch
100
+ const now = options.now ?? (() => new Date())
101
+
102
+ return {
103
+ async probeQuote({ candidate }, signal) {
104
+ const destinationChainId = eip155ChainId(candidate.destinationChain)
105
+ const originChainId = providerChainId(candidate.sourceChain, 'across')
106
+ const url = new URL(`${baseUrl}/swap/approval`)
107
+ url.search = new URLSearchParams({
108
+ amount: candidate.sourceAmount.amount,
109
+ depositor: options.userAddress,
110
+ destinationChainId,
111
+ inputToken: candidate.sourceToken.address,
112
+ integratorId: options.integratorId,
113
+ originChainId,
114
+ outputToken: candidate.destinationToken.address,
115
+ recipient: options.userAddress,
116
+ skipOriginTxEstimation: 'true',
117
+ strictTradeType: 'true',
118
+ tradeType: 'exactInput',
119
+ }).toString()
120
+
121
+ try {
122
+ const quote = await requestJson(url, {
123
+ fetcher,
124
+ headers: { Authorization: `Bearer ${options.apiKey}` },
125
+ signal,
126
+ })
127
+ return formatAcrossResult(candidate, quote, now())
128
+ } catch (cause) {
129
+ const unavailable = providerUnavailable(cause, [
130
+ 'AMOUNT_TOO_HIGH',
131
+ 'AMOUNT_TOO_LOW',
132
+ 'INSUFFICIENT_LIQUIDITY',
133
+ 'NO_QUOTES',
134
+ 'NO_ROUTE',
135
+ 'UNSUPPORTED_ROUTE',
136
+ ])
137
+ if (!unavailable) throw cause
138
+ return unavailableResult({
139
+ detail: 'across:swap-approval',
140
+ message: unavailable,
141
+ now: now(),
142
+ source: 'providerQuote',
143
+ })
144
+ }
145
+ },
146
+ }
147
+ }
148
+
149
+ export declare namespace across {
150
+ /** Across quote adapter options. */
151
+ type Options = {
152
+ /** Across API key sent as a bearer token. */
153
+ apiKey: string
154
+ /** Override for tests or Across test environments. */
155
+ baseUrl?: string | undefined
156
+ /** Fetch implementation override for tests. */
157
+ fetch?: Fetch | undefined
158
+ /** Across integrator id sent as the required query parameter. */
159
+ integratorId: string
160
+ /** Clock override for deterministic tests. */
161
+ now?: (() => Date) | undefined
162
+ /** Probe wallet address used as the Across depositor and recipient. */
163
+ userAddress: string
21
164
  }
22
165
  }
23
166
 
@@ -87,6 +230,144 @@ export declare namespace relay {
87
230
  }
88
231
  }
89
232
 
233
+ /** Creates a Rhino quote adapter backed by the public bridge-and-swap quote endpoint. */
234
+ export function rhino(options: rhino.Options = {}): FundingQuotes.ProviderAdapter {
235
+ const baseUrl = normalizeBaseUrl(options.baseUrl ?? defaultRhinoBaseUrl)
236
+ const fetcher = options.fetch ?? defaultFetch
237
+ const now = options.now ?? (() => new Date())
238
+
239
+ return {
240
+ async probeQuote({ cache, candidate }, signal) {
241
+ const chainIn = providerChainReference(candidate.sourceChain, 'rhino')
242
+ const tokenOut = rhinoToken(candidate.destinationToken)
243
+ const url = new URL(`${baseUrl}/quote/bridge-swap/public`)
244
+ url.search = new URLSearchParams({
245
+ amount: candidate.sourceAmount.formatted,
246
+ amountNative: '0',
247
+ chainIn,
248
+ chainOut: 'TEMPO',
249
+ mode: 'pay',
250
+ tokenIn: candidate.sourceToken.symbol,
251
+ tokenOut,
252
+ }).toString()
253
+
254
+ try {
255
+ const quote = await requestJson(url, {
256
+ fetcher,
257
+ headers: cache.bypass ? { 'cache-control': 'no-cache' } : undefined,
258
+ signal,
259
+ })
260
+ return formatRhinoResult(candidate, quote, { chainIn, now: now(), tokenOut })
261
+ } catch (cause) {
262
+ const unavailable = providerUnavailable(cause, ['NoRouteFoundError'])
263
+ if (!unavailable) throw cause
264
+ return unavailableResult({
265
+ detail: 'rhino:bridge-swap-public',
266
+ message: unavailable,
267
+ now: now(),
268
+ source: 'providerQuote',
269
+ })
270
+ }
271
+ },
272
+ }
273
+ }
274
+
275
+ export declare namespace rhino {
276
+ /** Rhino public quote adapter options. */
277
+ type Options = {
278
+ /** Override for tests or Rhino test environments. */
279
+ baseUrl?: string | undefined
280
+ /** Fetch implementation override for tests. */
281
+ fetch?: Fetch | undefined
282
+ /** Clock override for deterministic tests. */
283
+ now?: (() => Date) | undefined
284
+ }
285
+ }
286
+
287
+ /** Returns bounded provider failure details suitable for structured request logs. */
288
+ export function providerFailure(options: providerFailure.Options): providerFailure.Result {
289
+ const operation = providerOperations[options.provider]
290
+ if (
291
+ options.cause instanceof FundingQuotes.ProviderAdapterTimeoutError ||
292
+ (options.cause instanceof DOMException && options.cause.name === 'AbortError')
293
+ )
294
+ return { failure: 'timeout', id: options.provider, operation }
295
+ if (options.cause instanceof ProviderResponseError) {
296
+ const code = providerErrorCode(options.cause.body)
297
+ return {
298
+ ...(code === undefined ? {} : { code }),
299
+ failure: 'http',
300
+ id: options.provider,
301
+ operation,
302
+ status: options.cause.status,
303
+ }
304
+ }
305
+ if (options.cause instanceof ProviderPayloadError)
306
+ return { failure: 'payload', id: options.provider, operation }
307
+ if (options.cause instanceof TypeError)
308
+ return { failure: 'network', id: options.provider, operation }
309
+ return { failure: 'unknown', id: options.provider, operation }
310
+ }
311
+
312
+ export declare namespace providerFailure {
313
+ /** Inputs used to classify a provider failure. */
314
+ type Options = FundingQuotes.ProviderFailure
315
+
316
+ /** Bounded provider failure fields. */
317
+ type Result = {
318
+ /** Stable provider error code, when one is safe to retain. */
319
+ code?: string | undefined
320
+ /** Failure boundary that rejected the provider request. */
321
+ failure: 'http' | 'network' | 'payload' | 'timeout' | 'unknown'
322
+ /** Provider identifier. */
323
+ id: FundingQuotes.ProviderId
324
+ /** Provider operation identifier. */
325
+ operation: (typeof providerOperations)[FundingQuotes.ProviderId]
326
+ /** Upstream HTTP status, when a response was received. */
327
+ status?: number | undefined
328
+ }
329
+ }
330
+
331
+ function formatAcrossResult(
332
+ candidate: FundingQuotes.QuoteCandidate,
333
+ quote: unknown,
334
+ now: Date,
335
+ ): FundingQuotes.QuoteProbeResult {
336
+ const parsed = schema.AcrossQuote.safeParse(quote)
337
+ if (!parsed.success) throw new ProviderPayloadError()
338
+ const response = parsed.data
339
+ const expiresAt = new Date(response.quoteExpiryTimestamp * 1_000)
340
+ if (
341
+ response.inputAmount !== candidate.sourceAmount.amount ||
342
+ !matchesToken(response.inputToken, candidate.sourceChain, candidate.sourceToken) ||
343
+ !matchesToken(response.outputToken, candidate.destinationChain, candidate.destinationToken) ||
344
+ BigInt(response.expectedOutputAmount) <= 0n ||
345
+ BigInt(response.minOutputAmount) <= 0n ||
346
+ BigInt(response.minOutputAmount) > BigInt(response.expectedOutputAmount) ||
347
+ Number.isNaN(expiresAt.getTime()) ||
348
+ response.quoteExpiryTimestamp * 1_000 <= now.getTime()
349
+ )
350
+ throw new ProviderPayloadError()
351
+
352
+ return {
353
+ expiresAt: expiresAt.toISOString(),
354
+ minimumOutputAmount: response.minOutputAmount,
355
+ outputAmount: response.expectedOutputAmount,
356
+ outputAmountFormatted: decimalFromBaseUnits(
357
+ response.expectedOutputAmount,
358
+ candidate.destinationToken.decimals,
359
+ ),
360
+ quality: {
361
+ estimatedSeconds: response.expectedFillTime,
362
+ liquiditySource: 'providerQuote',
363
+ sourceDetail: 'across:swap-approval',
364
+ tier: 'liquid',
365
+ },
366
+ sampledAt: now.toISOString(),
367
+ status: 'available',
368
+ }
369
+ }
370
+
90
371
  function formatRelayResult(
91
372
  candidate: FundingQuotes.QuoteCandidate,
92
373
  quote: unknown,
@@ -116,6 +397,45 @@ function formatRelayResult(
116
397
  }
117
398
  }
118
399
 
400
+ function formatRhinoResult(
401
+ candidate: FundingQuotes.QuoteCandidate,
402
+ quote: unknown,
403
+ options: { chainIn: string; now: Date; tokenOut: string },
404
+ ): FundingQuotes.QuoteProbeResult {
405
+ const parsed = schema.RhinoQuote.safeParse(quote)
406
+ if (!parsed.success) throw new ProviderPayloadError()
407
+ const response = parsed.data
408
+ const inputAmount = baseUnitsFromDecimal(response.payAmount, candidate.sourceToken.decimals)
409
+ const outputAmount = baseUnitsFromDecimal(
410
+ response.receiveAmount,
411
+ candidate.destinationToken.decimals,
412
+ )
413
+ if (
414
+ response.chainIn !== options.chainIn ||
415
+ response.chainOut !== 'TEMPO' ||
416
+ response.token !== options.tokenOut ||
417
+ inputAmount !== candidate.sourceAmount.amount ||
418
+ outputAmount === undefined ||
419
+ BigInt(outputAmount) <= 0n
420
+ )
421
+ throw new ProviderPayloadError()
422
+
423
+ return {
424
+ outputAmount,
425
+ outputAmountFormatted: decimalFromBaseUnits(outputAmount, candidate.destinationToken.decimals),
426
+ quality: {
427
+ ...(response.estimatedDuration === undefined || response.estimatedDuration === null
428
+ ? {}
429
+ : { estimatedSeconds: Math.ceil(response.estimatedDuration / 1_000) }),
430
+ liquiditySource: 'providerQuote',
431
+ sourceDetail: 'rhino:bridge-swap-public',
432
+ tier: 'liquid',
433
+ },
434
+ sampledAt: options.now.toISOString(),
435
+ status: 'available',
436
+ }
437
+ }
438
+
119
439
  function unavailableResult(options: {
120
440
  detail: string
121
441
  message: string
@@ -172,7 +492,8 @@ function providerUnavailable(cause: unknown, codes: readonly string[]) {
172
492
 
173
493
  function providerErrorCode(input: unknown) {
174
494
  const body = record(input)
175
- const code = stringValue(body['code']) ?? stringValue(body['errorCode'])
495
+ const code =
496
+ stringValue(body['_tag']) ?? stringValue(body['code']) ?? stringValue(body['errorCode'])
176
497
  return code && /^[A-Za-z0-9_.:-]{1,64}$/.test(code) ? code : undefined
177
498
  }
178
499
 
@@ -186,6 +507,38 @@ function providerChainId(
186
507
  return eip155ChainId(quoteChain)
187
508
  }
188
509
 
510
+ function providerChainReference(
511
+ quoteChain: FundingQuotes.QuoteCandidate['sourceChain'],
512
+ provider: string,
513
+ ) {
514
+ const providerIds = quoteChain.providerIds as Record<string, string> | undefined
515
+ const id = providerIds?.[provider]
516
+ if (!id) throw new UnsupportedProviderChainError()
517
+ return id
518
+ }
519
+
520
+ function rhinoToken(token: FundingQuotes.TokenRef) {
521
+ const tokens: Record<string, string> = {
522
+ '0x20c00000000000000000000014f22ca97301eb73': 'USDT',
523
+ '0x20c000000000000000000000b9537d11c60e8b50': 'USDC',
524
+ }
525
+ const id = tokens[token.address.toLowerCase()]
526
+ if (!id) throw new UnsupportedProviderTokenError()
527
+ return id
528
+ }
529
+
530
+ function matchesToken(
531
+ token: z.output<typeof schema.AcrossQuote>['inputToken'],
532
+ chain: FundingQuotes.ChainRef,
533
+ expected: FundingQuotes.TokenRef,
534
+ ) {
535
+ return (
536
+ token.address.toLowerCase() === expected.address.toLowerCase() &&
537
+ token.chainId === Number(eip155ChainId(chain)) &&
538
+ token.decimals === expected.decimals
539
+ )
540
+ }
541
+
189
542
  function eip155ChainId(chain: { id: string; kind: string }) {
190
543
  if (chain.kind !== 'evm') throw new UnsupportedProviderChainError()
191
544
  const match = /^eip155:(\d+)$/.exec(chain.id)
@@ -235,6 +588,31 @@ function decimalFromBaseUnits(baseUnits: string, decimals: number) {
235
588
  return fraction ? `${whole}.${fraction}` : whole
236
589
  }
237
590
 
591
+ function baseUnitsFromDecimal(value: string, decimals: number) {
592
+ if (!/^\d+(\.\d+)?$/.test(value)) return undefined
593
+ const [whole, fraction = ''] = value.split('.')
594
+ const significantFraction = fraction.replace(/0+$/, '')
595
+ if (significantFraction.length > decimals) return undefined
596
+ return BigInt(`${whole}${significantFraction.padEnd(decimals, '0')}`).toString()
597
+ }
598
+
599
+ function assertAcrossConfiguration(options: across.Options) {
600
+ if (
601
+ options.apiKey.length === 0 ||
602
+ !/^0x[\da-fA-F]{4}$/.test(options.integratorId) ||
603
+ !/^0x[\da-fA-F]{40}$/.test(options.userAddress)
604
+ )
605
+ throw new ProviderConfigurationError()
606
+ }
607
+
608
+ class ProviderConfigurationError extends Error {
609
+ override name = 'FundingQuoteProviders.ProviderConfigurationError'
610
+ }
611
+
612
+ class ProviderPayloadError extends Error {
613
+ override name = 'FundingQuoteProviders.ProviderPayloadError'
614
+ }
615
+
238
616
  class ProviderResponseError extends Error {
239
617
  override name = 'FundingQuoteProviders.ProviderResponseError'
240
618
 
@@ -249,3 +627,7 @@ class ProviderResponseError extends Error {
249
627
  class UnsupportedProviderChainError extends Error {
250
628
  override name = 'FundingQuoteProviders.UnsupportedProviderChainError'
251
629
  }
630
+
631
+ class UnsupportedProviderTokenError extends Error {
632
+ override name = 'FundingQuoteProviders.UnsupportedProviderTokenError'
633
+ }
@@ -1,6 +1,7 @@
1
1
  import { Schema } from 'tapimo'
2
2
 
3
3
  import type * as FundingQuotes from '../../../internal/FundingQuotes.js'
4
+ import type * as Log from '../../../internal/Log.js'
4
5
  import * as TestApp from '../../../../test/App.js'
5
6
  import * as Funding from './funding.js'
6
7
 
@@ -27,22 +28,59 @@ describe('GET /funding/quotes', () => {
27
28
  })
28
29
 
29
30
  test('queries only the requested provider', async () => {
30
- let calls = 0
31
+ let acrossCalls = 0
32
+ let relayCalls = 0
31
33
  const app = TestApp.create({
32
34
  funding: {
33
35
  adapters: {
36
+ across: acrossAdapter(() => {
37
+ acrossCalls += 1
38
+ }),
34
39
  relay: relayAdapter(() => {
35
- calls += 1
40
+ relayCalls += 1
36
41
  }),
37
42
  },
38
43
  },
39
44
  })
40
- const response = await app.request(quoteUrl({ ...baseUsdcQuery, provider: 'relay' }), {
45
+ const response = await app.request(quoteUrl({ ...baseUsdcQuery, provider: 'across' }), {
41
46
  headers: TestApp.auth.headers,
42
47
  })
43
48
 
44
49
  expect(response.status).toBe(200)
45
- expect(calls).toBe(1)
50
+ expect(acrossCalls).toBe(1)
51
+ expect(relayCalls).toBe(0)
52
+ const body = await TestApp.json(response, Funding.schema.quoteFunding.Response)
53
+ expect(body.data[0]?.quote.expiresAt).toBe('2026-01-01T00:01:00Z')
54
+ })
55
+
56
+ test('returns non-EVM Rhino quotes with stable chain and token ids', async () => {
57
+ const app = TestApp.create({ funding: { adapters: { rhino: rhinoAdapter() } } })
58
+ const response = await app.request(
59
+ quoteUrl({
60
+ destinationToken: 'USDC.e',
61
+ provider: 'rhino',
62
+ sourceAmount: '1000000',
63
+ sourceChain: 'solana',
64
+ sourceToken: 'USDC',
65
+ }),
66
+ { headers: TestApp.auth.headers },
67
+ )
68
+
69
+ expect(response.status).toBe(200)
70
+ const body = await TestApp.json(response, Funding.schema.quoteFunding.Response)
71
+ expect(body.data[0]).toMatchObject({
72
+ id: 'solana-usdc-tempo-usdce-rhino',
73
+ provider: { id: 'rhino' },
74
+ sourceChain: {
75
+ addressFormat: 'base58',
76
+ id: 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp',
77
+ kind: 'solana',
78
+ },
79
+ sourceToken: {
80
+ tokenKey:
81
+ 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/token:EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v',
82
+ },
83
+ })
46
84
  })
47
85
 
48
86
  test('returns an empty list when no provider is configured', async () => {
@@ -90,6 +128,72 @@ describe('GET /funding/quotes', () => {
90
128
  expect(body.error.code).toBe('upstream_error')
91
129
  })
92
130
 
131
+ test('logs provider quote deadlines as timeouts', async () => {
132
+ const entries: Log.Entry[] = []
133
+ const app = TestApp.create({
134
+ funding: {
135
+ adapterTimeoutMs: 1,
136
+ adapters: {
137
+ across: {
138
+ probeQuote() {
139
+ return new Promise<FundingQuotes.QuoteProbeResult>(() => {})
140
+ },
141
+ },
142
+ },
143
+ },
144
+ logger: (entry) => {
145
+ entries.push(entry)
146
+ },
147
+ })
148
+
149
+ const response = await app.request(quoteUrl({ ...baseUsdcQuery, provider: 'across' }), {
150
+ headers: TestApp.auth.headers,
151
+ })
152
+
153
+ expect(response.status).toBe(502)
154
+ expect(entries[0]?.provider).toEqual({
155
+ failure: 'timeout',
156
+ id: 'across',
157
+ operation: 'swap/approval',
158
+ })
159
+ })
160
+
161
+ test('logs every failure when multiple providers fail', async () => {
162
+ const entries: Log.Entry[] = []
163
+ const app = TestApp.create({
164
+ funding: {
165
+ adapters: {
166
+ across: {
167
+ async probeQuote() {
168
+ throw new TypeError('Across network failure')
169
+ },
170
+ },
171
+ relay: {
172
+ async probeQuote() {
173
+ throw new Error('Relay failure')
174
+ },
175
+ },
176
+ },
177
+ },
178
+ logger: (entry) => {
179
+ entries.push(entry)
180
+ },
181
+ })
182
+
183
+ const response = await app.request(quoteUrl(), { headers: TestApp.auth.headers })
184
+
185
+ expect(response.status).toBe(502)
186
+ expect(entries[0]?.provider).toEqual({
187
+ failure: 'network',
188
+ id: 'across',
189
+ operation: 'swap/approval',
190
+ })
191
+ expect(entries[0]?.providerFailures).toEqual([
192
+ { failure: 'network', id: 'across', operation: 'swap/approval' },
193
+ { failure: 'unknown', id: 'relay', operation: 'quote/v2' },
194
+ ])
195
+ })
196
+
93
197
  test('rejects successful provider results without output amounts', async () => {
94
198
  const app = TestApp.create({
95
199
  funding: {
@@ -126,9 +230,9 @@ describe('GET /funding/quotes', () => {
126
230
  expect(body.error.code).toBe('query_invalid')
127
231
  })
128
232
 
129
- test('rejects source chains without a live quote adapter', async () => {
233
+ test('rejects unsupported source chains', async () => {
130
234
  const response = await TestApp.create().request(
131
- quoteUrl({ ...baseUsdcQuery, sourceChain: 'solana' }),
235
+ quoteUrl({ ...baseUsdcQuery, sourceChain: 'bitcoin' }),
132
236
  { headers: TestApp.auth.headers },
133
237
  )
134
238
  expect(response.status).toBe(400)
@@ -151,7 +255,13 @@ describe('GET /funding/chains', () => {
151
255
  expect(response.status).toBe(200)
152
256
  const body = await TestApp.json(response, Funding.schema.listFundingChains.Response)
153
257
 
154
- expect(body.data.map((chain) => chain.id)).toEqual(['eip155:1', 'eip155:8453', 'eip155:56'])
258
+ expect(body.data.map((chain) => chain.id)).toEqual([
259
+ 'eip155:1',
260
+ 'eip155:8453',
261
+ 'eip155:56',
262
+ 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp',
263
+ 'tron:0x2b6653dc',
264
+ ])
155
265
  expect(body.data.flatMap((chain) => chain.tokens.map((token) => token.symbol))).toEqual(
156
266
  expect.arrayContaining(['USDC', 'USDT']),
157
267
  )
@@ -168,6 +278,13 @@ describe('GET /funding/providers', () => {
168
278
  const body = await TestApp.json(response, Funding.schema.listProviders.Response)
169
279
 
170
280
  expect(body.data).toEqual([
281
+ {
282
+ id: 'across',
283
+ kind: 'intentBridge',
284
+ name: 'Across',
285
+ requiresApiKey: true,
286
+ status: 'unconfigured',
287
+ },
171
288
  {
172
289
  id: 'relay',
173
290
  kind: 'solverNetwork',
@@ -175,6 +292,13 @@ describe('GET /funding/providers', () => {
175
292
  requiresApiKey: false,
176
293
  status: 'configured',
177
294
  },
295
+ {
296
+ id: 'rhino',
297
+ kind: 'bridge',
298
+ name: 'Rhino',
299
+ requiresApiKey: false,
300
+ status: 'unconfigured',
301
+ },
178
302
  ])
179
303
  })
180
304
 
@@ -222,3 +346,46 @@ function relayAdapter(onProbe: () => void = () => {}): FundingQuotes.ProviderAda
222
346
  },
223
347
  }
224
348
  }
349
+
350
+ function acrossAdapter(onProbe: () => void = () => {}): FundingQuotes.ProviderAdapter {
351
+ return {
352
+ async probeQuote(input, signal) {
353
+ onProbe()
354
+ expect(signal.aborted).toBe(false)
355
+ expect(input.candidate.id).toBe('base-usdc-tempo-usdce-across')
356
+ return {
357
+ expiresAt: '2026-01-01T00:01:00Z',
358
+ minimumOutputAmount: '988000',
359
+ outputAmount: '990000',
360
+ outputAmountFormatted: '0.99',
361
+ quality: {
362
+ estimatedSeconds: 2,
363
+ liquiditySource: 'providerQuote',
364
+ sourceDetail: 'across:swap-approval',
365
+ tier: 'liquid',
366
+ },
367
+ sampledAt: '2026-01-01T00:00:00Z',
368
+ status: 'available',
369
+ }
370
+ },
371
+ }
372
+ }
373
+
374
+ function rhinoAdapter(): FundingQuotes.ProviderAdapter {
375
+ return {
376
+ async probeQuote(input, signal) {
377
+ expect(signal.aborted).toBe(false)
378
+ expect(input.candidate.id).toBe('solana-usdc-tempo-usdce-rhino')
379
+ return {
380
+ outputAmount: '983000',
381
+ quality: {
382
+ liquiditySource: 'providerQuote',
383
+ sourceDetail: 'rhino:bridge-swap-public',
384
+ tier: 'liquid',
385
+ },
386
+ sampledAt: '2026-01-01T00:00:00Z',
387
+ status: 'available',
388
+ }
389
+ },
390
+ }
391
+ }