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
@@ -25,6 +25,46 @@ async function paths(app: { request: (path: string) => Promise<Response> | Respo
25
25
  return Object.keys((await document(app)).paths)
26
26
  }
27
27
 
28
+ describe('App.create()', () => {
29
+ test('publishes shared chain context without data routes', async () => {
30
+ const rpc = { url: () => 'https://rpc.example' }
31
+ const group = new Hono<App.Environment>().get('/context', (c) => {
32
+ const client = c.get('getClient')()
33
+ return c.json({
34
+ chainId: c.get('chainId'),
35
+ clientChainId: client.chain.id,
36
+ memoized: client === c.get('getClient')(),
37
+ rpc: c.get('rpc') === rpc,
38
+ supportedChainIds: [...c.get('supportedChainIds')].sort((a, b) => a - b),
39
+ })
40
+ })
41
+ const app = App.create({
42
+ auth: false,
43
+ db: TestApp.database(),
44
+ defaultChainId: 1337,
45
+ rpc,
46
+ supportedChainIds: [999],
47
+ }).route('/', group)
48
+
49
+ const response = await app.request('/context')
50
+
51
+ expect(await response.json()).toMatchInlineSnapshot(`
52
+ {
53
+ "chainId": 1337,
54
+ "clientChainId": 1337,
55
+ "memoized": true,
56
+ "rpc": true,
57
+ "supportedChainIds": [
58
+ 999,
59
+ 1337,
60
+ 4217,
61
+ 42431,
62
+ ],
63
+ }
64
+ `)
65
+ })
66
+ })
67
+
28
68
  describe('App.from()', () => {
29
69
  test('contributes external route-group metadata', async () => {
30
70
  const group = new Hono().get(
@@ -110,6 +150,43 @@ describe('data()', () => {
110
150
  const orgs = await app.request('/v1/orgs')
111
151
  expect(orgs.status).toBe(404)
112
152
  })
153
+
154
+ test('inherits shared chain context', async () => {
155
+ const rpc = { url: () => 'https://rpc.example' }
156
+ const context = new Hono<App.Environment>().get('/context', (c) =>
157
+ c.json({
158
+ chainId: c.get('chainId'),
159
+ clientChainId: c.get('getClient')().chain.id,
160
+ rpc: c.get('rpc') === rpc,
161
+ supportedChainIds: [...c.get('supportedChainIds')].sort((a, b) => a - b),
162
+ }),
163
+ )
164
+ const app = App.create({
165
+ auth: false,
166
+ db: TestApp.database(),
167
+ defaultChainId: 333,
168
+ rpc,
169
+ supportedChainIds: [444],
170
+ })
171
+ .route('/', data())
172
+ .route('/', context)
173
+
174
+ const response = await app.request('/context')
175
+
176
+ expect(await response.json()).toMatchInlineSnapshot(`
177
+ {
178
+ "chainId": 333,
179
+ "clientChainId": 333,
180
+ "rpc": true,
181
+ "supportedChainIds": [
182
+ 333,
183
+ 444,
184
+ 4217,
185
+ 42431,
186
+ ],
187
+ }
188
+ `)
189
+ })
113
190
  })
114
191
 
115
192
  describe('relay()', () => {
@@ -33,59 +33,36 @@ import { verifiedTokens } from './routes/verified-tokens.js'
33
33
  import { schema as webhookSchema, webhooks } from './routes/webhooks.js'
34
34
 
35
35
  /**
36
- * The chain-data route group: read endpoints over blocks, transactions, tokens,
37
- * the exchange/fee-AMM, and the RPC/indexer passthroughs. It owns all chain
38
- * configuration and publishes the chain clients on request context.
36
+ * The chain-data route group extends app context with TIDX, assets, zones,
37
+ * verified tokens, and webhooks, and exposes indexed and RPC-backed reads.
39
38
  *
40
39
  * ```ts
41
40
  * App.create(options).route('/', data())
42
41
  * ```
43
42
  */
44
43
  export function data(options: data.Options = {}) {
45
- const { assets, defaultChainId = Viem.defaultChainId, rpc: rpc_option, tidx } = options
44
+ const { assets, tidx } = options
46
45
 
47
46
  // Zone endpoints are explicit trust boundaries; the API never derives them.
48
47
  const zones = new Map((options.zones ?? []).map((zone) => [zone.chainId, zone]))
49
48
  const dataRouteHandlers = new Set<unknown>()
50
49
 
51
- // The chain ids this deployment serves (built-in public chains plus
52
- // `defaultChainId` and any explicit `supportedChainIds`). Published on context
53
- // so handlers with a path `chainId` can guard it too.
54
- const supportedChainIds = new Set<number>([
55
- ...Object.keys(Viem.url).map(Number),
56
- ...Object.keys(Tidx.url).map(Number),
57
- defaultChainId,
58
- ...(options.supportedChainIds ?? []),
59
- ...zones.keys(),
60
- ])
61
-
62
- // Memoize one viem client per resolved upstream (stateless HTTP transports).
63
- // Keying on the resolved output (`url`+`basicAuth`+`chainId`), not the
64
- // principal, bounds cardinality by the number of distinct upstreams.
65
- const clients = new Map<string, ReturnType<typeof Viem.getClient>>()
66
- const getClient = (chainId = defaultChainId, principal: Auth.Principal | null = null) => {
67
- const { url, basicAuth } = Viem.resolve(rpc_option, { chainId, principal })
68
- const cacheKey = `${url}|${basicAuth ?? ''}|${chainId}`
69
- const existing = clients.get(cacheKey)
70
- if (existing) return existing
71
- const client = Viem.getClient({ chainId, principal, rpc: rpc_option })
72
- clients.set(cacheKey, client)
73
- return client
74
- }
75
-
76
- // Memoize TIDX clients per resolved upstream.
77
- const getTidx = Tidx.createGetClient({ defaultChainId, tidx })
50
+ const getTidx = Tidx.createGetClient({ tidx })
78
51
 
79
52
  const app = new Hono<App.Environment>()
80
53
  // Publish the chain-data context vars for this group's handlers. Runs after
81
54
  // `App.create`'s middleware, so `getClient`/`getTidx` can key on the
82
55
  // resolved principal and `getAsset` can read `basePath` off context.
83
56
  .use('*', async (c, next) => {
84
- c.set('chainId', defaultChainId)
57
+ const getClient = c.get('getClient')
58
+ const supportedChainIds = new Set<number>([
59
+ ...c.get('supportedChainIds'),
60
+ ...Object.keys(Tidx.url).map(Number),
61
+ ...zones.keys(),
62
+ ])
85
63
  c.set('getAsset', createGetAsset({ assets, basePath: c.get('basePath'), origin: new URL(c.req.url).origin })) // prettier-ignore
86
- c.set('getClient', (chainId) => getClient(chainId, Auth.getPrincipal(c)))
87
- c.set('getTidx', (chainId) => getTidx(chainId, Auth.getPrincipal(c)))
88
- c.set('rpc', rpc_option)
64
+ c.set('getClient', (chainId) => getClient(chainId ?? c.get('chainId')))
65
+ c.set('getTidx', (chainId) => getTidx(chainId ?? c.get('chainId'), Auth.getPrincipal(c)))
89
66
  c.set('supportedChainIds', supportedChainIds)
90
67
  c.set('tidx', tidx)
91
68
  c.set('verifiedTokens', options.verifiedTokens === false ? undefined : (options.verifiedTokens ?? {})) // prettier-ignore
@@ -95,6 +72,8 @@ export function data(options: data.Options = {}) {
95
72
  })
96
73
  // Resolve one chain for every data route before auth and handlers use it.
97
74
  .use('*', async (c, next) => {
75
+ const defaultChainId = c.get('chainId')
76
+ const supportedChainIds = c.get('supportedChainIds')
98
77
  const selection = resolveChainSelector({
99
78
  c,
100
79
  defaultChainId,
@@ -118,7 +97,7 @@ export function data(options: data.Options = {}) {
118
97
  if (zones.size === 0) return next()
119
98
  const selection = resolveChainSelector({
120
99
  c,
121
- defaultChainId,
100
+ defaultChainId: c.get('chainId'),
122
101
  handlers: dataRouteHandlers,
123
102
  })
124
103
  if (!selection.matched) return next()
@@ -155,7 +134,7 @@ export function data(options: data.Options = {}) {
155
134
 
156
135
  const selection = resolveChainSelector({
157
136
  c,
158
- defaultChainId,
137
+ defaultChainId: c.get('chainId'),
159
138
  handlers: dataRouteHandlers,
160
139
  })
161
140
  if (!selection.matched || !Viem.isMainnet(c.get('chainId'))) return next()
@@ -197,7 +176,11 @@ export function data(options: data.Options = {}) {
197
176
  .route('/', coingecko())
198
177
  .route('/', blocksByTimestampCompat())
199
178
  const edgeCacheEligible = (c: Context<App.Environment>) => {
200
- const selection = resolveChainSelector({ c, defaultChainId, handlers: dataRouteHandlers })
179
+ const selection = resolveChainSelector({
180
+ c,
181
+ defaultChainId: c.get('chainId'),
182
+ handlers: dataRouteHandlers,
183
+ })
201
184
  return (
202
185
  selection.matched &&
203
186
  !selection.conflict &&
@@ -210,7 +193,10 @@ export function data(options: data.Options = {}) {
210
193
  dataRouteHandlers.add(route.handler)
211
194
  if (zones.size > 0) EdgeCache.setEligibility(route.handler, edgeCacheEligible)
212
195
  }
213
- return Metadata.attach(app, openapi)
196
+ return Metadata.attach(app, {
197
+ ...openapi,
198
+ supportedChainIds: [...Object.keys(Tidx.url).map(Number), ...zones.keys()],
199
+ })
214
200
  }
215
201
 
216
202
  export declare namespace data {
@@ -218,14 +204,8 @@ export declare namespace data {
218
204
  type Options = {
219
205
  /** Static API asset store for the current runtime (e.g. `Assets.cloudflareR2(...)`). Omit to skip static assets — token logo URIs go unresolved. */
220
206
  assets?: Assets.Assets | undefined
221
- /** Default Tempo chain id for requests that omit `chainId`. */
222
- defaultChainId?: Viem.ChainId | undefined
223
207
  /** Funding quote provider configuration. */
224
208
  funding?: funding.Options | undefined
225
- /** Tempo RPC options: a static config, or a per-request resolver that routes by request principal to a separate (e.g. public, no-auth) RPC. */
226
- rpc?: Viem.getClient.Rpc | undefined
227
- /** Extra chain ids this deployment serves, beyond the built-in public chains and `defaultChainId`. A `chainId` outside this set is rejected with a 400. */
228
- supportedChainIds?: readonly number[] | undefined
229
209
  /** TIDX query client options. */
230
210
  tidx?: Tidx.getClient.Tidx | undefined
231
211
  /** Verified-token feature configuration. Enabled by default (token rows live in `db`; empty until published); pass `false` to always serve an empty verified list. */
@@ -4,6 +4,124 @@ import * as FundingQuoteProviders from './FundingQuoteProviders.js'
4
4
  const now = () => new Date('2026-01-01T00:00:00.000Z')
5
5
 
6
6
  describe('FundingQuoteProviders', () => {
7
+ test('requires every Across deployment binding when any is configured', () => {
8
+ expect(FundingQuoteProviders.configureAcross()).toBeUndefined()
9
+ expect(() =>
10
+ FundingQuoteProviders.configureAcross({ apiKey: 'across-api-key' }),
11
+ ).toThrowErrorMatchingInlineSnapshot(`[FundingQuoteProviders.ProviderConfigurationError]`)
12
+ expect(
13
+ FundingQuoteProviders.configureAcross({
14
+ apiKey: 'across-api-key',
15
+ integratorId: '0xdead',
16
+ userAddress: '0x1111111111111111111111111111111111111111',
17
+ }),
18
+ ).toEqual({
19
+ apiKey: 'across-api-key',
20
+ integratorId: '0xdead',
21
+ userAddress: '0x1111111111111111111111111111111111111111',
22
+ })
23
+ })
24
+
25
+ test('maps successful Across quotes to available quote probes', async () => {
26
+ const requests: RecordedRequest[] = []
27
+ const candidate = quoteCandidate('across')
28
+ const fetch = recordingFetch(requests, async () => jsonResponse(acrossQuote(candidate)))
29
+ const adapter = FundingQuoteProviders.across({
30
+ apiKey: 'across-api-key',
31
+ baseUrl: 'https://across.test/api/',
32
+ fetch,
33
+ integratorId: '0xdead',
34
+ now,
35
+ userAddress: '0x1111111111111111111111111111111111111111',
36
+ })
37
+
38
+ const result = await adapter.probeQuote(probeInput(candidate), new AbortController().signal)
39
+
40
+ expect(requests).toHaveLength(1)
41
+ expect(requests[0]?.url.pathname).toBe('/api/swap/approval')
42
+ expect(requests[0]?.url.searchParams.get('originChainId')).toBe('8453')
43
+ expect(requests[0]?.url.searchParams.get('destinationChainId')).toBe('4217')
44
+ expect(requests[0]?.url.searchParams.get('amount')).toBe('1000000')
45
+ expect(requests[0]?.url.searchParams.get('integratorId')).toBe('0xdead')
46
+ expect(requests[0]?.url.searchParams.get('tradeType')).toBe('exactInput')
47
+ expect(requests[0]?.url.searchParams.get('skipOriginTxEstimation')).toBe('true')
48
+ expect(requests[0]?.url.searchParams.get('strictTradeType')).toBe('true')
49
+ expect(requests[0]?.url.searchParams.get('depositor')).toBe(
50
+ '0x1111111111111111111111111111111111111111',
51
+ )
52
+ expect(requests[0]?.url.searchParams.get('recipient')).toBe(
53
+ '0x1111111111111111111111111111111111111111',
54
+ )
55
+ expect(new Headers(requests[0]?.init?.headers).get('authorization')).toBe(
56
+ 'Bearer across-api-key',
57
+ )
58
+ expect(result).toMatchObject({
59
+ expiresAt: '2026-01-01T00:01:00.000Z',
60
+ minimumOutputAmount: '988000',
61
+ outputAmount: '990000',
62
+ outputAmountFormatted: '0.99',
63
+ quality: {
64
+ estimatedSeconds: 2,
65
+ liquiditySource: 'providerQuote',
66
+ sourceDetail: 'across:swap-approval',
67
+ tier: 'liquid',
68
+ },
69
+ sampledAt: '2026-01-01T00:00:00.000Z',
70
+ status: 'available',
71
+ })
72
+ })
73
+
74
+ test('maps recognized Across quote errors to unavailable probes', async () => {
75
+ const candidate = quoteCandidate('across')
76
+ const fetch = recordingFetch([], async () => jsonResponse({ code: 'NO_ROUTE' }, 400))
77
+ const adapter = FundingQuoteProviders.across({
78
+ apiKey: 'across-api-key',
79
+ fetch,
80
+ integratorId: '0xdead',
81
+ now,
82
+ userAddress: '0x1111111111111111111111111111111111111111',
83
+ })
84
+
85
+ await expect(
86
+ adapter.probeQuote(probeInput(candidate), new AbortController().signal),
87
+ ).resolves.toMatchObject({
88
+ quality: {
89
+ liquiditySource: 'providerQuote',
90
+ sourceDetail: 'across:swap-approval:NO_ROUTE',
91
+ tier: 'unavailable',
92
+ },
93
+ status: 'unavailable',
94
+ })
95
+ })
96
+
97
+ test('rejects malformed Across quote payloads', async () => {
98
+ const candidate = quoteCandidate('across')
99
+ const fetch = recordingFetch([], async () =>
100
+ jsonResponse({ ...acrossQuote(candidate), expectedOutputAmount: '0' }),
101
+ )
102
+ const adapter = FundingQuoteProviders.across({
103
+ apiKey: 'across-api-key',
104
+ fetch,
105
+ integratorId: '0xdead',
106
+ now,
107
+ userAddress: '0x1111111111111111111111111111111111111111',
108
+ })
109
+
110
+ await expect(
111
+ adapter.probeQuote(probeInput(candidate), new AbortController().signal),
112
+ ).rejects.toThrowErrorMatchingInlineSnapshot(`[FundingQuoteProviders.ProviderPayloadError]`)
113
+ })
114
+
115
+ test('rejects invalid Across configuration', () => {
116
+ expect(() =>
117
+ FundingQuoteProviders.across({
118
+ apiKey: 'across-api-key',
119
+ integratorId: 'tempo',
120
+ userAddress: '0x1111111111111111111111111111111111111111',
121
+ }),
122
+ ).toThrowErrorMatchingInlineSnapshot(`[FundingQuoteProviders.ProviderConfigurationError]`)
123
+ })
124
+
7
125
  test('maps successful Relay quotes to available quote probes', async () => {
8
126
  const requests: RecordedRequest[] = []
9
127
  const candidate = quoteCandidate()
@@ -54,6 +172,191 @@ describe('FundingQuoteProviders', () => {
54
172
  })
55
173
  })
56
174
 
175
+ test('maps successful Rhino quotes to available quote probes', async () => {
176
+ const requests: RecordedRequest[] = []
177
+ const candidate = quoteCandidate('rhino')
178
+ const fetch = recordingFetch(requests, async () =>
179
+ jsonResponse({
180
+ _tag: 'bridge',
181
+ chainIn: 'BASE',
182
+ chainOut: 'TEMPO',
183
+ estimatedDuration: 1_500,
184
+ payAmount: '1.0000000',
185
+ receiveAmount: '0.9830000',
186
+ token: 'USDC',
187
+ }),
188
+ )
189
+ const adapter = FundingQuoteProviders.rhino({
190
+ baseUrl: 'https://rhino.test/bridge/',
191
+ fetch,
192
+ now,
193
+ })
194
+
195
+ const result = await adapter.probeQuote(probeInput(candidate), new AbortController().signal)
196
+
197
+ expect(requests).toHaveLength(1)
198
+ expect(requests[0]?.url.pathname).toBe('/bridge/quote/bridge-swap/public')
199
+ expect(Object.fromEntries(requests[0]!.url.searchParams)).toEqual({
200
+ amount: '1',
201
+ amountNative: '0',
202
+ chainIn: 'BASE',
203
+ chainOut: 'TEMPO',
204
+ mode: 'pay',
205
+ tokenIn: 'USDC',
206
+ tokenOut: 'USDC',
207
+ })
208
+ expect(result).toEqual({
209
+ outputAmount: '983000',
210
+ outputAmountFormatted: '0.983',
211
+ quality: {
212
+ estimatedSeconds: 2,
213
+ liquiditySource: 'providerQuote',
214
+ sourceDetail: 'rhino:bridge-swap-public',
215
+ tier: 'liquid',
216
+ },
217
+ sampledAt: '2026-01-01T00:00:00.000Z',
218
+ status: 'available',
219
+ })
220
+ })
221
+
222
+ test('maps unavailable Rhino routes to unavailable quote probes', async () => {
223
+ const candidate = quoteCandidate('rhino')
224
+ const adapter = FundingQuoteProviders.rhino({
225
+ fetch: recordingFetch([], async () => jsonResponse({ _tag: 'NoRouteFoundError' }, 404)),
226
+ now,
227
+ })
228
+
229
+ await expect(
230
+ adapter.probeQuote(probeInput(candidate), new AbortController().signal),
231
+ ).resolves.toMatchObject({
232
+ quality: {
233
+ sourceDetail: 'rhino:bridge-swap-public:NoRouteFoundError',
234
+ tier: 'unavailable',
235
+ },
236
+ status: 'unavailable',
237
+ })
238
+ })
239
+
240
+ test('maps Solana and Tron identifiers into Rhino quote requests', async () => {
241
+ const cases = [
242
+ {
243
+ candidate: quoteCandidate('rhino', { sourceChain: 'solana' }),
244
+ chainIn: 'SOLANA',
245
+ tokenOut: 'USDC',
246
+ },
247
+ {
248
+ candidate: quoteCandidate('rhino', {
249
+ destinationToken: 'USDT0',
250
+ sourceChain: 'tron',
251
+ sourceToken: 'USDT',
252
+ }),
253
+ chainIn: 'TRON',
254
+ tokenOut: 'USDT',
255
+ },
256
+ ] as const
257
+
258
+ for (const testCase of cases) {
259
+ const requests: RecordedRequest[] = []
260
+ const adapter = FundingQuoteProviders.rhino({
261
+ fetch: recordingFetch(requests, async () =>
262
+ jsonResponse({
263
+ _tag: 'bridge',
264
+ chainIn: testCase.chainIn,
265
+ chainOut: 'TEMPO',
266
+ payAmount: '1',
267
+ receiveAmount: '0.983',
268
+ token: testCase.tokenOut,
269
+ }),
270
+ ),
271
+ now,
272
+ })
273
+
274
+ await expect(
275
+ adapter.probeQuote(probeInput(testCase.candidate), new AbortController().signal),
276
+ ).resolves.toMatchObject({ outputAmount: '983000', status: 'available' })
277
+ expect(requests[0]?.url.searchParams.get('chainIn')).toBe(testCase.chainIn)
278
+ expect(requests[0]?.url.searchParams.get('tokenOut')).toBe(testCase.tokenOut)
279
+ }
280
+ })
281
+
282
+ test('surfaces Rhino request decoding failures', async () => {
283
+ const candidate = quoteCandidate('rhino')
284
+ const adapter = FundingQuoteProviders.rhino({
285
+ fetch: recordingFetch([], async () => jsonResponse({ _tag: 'HttpApiDecodeError' }, 400)),
286
+ now,
287
+ })
288
+
289
+ await expect(
290
+ adapter.probeQuote(probeInput(candidate), new AbortController().signal),
291
+ ).rejects.toThrowErrorMatchingInlineSnapshot(
292
+ `[FundingQuoteProviders.ProviderResponseError: Provider request failed with status 400]`,
293
+ )
294
+ })
295
+
296
+ test('surfaces unrecognized Rhino route errors', async () => {
297
+ const candidate = quoteCandidate('rhino')
298
+ const failures = [
299
+ { body: null, status: 404 },
300
+ { body: { _tag: 'InvalidRequest' }, status: 422 },
301
+ ] as const
302
+
303
+ for (const failure of failures) {
304
+ const adapter = FundingQuoteProviders.rhino({
305
+ fetch: recordingFetch([], async () => jsonResponse(failure.body, failure.status)),
306
+ now,
307
+ })
308
+ const cause = await adapter
309
+ .probeQuote(probeInput(candidate), new AbortController().signal)
310
+ .catch((error: unknown) => error)
311
+ expect(cause).toMatchObject({
312
+ name: 'FundingQuoteProviders.ProviderResponseError',
313
+ status: failure.status,
314
+ })
315
+ }
316
+ })
317
+
318
+ test('rejects mismatched Rhino quote payloads', async () => {
319
+ const candidate = quoteCandidate('rhino')
320
+ const adapter = FundingQuoteProviders.rhino({
321
+ fetch: recordingFetch([], async () =>
322
+ jsonResponse({
323
+ _tag: 'bridge',
324
+ chainIn: 'SOLANA',
325
+ chainOut: 'TEMPO',
326
+ payAmount: '1',
327
+ receiveAmount: '0.983',
328
+ token: 'USDC',
329
+ }),
330
+ ),
331
+ now,
332
+ })
333
+
334
+ await expect(
335
+ adapter.probeQuote(probeInput(candidate), new AbortController().signal),
336
+ ).rejects.toThrowErrorMatchingInlineSnapshot(`[FundingQuoteProviders.ProviderPayloadError]`)
337
+ })
338
+
339
+ test('rejects non-representable Rhino output precision', async () => {
340
+ const candidate = quoteCandidate('rhino')
341
+ const adapter = FundingQuoteProviders.rhino({
342
+ fetch: recordingFetch([], async () =>
343
+ jsonResponse({
344
+ _tag: 'bridge',
345
+ chainIn: 'BASE',
346
+ chainOut: 'TEMPO',
347
+ payAmount: '1',
348
+ receiveAmount: '0.9830001',
349
+ token: 'USDC',
350
+ }),
351
+ ),
352
+ now,
353
+ })
354
+
355
+ await expect(
356
+ adapter.probeQuote(probeInput(candidate), new AbortController().signal),
357
+ ).rejects.toThrowErrorMatchingInlineSnapshot(`[FundingQuoteProviders.ProviderPayloadError]`)
358
+ })
359
+
57
360
  test('calls the runtime fetch method with its global receiver', async () => {
58
361
  const candidate = quoteCandidate()
59
362
  const fetch = vi.fn(function (this: unknown) {
@@ -82,6 +385,94 @@ describe('FundingQuoteProviders', () => {
82
385
  vi.unstubAllGlobals()
83
386
  }
84
387
  })
388
+
389
+ const invalidAcrossQuotes = [
390
+ [
391
+ 'mismatched input amounts',
392
+ (body: ReturnType<typeof acrossQuote>) => (body.inputAmount = '1'),
393
+ ],
394
+ [
395
+ 'mismatched token addresses',
396
+ (body: ReturnType<typeof acrossQuote>) =>
397
+ (body.inputToken.address = '0xfde4c96c8593536e31f229ea8f37b2ada2699bb2'),
398
+ ],
399
+ [
400
+ 'mismatched token chains',
401
+ (body: ReturnType<typeof acrossQuote>) => (body.inputToken.chainId = 1),
402
+ ],
403
+ [
404
+ 'mismatched token decimals',
405
+ (body: ReturnType<typeof acrossQuote>) => (body.inputToken.decimals = 18),
406
+ ],
407
+ [
408
+ 'expired quotes',
409
+ (body: ReturnType<typeof acrossQuote>) => (body.quoteExpiryTimestamp = 1767225600),
410
+ ],
411
+ [
412
+ 'out-of-range expiries',
413
+ (body: ReturnType<typeof acrossQuote>) =>
414
+ (body.quoteExpiryTimestamp = Number.MAX_SAFE_INTEGER),
415
+ ],
416
+ [
417
+ 'invalid output bounds',
418
+ (body: ReturnType<typeof acrossQuote>) => (body.minOutputAmount = '990001'),
419
+ ],
420
+ ] as const
421
+
422
+ for (const [name, mutate] of invalidAcrossQuotes)
423
+ test(`rejects Across quotes with ${name}`, async () => {
424
+ const candidate = quoteCandidate('across')
425
+ const body = acrossQuote(candidate)
426
+ mutate(body)
427
+ const adapter = FundingQuoteProviders.across({
428
+ apiKey: 'across-api-key',
429
+ fetch: recordingFetch([], async () => jsonResponse(body)),
430
+ integratorId: '0xdead',
431
+ now,
432
+ userAddress: '0x1111111111111111111111111111111111111111',
433
+ })
434
+
435
+ await expect(
436
+ adapter.probeQuote(probeInput(candidate), new AbortController().signal),
437
+ ).rejects.toThrowErrorMatchingInlineSnapshot(`[FundingQuoteProviders.ProviderPayloadError]`)
438
+ })
439
+
440
+ test('returns bounded Across failure diagnostics', async () => {
441
+ const candidate = quoteCandidate('across')
442
+ const adapter = FundingQuoteProviders.across({
443
+ apiKey: 'across-api-key',
444
+ fetch: recordingFetch([], async () =>
445
+ jsonResponse({ apiKey: 'secret', code: 'UNAUTHORIZED' }, 401),
446
+ ),
447
+ integratorId: '0xdead',
448
+ now,
449
+ userAddress: '0x1111111111111111111111111111111111111111',
450
+ })
451
+ const cause = await adapter
452
+ .probeQuote(probeInput(candidate), new AbortController().signal)
453
+ .catch((error: unknown) => error)
454
+
455
+ expect(FundingQuoteProviders.providerFailure({ cause, provider: 'across' })).toEqual({
456
+ code: 'UNAUTHORIZED',
457
+ failure: 'http',
458
+ id: 'across',
459
+ operation: 'swap/approval',
460
+ status: 401,
461
+ })
462
+ })
463
+
464
+ test('returns explicit timeout failure diagnostics', () => {
465
+ expect(
466
+ FundingQuoteProviders.providerFailure({
467
+ cause: new FundingQuotes.ProviderAdapterTimeoutError(),
468
+ provider: 'across',
469
+ }),
470
+ ).toEqual({
471
+ failure: 'timeout',
472
+ id: 'across',
473
+ operation: 'swap/approval',
474
+ })
475
+ })
85
476
  })
86
477
 
87
478
  type RecordedRequest = {
@@ -89,18 +480,53 @@ type RecordedRequest = {
89
480
  url: URL
90
481
  }
91
482
 
92
- function quoteCandidate(): FundingQuotes.QuoteProbeInput['candidate'] {
483
+ function quoteCandidate(
484
+ provider: FundingQuotes.ProviderId = 'relay',
485
+ options: quoteCandidate.Options = {},
486
+ ): FundingQuotes.QuoteProbeInput['candidate'] {
93
487
  const [candidate] = FundingQuotes.listQuoteCandidates({
94
- provider: 'relay',
488
+ destinationToken: options.destinationToken,
489
+ provider,
95
490
  sourceAmount: '1000000',
96
491
  sourceAmountUnits: 'baseUnits',
97
- sourceToken: 'USDC',
98
- sourceChain: 'base',
492
+ sourceChain: options.sourceChain ?? 'base',
493
+ sourceToken: options.sourceToken ?? 'USDC',
99
494
  })
100
- if (!candidate) throw new Error('Missing Relay quote candidate')
495
+ if (!candidate) throw new Error(`Missing ${provider} quote candidate`)
101
496
  return candidate
102
497
  }
103
498
 
499
+ declare namespace quoteCandidate {
500
+ type Options = {
501
+ destinationToken?: string | undefined
502
+ sourceChain?: string | undefined
503
+ sourceToken?: string | undefined
504
+ }
505
+ }
506
+
507
+ function acrossQuote(candidate: FundingQuotes.QuoteProbeInput['candidate']) {
508
+ return {
509
+ amountType: 'exactInput',
510
+ expectedFillTime: 2,
511
+ expectedOutputAmount: '990000',
512
+ inputAmount: candidate.sourceAmount.amount,
513
+ inputToken: {
514
+ address: candidate.sourceToken.address,
515
+ chainId: 8453,
516
+ decimals: candidate.sourceToken.decimals,
517
+ symbol: candidate.sourceToken.symbol,
518
+ },
519
+ minOutputAmount: '988000',
520
+ outputToken: {
521
+ address: candidate.destinationToken.address,
522
+ chainId: 4217,
523
+ decimals: candidate.destinationToken.decimals,
524
+ symbol: candidate.destinationToken.symbol,
525
+ },
526
+ quoteExpiryTimestamp: 1767225660,
527
+ }
528
+ }
529
+
104
530
  function probeInput(
105
531
  candidate: FundingQuotes.QuoteProbeInput['candidate'],
106
532
  ): FundingQuotes.QuoteProbeInput {