tapimo 0.5.0 → 0.5.2

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 (197) hide show
  1. package/dist/App.d.ts +4854 -4829
  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 +2 -2
  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 +1398 -1405
  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 +108 -3
  33. package/dist/apps/data/FundingQuoteProviders.d.ts.map +1 -1
  34. package/dist/apps/data/FundingQuoteProviders.js +409 -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/blocks.d.ts +123 -123
  38. package/dist/apps/data/routes/funding.d.ts +236 -146
  39. package/dist/apps/data/routes/funding.d.ts.map +1 -1
  40. package/dist/apps/data/routes/funding.js +23 -12
  41. package/dist/apps/data/routes/funding.js.map +1 -1
  42. package/dist/apps/data/routes/indexer.d.ts.map +1 -1
  43. package/dist/apps/data/routes/indexer.js +9 -2
  44. package/dist/apps/data/routes/indexer.js.map +1 -1
  45. package/dist/apps/data/routes/rpc.d.ts +31 -31
  46. package/dist/apps/data/routes/tokenlist.d.ts +36 -36
  47. package/dist/apps/data/routes/transactions.d.ts +281 -281
  48. package/dist/apps/data/routes/transfers.d.ts +66 -73
  49. package/dist/apps/data/routes/transfers.d.ts.map +1 -1
  50. package/dist/apps/data/routes/transfers.js +21 -27
  51. package/dist/apps/data/routes/transfers.js.map +1 -1
  52. package/dist/apps/data/routes/verified-tokens.d.ts +86 -86
  53. package/dist/apps/data/routes/webhooks.d.ts +35 -35
  54. package/dist/apps/data/routes/webhooks.d.ts.map +1 -1
  55. package/dist/apps/data/routes/webhooks.js +176 -42
  56. package/dist/apps/data/routes/webhooks.js.map +1 -1
  57. package/dist/apps/management/App.d.ts +520 -504
  58. package/dist/apps/management/App.d.ts.map +1 -1
  59. package/dist/apps/management/Billing.d.ts.map +1 -1
  60. package/dist/apps/management/Billing.js +5 -4
  61. package/dist/apps/management/Billing.js.map +1 -1
  62. package/dist/apps/management/routes/api-keys.d.ts +120 -120
  63. package/dist/apps/management/routes/billing.d.ts +211 -211
  64. package/dist/apps/management/routes/billing.d.ts.map +1 -1
  65. package/dist/apps/management/routes/billing.js +1 -1
  66. package/dist/apps/management/routes/billing.js.map +1 -1
  67. package/dist/apps/management/routes/invitations.d.ts +72 -72
  68. package/dist/apps/management/routes/invite-links.d.ts +159 -159
  69. package/dist/apps/management/routes/me.d.ts +17 -17
  70. package/dist/apps/management/routes/members.d.ts +80 -80
  71. package/dist/apps/management/routes/orgs.d.ts +59 -43
  72. package/dist/apps/management/routes/orgs.d.ts.map +1 -1
  73. package/dist/apps/management/routes/orgs.js +18 -1
  74. package/dist/apps/management/routes/orgs.js.map +1 -1
  75. package/dist/apps/management/routes/projects.d.ts +109 -109
  76. package/dist/apps/management/routes/scopes.d.ts +11 -11
  77. package/dist/apps/management/routes/usage.d.ts +79 -79
  78. package/dist/apps/mcp.js +2 -2
  79. package/dist/apps/mcp.js.map +1 -1
  80. package/dist/apps/relay/App.d.ts +2 -2
  81. package/dist/apps/relay/App.js +2 -2
  82. package/dist/apps/relay/Sponsorships.d.ts +3 -0
  83. package/dist/apps/relay/Sponsorships.d.ts.map +1 -1
  84. package/dist/apps/relay/Sponsorships.js +15 -1
  85. package/dist/apps/relay/Sponsorships.js.map +1 -1
  86. package/dist/db/Db.d.ts.map +1 -1
  87. package/dist/db/Db.js +4 -0
  88. package/dist/db/Db.js.map +1 -1
  89. package/dist/db/Schema.js +1 -1
  90. package/dist/db/Schema.js.map +1 -1
  91. package/dist/db/tables/organizations.d.ts +6 -0
  92. package/dist/db/tables/organizations.d.ts.map +1 -1
  93. package/dist/db/tables/organizations.js +26 -0
  94. package/dist/db/tables/organizations.js.map +1 -1
  95. package/dist/db/tables/requestUsage.d.ts +2 -11
  96. package/dist/db/tables/requestUsage.d.ts.map +1 -1
  97. package/dist/db/tables/requestUsage.js +1 -20
  98. package/dist/db/tables/requestUsage.js.map +1 -1
  99. package/dist/db/tables/sponsoredTransactions.d.ts +2 -1
  100. package/dist/db/tables/sponsoredTransactions.d.ts.map +1 -1
  101. package/dist/db/tables/sponsoredTransactions.js +52 -10
  102. package/dist/db/tables/sponsoredTransactions.js.map +1 -1
  103. package/dist/internal/Auth.d.ts +4 -0
  104. package/dist/internal/Auth.d.ts.map +1 -1
  105. package/dist/internal/Auth.js +15 -8
  106. package/dist/internal/Auth.js.map +1 -1
  107. package/dist/internal/Cache.d.ts.map +1 -1
  108. package/dist/internal/Cache.js +9 -1
  109. package/dist/internal/Cache.js.map +1 -1
  110. package/dist/internal/EdgeCache.d.ts.map +1 -1
  111. package/dist/internal/EdgeCache.js +12 -3
  112. package/dist/internal/EdgeCache.js.map +1 -1
  113. package/dist/internal/FundingQuotes.d.ts +176 -56
  114. package/dist/internal/FundingQuotes.d.ts.map +1 -1
  115. package/dist/internal/FundingQuotes.js +100 -5
  116. package/dist/internal/FundingQuotes.js.map +1 -1
  117. package/dist/internal/Log.d.ts +18 -12
  118. package/dist/internal/Log.d.ts.map +1 -1
  119. package/dist/internal/Log.js +2 -0
  120. package/dist/internal/Log.js.map +1 -1
  121. package/dist/internal/RateLimit.d.ts +2 -0
  122. package/dist/internal/RateLimit.d.ts.map +1 -1
  123. package/dist/internal/RateLimit.js +4 -4
  124. package/dist/internal/RateLimit.js.map +1 -1
  125. package/dist/internal/Tidx.d.ts +9 -3
  126. package/dist/internal/Tidx.d.ts.map +1 -1
  127. package/dist/internal/Tidx.js +13 -7
  128. package/dist/internal/Tidx.js.map +1 -1
  129. package/dist/internal/Viem.d.ts +14 -1
  130. package/dist/internal/Viem.d.ts.map +1 -1
  131. package/dist/internal/Viem.js +19 -1
  132. package/dist/internal/Viem.js.map +1 -1
  133. package/package.json +7 -2
  134. package/src/ApiKeys.test.ts +2 -0
  135. package/src/App.test.ts +15 -4
  136. package/src/App.ts +31 -5
  137. package/src/Assets.ts +3 -3
  138. package/src/Client.test-d.ts +30 -3
  139. package/src/Scope.ts +4 -2
  140. package/src/Webhooks.ts +5 -10
  141. package/src/admin/apps/api-keys.test.ts +27 -0
  142. package/src/admin/apps/api-keys.ts +8 -1
  143. package/src/admin/apps/scopes.test.ts +7 -2
  144. package/src/admin/apps/scopes.ts +2 -2
  145. package/src/admin/ui/src/components/Dialog.tsx +8 -2
  146. package/src/admin/ui/src/lib/scopes.test.ts +19 -0
  147. package/src/admin/ui/src/lib/scopes.ts +12 -0
  148. package/src/admin/ui/src/routes/api-keys.tsx +128 -27
  149. package/src/admin/ui.generated.ts +33 -33
  150. package/src/apps/composition.test.ts +77 -0
  151. package/src/apps/data/App.ts +25 -45
  152. package/src/apps/data/FundingQuoteProviders.test.ts +615 -5
  153. package/src/apps/data/FundingQuoteProviders.ts +570 -4
  154. package/src/apps/data/routes/funding.test.ts +181 -7
  155. package/src/apps/data/routes/funding.ts +27 -12
  156. package/src/apps/data/routes/indexer.test.ts +82 -20
  157. package/src/apps/data/routes/indexer.ts +8 -2
  158. package/src/apps/data/routes/tokens.test.ts +54 -0
  159. package/src/apps/data/routes/transfers.test.ts +73 -1
  160. package/src/apps/data/routes/transfers.ts +24 -30
  161. package/src/apps/data/routes/webhooks.test.ts +203 -0
  162. package/src/apps/data/routes/webhooks.ts +191 -54
  163. package/src/apps/management/Billing.test.ts +52 -3
  164. package/src/apps/management/Billing.ts +5 -4
  165. package/src/apps/management/routes/billing.ts +6 -1
  166. package/src/apps/management/routes/orgs.ts +19 -1
  167. package/src/apps/mcp.test.ts +18 -0
  168. package/src/apps/mcp.ts +2 -2
  169. package/src/apps/relay/App.test.ts +6 -5
  170. package/src/apps/relay/App.ts +2 -2
  171. package/src/apps/relay/Sponsorships.test.ts +2 -2
  172. package/src/apps/relay/Sponsorships.ts +24 -1
  173. package/src/db/Db.test.ts +5 -5
  174. package/src/db/Db.ts +4 -0
  175. package/src/db/Schema.ts +1 -1
  176. package/src/db/tables/billingSettings.test.ts +2 -2
  177. package/src/db/tables/organizations.test.ts +32 -2
  178. package/src/db/tables/organizations.ts +28 -0
  179. package/src/db/tables/requestUsage.ts +3 -23
  180. package/src/db/tables/sponsoredTransactions.test.ts +60 -2
  181. package/src/db/tables/sponsoredTransactions.ts +67 -21
  182. package/src/db/tables/stripeCustomers.test.ts +2 -2
  183. package/src/internal/Auth.test.ts +63 -10
  184. package/src/internal/Auth.ts +21 -9
  185. package/src/internal/Cache.test.ts +19 -0
  186. package/src/internal/Cache.ts +13 -1
  187. package/src/internal/EdgeCache.test.ts +40 -0
  188. package/src/internal/EdgeCache.ts +11 -3
  189. package/src/internal/FundingQuotes.test.ts +130 -3
  190. package/src/internal/FundingQuotes.ts +113 -6
  191. package/src/internal/Log.ts +21 -14
  192. package/src/internal/RateLimit.test.ts +12 -0
  193. package/src/internal/RateLimit.ts +4 -4
  194. package/src/internal/Tidx.test.ts +48 -0
  195. package/src/internal/Tidx.ts +21 -7
  196. package/src/internal/Viem.test.ts +46 -0
  197. package/src/internal/Viem.ts +34 -1
@@ -58,6 +58,8 @@ export type Context = {
58
58
  * framework default.
59
59
  */
60
60
  publicRateLimit?: RateLimit.Limit | undefined
61
+ /** Resolves a trusted client IP for public quota identity. */
62
+ publicClientIp?: ((request: Request) => string | undefined) | undefined
61
63
  /** Rate-limit store used by API handlers. */
62
64
  rateLimit?: RateLimit.Store | undefined
63
65
  /** Session lane resolution (the session surface's capability), or undefined when sessions are unmounted. */
@@ -523,6 +525,9 @@ export function middleware<
523
525
  // API-key-only routes (no per-route `policy.public`) still fall back to the
524
526
  // deployment's configured public quota.
525
527
  publicRateLimit: defaults.public.rateLimit,
528
+ ...(options.public && options.public.clientIp
529
+ ? { publicClientIp: options.public.clientIp }
530
+ : {}),
526
531
  ...(options.mpp === false
527
532
  ? {}
528
533
  : {
@@ -608,6 +613,8 @@ export declare namespace middleware {
608
613
  /** Default public access policy. Pass false to disable anonymous access. */
609
614
  public?:
610
615
  | {
616
+ /** Resolves the client IP from trusted runtime or proxy metadata. Defaults to `cf-connecting-ip`. */
617
+ clientIp?: ((request: Request) => string | undefined) | undefined
611
618
  /** Anonymous public quota for protected routes. */
612
619
  rateLimit?: RateLimit.Limit | undefined
613
620
  }
@@ -1406,6 +1413,16 @@ export function require<environment extends Environment = Environment>(
1406
1413
  const hasQueryApiKey = new URL(c.req.url).searchParams.has('key')
1407
1414
  // Auth owns `key`; strict route schemas must not validate it.
1408
1415
  stripApiKeyQuery(c)
1416
+ // Reject unknown chains before MPP builds a chain-bound challenge. Route
1417
+ // groups publish their supported chains into the shared app context.
1418
+ const chainId = explicitChainId(c)
1419
+ const supportedChainIds = (c.var as Record<string, unknown>)['supportedChainIds']
1420
+ if (
1421
+ chainId !== undefined &&
1422
+ supportedChainIds instanceof Set &&
1423
+ !supportedChainIds.has(chainId)
1424
+ )
1425
+ return Response.unsupportedChainId(c, chainId, supportedChainIds)
1409
1426
  const proceed = async () => {
1410
1427
  await next()
1411
1428
  // Query credential responses must not enter shared caches.
@@ -1557,7 +1574,7 @@ export function require<environment extends Environment = Environment>(
1557
1574
  }
1558
1575
  }
1559
1576
 
1560
- const publicPrincipal = principalFromPublic(c)
1577
+ const publicPrincipal = principalFromPublic(c, auth)
1561
1578
  if (hasPayment) {
1562
1579
  c.set('principal', publicPrincipal)
1563
1580
  return requirePayment(c, proceed, {
@@ -1891,15 +1908,10 @@ function principalFromApiKey(apiKey: ApiKey.ApiKey): Principal {
1891
1908
  }
1892
1909
  }
1893
1910
 
1894
- function principalFromPublic(c: hono_Context): Principal {
1895
- const forwarded = c.req.header('x-forwarded-for')?.split(',')[0]?.trim()
1911
+ function principalFromPublic(c: hono_Context, auth: Context): Principal {
1912
+ const ip = auth.publicClientIp ? auth.publicClientIp(c.req.raw) : c.req.header('cf-connecting-ip')
1896
1913
  return {
1897
- id:
1898
- c.req.header('cf-connecting-ip') ??
1899
- c.req.header('x-real-ip') ??
1900
- forwarded ??
1901
- c.req.header('x-test-client-ip') ??
1902
- 'anonymous',
1914
+ id: ip ? RateLimit.normalizeIp(ip) : 'anonymous',
1903
1915
  type: 'public',
1904
1916
  }
1905
1917
  }
@@ -1,6 +1,25 @@
1
1
  import { Hono } from 'hono'
2
+ import * as z from 'zod/mini'
2
3
  import * as Cache from './Cache.js'
3
4
 
5
+ describe('urlKey', () => {
6
+ test('serializes parsed arrays as repeated query parameters', async () => {
7
+ const schema = z.object({ include: z.array(z.string()) })
8
+ const app = new Hono<{ Variables: { chainId: number } }>()
9
+ app.use('*', async (c, next) => {
10
+ c.set('chainId', 4217)
11
+ await next()
12
+ })
13
+ app.get('/', (c) => c.text(Cache.urlKey(c, schema)))
14
+
15
+ const left = await (await app.request('/?include=a%2Cb&include=c')).text()
16
+ const right = await (await app.request('/?include=a&include=b%2Cc')).text()
17
+
18
+ expect(left).toMatchInlineSnapshot(`"http://localhost/?chainId=4217&include=a%2Cb&include=c"`)
19
+ expect(right).toMatchInlineSnapshot(`"http://localhost/?chainId=4217&include=a&include=b%2Cc"`)
20
+ })
21
+ })
22
+
4
23
  describe('response', () => {
5
24
  test('preserves rate-limit headers without caching them', async () => {
6
25
  await withCaches(async () => {
@@ -192,11 +192,23 @@ export function urlKey<
192
192
  schema extends z.ZodMiniType,
193
193
  >(c: Context<environment>, schema: schema): string {
194
194
  const url = new URL(c.req.url)
195
- const query = schema.parse(Object.fromEntries(url.searchParams)) as Record<string, unknown>
195
+ const query = schema.parse(
196
+ Object.fromEntries(
197
+ Object.entries(c.req.queries()).map(([key, values]) => [
198
+ key,
199
+ values.length === 1 ? values[0] : values,
200
+ ]),
201
+ ),
202
+ ) as Record<string, unknown>
196
203
  url.search = ''
197
204
  if (query['chainId'] === undefined) url.searchParams.set('chainId', String(c.get('chainId')))
198
205
  for (const [key, value] of Object.entries(query)) {
199
206
  if (value === undefined) continue
207
+ if (Array.isArray(value)) {
208
+ for (const item of value)
209
+ url.searchParams.append(key, String(item as number | string | boolean))
210
+ continue
211
+ }
200
212
  url.searchParams.set(key, String(value as number | string | boolean))
201
213
  }
202
214
  return url.toString()
@@ -130,6 +130,33 @@ describe('middleware', () => {
130
130
  expect(store.size).toMatchInlineSnapshot(`0`)
131
131
  })
132
132
 
133
+ test('strips payment headers from private responses before sharing them', async () => {
134
+ const { app, store, downstreamCalls } = createApp()
135
+
136
+ const miss = await app.request('/private')
137
+ const hit = await app.request('/private')
138
+
139
+ expect(downstreamCalls()).toMatchInlineSnapshot(`1`)
140
+ expect(store.size).toMatchInlineSnapshot(`1`)
141
+ expect(miss.headers.get('payment-receipt')).toMatchInlineSnapshot(`"payer-specific"`)
142
+ expect(miss.headers.get('payment-session-snapshot')).toMatchInlineSnapshot(`"session-specific"`)
143
+ expect(hit.headers.get('payment-receipt')).toMatchInlineSnapshot(`null`)
144
+ expect(hit.headers.get('payment-session-snapshot')).toMatchInlineSnapshot(`null`)
145
+ expect(hit.headers.get('cache-control')).toMatchInlineSnapshot(
146
+ `"public, max-age=30, s-maxage=30"`,
147
+ )
148
+ })
149
+
150
+ test('does not cache responses marked `no-store`', async () => {
151
+ const { app, store, downstreamCalls } = createApp()
152
+
153
+ await app.request('/no-store')
154
+ await app.request('/no-store')
155
+
156
+ expect(downstreamCalls()).toMatchInlineSnapshot(`2`)
157
+ expect(store.size).toMatchInlineSnapshot(`0`)
158
+ })
159
+
133
160
  test('does not cache routes that did not opt in', async () => {
134
161
  const { app, store, downstreamCalls } = createApp()
135
162
 
@@ -271,6 +298,19 @@ function createApp(
271
298
  c.set('edgeCache', { maxAge: 30, staleWhileRevalidate: 300 })
272
299
  return c.json({ ok: false }, 500)
273
300
  })
301
+ app.get('/private', cacheable, (c) => {
302
+ c.set('edgeCache', { maxAge: 30 })
303
+ c.header('Cache-Control', 'private, max-age=30')
304
+ c.header('Payment-Receipt', 'payer-specific')
305
+ c.header('Payment-Session-Snapshot', 'session-specific')
306
+ return c.json({ ok: true })
307
+ })
308
+ app.get('/no-store', cacheable, (c) => {
309
+ c.set('edgeCache', { maxAge: 30 })
310
+ c.header('Cache-Control', 'no-store')
311
+ c.header('Payment-Receipt', 'payer-specific')
312
+ return c.json({ ok: true })
313
+ })
274
314
  app.get('/binary', cacheable, (c) => {
275
315
  c.set('edgeCache', { maxAge: 30, staleWhileRevalidate: 300 })
276
316
  // Bytes that are not valid UTF-8 (PNG signature) — corrupted if stored via
@@ -33,12 +33,16 @@ const volatileHeaders = [
33
33
  'cf-cache-status',
34
34
  'content-encoding',
35
35
  'content-length',
36
- 'server-timing',
37
- 'set-cookie',
36
+ 'payment-receipt',
37
+ 'payment-session-snapshot',
38
38
  'ratelimit-limit',
39
39
  'ratelimit-remaining',
40
40
  'ratelimit-reset',
41
+ 'ratelimit-scope',
42
+ 'server-timing',
43
+ 'set-cookie',
41
44
  'tempo-request-id',
45
+ 'www-authenticate',
42
46
  ]
43
47
 
44
48
  /**
@@ -115,6 +119,8 @@ export function middleware<environment extends Env>(
115
119
  if (c.res.status < 200 || c.res.status >= 300) return
116
120
  if (c.res.headers.has('set-cookie')) return
117
121
  if (c.res.headers.get('content-type')?.includes('text/event-stream')) return
122
+ const responseDirectives = (c.res.headers.get('cache-control') ?? '').toLowerCase()
123
+ if (responseDirectives.includes('no-cache') || responseDirectives.includes('no-store')) return
118
124
 
119
125
  // Store a public, sanitized copy; the client still receives the original
120
126
  // (private, metered) response for this miss. Clone synchronously — the
@@ -247,7 +253,9 @@ function isTextual(contentType: string | null): boolean {
247
253
  function cacheKey(requestUrl: string) {
248
254
  const url = new URL(requestUrl)
249
255
  url.searchParams.sort()
250
- return url.toString()
256
+ // Version the key so entries written before a sanitizer change cannot
257
+ // survive the deployment that changes the shared-cache boundary.
258
+ return `edge:v2:${url.toString()}`
251
259
  }
252
260
 
253
261
  function getWaitUntil(c: { executionCtx?: { waitUntil(promise: Promise<unknown>): void } }) {
@@ -83,9 +83,11 @@ describe('listQuotes', () => {
83
83
  },
84
84
  })
85
85
 
86
- await expect(quotes).rejects.toThrowErrorMatchingInlineSnapshot(
87
- `[FundingQuotes.ProviderQuoteError]`,
88
- )
86
+ const cause = await quotes.catch((error: unknown) => error)
87
+ expect(cause).toMatchObject({
88
+ failures: [{ cause: expect.any(Error), provider: 'relay' }],
89
+ name: 'FundingQuotes.ProviderQuoteError',
90
+ })
89
91
  })
90
92
 
91
93
  test('rejects successful provider responses without output amounts', async () => {
@@ -145,6 +147,32 @@ describe('listQuotes', () => {
145
147
 
146
148
  expect(seen?.cache.bypass).toBe(true)
147
149
  })
150
+
151
+ test('returns successful quotes when another provider fails', async () => {
152
+ const quotes = await FundingQuotes.listQuotes({
153
+ ...baseUsdcRequest,
154
+ adapters: {
155
+ across: {
156
+ async probeQuote() {
157
+ throw new Error('upstream unavailable')
158
+ },
159
+ },
160
+ relay: {
161
+ async probeQuote() {
162
+ return {
163
+ outputAmount: '990000',
164
+ sampledAt: '2026-01-01T00:00:00Z',
165
+ status: 'available',
166
+ }
167
+ },
168
+ },
169
+ },
170
+ provider: undefined,
171
+ })
172
+
173
+ expect(quotes).toHaveLength(1)
174
+ expect(quotes[0]?.provider.id).toBe('relay')
175
+ })
148
176
  })
149
177
 
150
178
  describe('listChains', () => {
@@ -153,6 +181,84 @@ describe('listChains', () => {
153
181
  'eip155:1',
154
182
  'eip155:8453',
155
183
  'eip155:56',
184
+ 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp',
185
+ 'tron:0x2b6653dc',
186
+ ])
187
+ })
188
+
189
+ test('uses chain-specific token keys for non-EVM inventory', () => {
190
+ const chains = FundingQuotes.listChains()
191
+ expect(chains.find((chain) => chain.kind === 'solana')?.tokens[0]?.tokenKey).toBe(
192
+ 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/token:EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v',
193
+ )
194
+ expect(chains.find((chain) => chain.kind === 'tron')?.tokens[0]?.tokenKey).toBe(
195
+ 'tron:0x2b6653dc/trc20:TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t',
196
+ )
197
+ })
198
+ })
199
+
200
+ describe('listQuoteCandidates', () => {
201
+ test('round-trips the advertised Solana chain and token ids', () => {
202
+ const [candidate] = FundingQuotes.listQuoteCandidates({
203
+ provider: 'rhino',
204
+ sourceAmount: '1000000',
205
+ sourceAmountUnits: 'baseUnits',
206
+ sourceChain: 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp',
207
+ sourceToken:
208
+ 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/token:EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v',
209
+ })
210
+
211
+ expect(candidate?.id).toBe('solana-usdc-tempo-usdce-rhino')
212
+ })
213
+
214
+ test('keeps non-EVM token identifiers case-sensitive', () => {
215
+ expect(
216
+ FundingQuotes.listQuoteCandidates({
217
+ provider: 'rhino',
218
+ sourceAmount: '1000000',
219
+ sourceAmountUnits: 'baseUnits',
220
+ sourceChain: 'solana',
221
+ sourceToken: 'epjfwdd5aufqssqem2qn1xzybapc8g4wegkzwytdt1v',
222
+ }),
223
+ ).toEqual([])
224
+ })
225
+
226
+ test('resolves the complete Tron token key', () => {
227
+ const [candidate] = FundingQuotes.listQuoteCandidates({
228
+ destinationToken: 'USDT0',
229
+ provider: 'rhino',
230
+ sourceAmount: '1000000',
231
+ sourceAmountUnits: 'baseUnits',
232
+ sourceChain: 'tron:0x2b6653dc',
233
+ sourceToken: 'tron:0x2b6653dc/trc20:TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t',
234
+ })
235
+
236
+ expect(candidate?.id).toBe('tron-usdt-tempo-usdt0-rhino')
237
+ })
238
+
239
+ test('selects proven Squid stablecoin corridors', () => {
240
+ const candidates = [
241
+ FundingQuotes.listQuoteCandidates({
242
+ destinationToken: 'USDC.e',
243
+ provider: 'squid',
244
+ sourceAmount: '1000000',
245
+ sourceAmountUnits: 'baseUnits',
246
+ sourceChain: 'base',
247
+ sourceToken: 'USDC',
248
+ })[0],
249
+ FundingQuotes.listQuoteCandidates({
250
+ destinationToken: 'USDT0',
251
+ provider: 'squid',
252
+ sourceAmount: '1000000',
253
+ sourceAmountUnits: 'baseUnits',
254
+ sourceChain: 'ethereum',
255
+ sourceToken: 'USDT',
256
+ })[0],
257
+ ]
258
+
259
+ expect(candidates.map((candidate) => candidate?.id)).toEqual([
260
+ 'base-usdc-tempo-usdce-squid',
261
+ 'ethereum-usdt-tempo-usdt0-squid',
156
262
  ])
157
263
  })
158
264
  })
@@ -161,6 +267,13 @@ describe('listProviders', () => {
161
267
  test('reports deployment configuration', () => {
162
268
  expect(FundingQuotes.listProviders()).toMatchInlineSnapshot(`
163
269
  [
270
+ {
271
+ "id": "across",
272
+ "kind": "intentBridge",
273
+ "name": "Across",
274
+ "requiresApiKey": true,
275
+ "status": "unconfigured",
276
+ },
164
277
  {
165
278
  "id": "relay",
166
279
  "kind": "solverNetwork",
@@ -168,6 +281,20 @@ describe('listProviders', () => {
168
281
  "requiresApiKey": false,
169
282
  "status": "unconfigured",
170
283
  },
284
+ {
285
+ "id": "rhino",
286
+ "kind": "bridge",
287
+ "name": "Rhino",
288
+ "requiresApiKey": false,
289
+ "status": "unconfigured",
290
+ },
291
+ {
292
+ "id": "squid",
293
+ "kind": "solverNetwork",
294
+ "name": "Squid",
295
+ "requiresApiKey": true,
296
+ "status": "unconfigured",
297
+ },
171
298
  ]
172
299
  `)
173
300
  })
@@ -18,6 +18,7 @@ const sourceChains = [
18
18
  kind: 'evm',
19
19
  name: 'Ethereum',
20
20
  providerIds: {
21
+ across: '1',
21
22
  relay: '1',
22
23
  },
23
24
  slug: 'ethereum',
@@ -71,7 +72,9 @@ const sourceChains = [
71
72
  kind: 'evm',
72
73
  name: 'Base',
73
74
  providerIds: {
75
+ across: '8453',
74
76
  relay: '8453',
77
+ rhino: 'BASE',
75
78
  },
76
79
  slug: 'base',
77
80
  tokens: [
@@ -108,6 +111,7 @@ const sourceChains = [
108
111
  kind: 'evm',
109
112
  name: 'BNB Smart Chain',
110
113
  providerIds: {
114
+ across: '56',
111
115
  relay: '56',
112
116
  },
113
117
  slug: 'bnb',
@@ -130,6 +134,48 @@ const sourceChains = [
130
134
  },
131
135
  ],
132
136
  },
137
+ {
138
+ addressFormat: 'base58',
139
+ aliases: ['solana', 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp'],
140
+ id: 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp',
141
+ kind: 'solana',
142
+ name: 'Solana',
143
+ providerIds: {
144
+ rhino: 'SOLANA',
145
+ },
146
+ slug: 'solana',
147
+ tokens: [
148
+ {
149
+ address: 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v',
150
+ coingeckoId: 'usd-coin',
151
+ decimals: 6,
152
+ name: 'USD Coin',
153
+ standard: 'SPL',
154
+ symbol: 'USDC',
155
+ },
156
+ ],
157
+ },
158
+ {
159
+ addressFormat: 'base58check',
160
+ aliases: ['tron', 'tron:0x2b6653dc'],
161
+ id: 'tron:0x2b6653dc',
162
+ kind: 'tron',
163
+ name: 'Tron',
164
+ providerIds: {
165
+ rhino: 'TRON',
166
+ },
167
+ slug: 'tron',
168
+ tokens: [
169
+ {
170
+ address: 'TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t',
171
+ coingeckoId: 'tether',
172
+ decimals: 6,
173
+ name: 'Tether USD',
174
+ standard: 'TRC-20',
175
+ symbol: 'USDT',
176
+ },
177
+ ],
178
+ },
133
179
  ] as const
134
180
 
135
181
  /** Tempo destination tokens supported by funding quote providers. */
@@ -178,6 +224,13 @@ const destinationTokens = [
178
224
 
179
225
  /** Providers supported by live funding quotes. */
180
226
  const providers = [
227
+ {
228
+ id: 'across',
229
+ kind: 'intentBridge',
230
+ name: 'Across',
231
+ requiresApiKey: true,
232
+ status: 'unconfigured',
233
+ },
181
234
  {
182
235
  id: 'relay',
183
236
  kind: 'solverNetwork',
@@ -185,6 +238,20 @@ const providers = [
185
238
  requiresApiKey: false,
186
239
  status: 'unconfigured',
187
240
  },
241
+ {
242
+ id: 'rhino',
243
+ kind: 'bridge',
244
+ name: 'Rhino',
245
+ requiresApiKey: false,
246
+ status: 'unconfigured',
247
+ },
248
+ {
249
+ id: 'squid',
250
+ kind: 'solverNetwork',
251
+ name: 'Squid',
252
+ requiresApiKey: true,
253
+ status: 'unconfigured',
254
+ },
188
255
  ] as const
189
256
 
190
257
  type TokenInput = (typeof sourceChains)[number]['tokens'][number]
@@ -262,6 +329,8 @@ export type QuoteProbeInput = {
262
329
 
263
330
  /** Quote probe data returned by a provider adapter. */
264
331
  export type QuoteProbeResult = {
332
+ /** Timestamp after which the provider quote is no longer valid. */
333
+ expiresAt?: string | undefined
265
334
  /** Slippage-adjusted minimum destination amount in base units. */
266
335
  minimumOutputAmount?: string | undefined
267
336
  /** Expected destination amount in base units. */
@@ -294,9 +363,22 @@ export type ProviderAdapter = {
294
363
  /** Provider adapter registry, keyed by provider id. */
295
364
  export type ProviderAdapters = Partial<Record<ProviderId, ProviderAdapter>>
296
365
 
366
+ /** A provider adapter failure retained for bounded request diagnostics. */
367
+ export type ProviderFailure = {
368
+ /** Error thrown by the provider boundary. */
369
+ cause: unknown
370
+ /** Provider that failed. */
371
+ provider: ProviderId
372
+ }
373
+
297
374
  /** Curated provider support rows used to select live quote probes. */
298
375
  const inventory: readonly InventoryEntry[] = [
376
+ { destinationToken: 'usdce', provider: 'across', sourceChains: ['ethereum', 'base', 'bnb'], sourceTokens: ['USDC'] }, // prettier-ignore
299
377
  { destinationToken: 'usdce', provider: 'relay', sourceChains: ['ethereum', 'base', 'bnb'], sourceTokens: ['USDC', 'USDT'] }, // prettier-ignore
378
+ { destinationToken: 'usdce', provider: 'rhino', sourceChains: ['base', 'solana'], sourceTokens: ['USDC'] }, // prettier-ignore
379
+ { destinationToken: 'usdce', provider: 'squid', sourceChains: ['ethereum', 'base', 'bnb'], sourceTokens: ['USDC'] }, // prettier-ignore
380
+ { destinationToken: 'usdt0', provider: 'rhino', sourceChains: ['tron'], sourceTokens: ['USDT'] }, // prettier-ignore
381
+ { destinationToken: 'usdt0', provider: 'squid', sourceChains: ['ethereum', 'base', 'bnb'], sourceTokens: ['USDT'] }, // prettier-ignore
300
382
  ]
301
383
 
302
384
  type InventoryEntry = {
@@ -367,8 +449,12 @@ export async function listQuotes(options: listQuotes.Options) {
367
449
  const quotes = results.flatMap((result) =>
368
450
  result.status === 'fulfilled' && result.value ? [result.value] : [],
369
451
  )
370
- if (quotes.length === 0 && results.some((result) => result.status === 'rejected'))
371
- throw new ProviderQuoteError()
452
+ const failures = results.flatMap((result, index) =>
453
+ result.status === 'rejected'
454
+ ? [{ cause: result.reason, provider: candidates[index]!.provider.id }]
455
+ : [],
456
+ )
457
+ if (quotes.length === 0 && failures.length > 0) throw new ProviderQuoteError(failures)
372
458
  return quotes
373
459
  }
374
460
 
@@ -439,8 +525,13 @@ export declare namespace listQuotes {
439
525
  /** Source amount unit accepted by the funding quote request. */
440
526
  export type SourceAmountUnits = 'baseUnits' | 'decimal'
441
527
 
442
- function tokenKey(chain: { id: string }, token: TokenInput | DestinationTokenInput) {
443
- return `${chain.id}/erc20:${token.address}`
528
+ function tokenKey(chain: { id: string; kind: string }, token: TokenInput | DestinationTokenInput) {
529
+ const namespace = (() => {
530
+ if (chain.kind === 'solana') return 'token'
531
+ if (chain.kind === 'tron') return 'trc20'
532
+ return 'erc20'
533
+ })()
534
+ return `${chain.id}/${namespace}:${token.address}`
444
535
  }
445
536
 
446
537
  function decimalFromBaseUnits(baseUnits: string, decimals: number) {
@@ -485,7 +576,7 @@ async function quoteCandidate(
485
576
  quote.outputAmount === undefined ||
486
577
  quote.outputAmount === null
487
578
  )
488
- throw new ProviderQuoteError()
579
+ throw new InvalidProviderQuoteError()
489
580
  return formatFundingQuote(candidate, {
490
581
  ...quote,
491
582
  outputAmount: quote.outputAmount,
@@ -595,6 +686,7 @@ function formatFundingQuote(candidate: QuoteCandidate, quote: SuccessfulQuotePro
595
686
  tier: quote.status === 'thin' ? ('thin' as const) : ('liquid' as const),
596
687
  },
597
688
  quote: {
689
+ ...(quote.expiresAt !== undefined ? { expiresAt: quote.expiresAt } : {}),
598
690
  inputAmount: candidate.sourceAmount.amount,
599
691
  ...(quote.minimumOutputAmount !== undefined
600
692
  ? { minimumOutputAmount: quote.minimumOutputAmount }
@@ -685,10 +777,13 @@ function matchesTokenIdentifier(
685
777
  }
686
778
 
687
779
  function resolveSourceChain(input: string) {
780
+ const trimmed = input.trim()
688
781
  const normalized = normalizeInput(input)
689
782
  const chain = sourceChains.find(
690
783
  (candidate) =>
691
- candidate.id === normalized || (candidate.aliases as readonly string[]).includes(normalized),
784
+ candidate.id === trimmed ||
785
+ candidate.slug === normalized ||
786
+ (candidate.kind === 'evm' && (candidate.aliases as readonly string[]).includes(normalized)),
692
787
  )
693
788
  if (!chain) throw new UnsupportedSourceChainError()
694
789
  return chain
@@ -722,6 +817,14 @@ export class ProviderAdapterTimeoutError extends Error {
722
817
  /** Error thrown when every matching provider fails to produce a quote. */
723
818
  export class ProviderQuoteError extends Error {
724
819
  override name = 'FundingQuotes.ProviderQuoteError'
820
+
821
+ /** Provider failures that prevented a successful quote. */
822
+ failures: readonly ProviderFailure[]
823
+
824
+ constructor(failures: readonly ProviderFailure[]) {
825
+ super()
826
+ this.failures = failures
827
+ }
725
828
  }
726
829
 
727
830
  /** Error thrown when the caller filters to an unknown provider. */
@@ -733,3 +836,7 @@ export class UnsupportedProviderError extends Error {
733
836
  export class UnsupportedSourceChainError extends Error {
734
837
  override name = 'FundingQuotes.UnsupportedSourceChainError'
735
838
  }
839
+
840
+ class InvalidProviderQuoteError extends Error {
841
+ override name = 'FundingQuotes.InvalidProviderQuoteError'
842
+ }
@@ -12,6 +12,8 @@ export type Variables = {
12
12
  errorCode?: string | undefined
13
13
  /** Redacted provider failure details recorded by an upstream adapter. */
14
14
  providerFailure?: Entry['provider'] | undefined
15
+ /** Redacted provider failures when multiple upstream adapters fail. */
16
+ providerFailures?: Entry['providerFailures'] | undefined
15
17
  }
16
18
 
17
19
  /** Hono environment shape used by the request logger. */
@@ -28,6 +30,20 @@ export type Environment = {
28
30
  /** Log level, derived from the response status. */
29
31
  export type Level = 'error' | 'info' | 'warn'
30
32
 
33
+ /** Redacted upstream provider failure details. */
34
+ export type ProviderFailure = {
35
+ /** Stable provider error code, when one is safe to retain. */
36
+ code?: string | undefined
37
+ /** Failure boundary that rejected the provider request. */
38
+ failure: 'http' | 'network' | 'payload' | 'timeout' | 'unknown'
39
+ /** Provider identifier. */
40
+ id: string
41
+ /** Provider operation identifier. */
42
+ operation: string
43
+ /** Upstream HTTP status, when a response was received. */
44
+ status?: number | undefined
45
+ }
46
+
31
47
  /** One canonical request log entry. */
32
48
  export type Entry = {
33
49
  /** `'hit'` when the edge response cache served the request without reaching the origin handlers. */
@@ -64,20 +80,9 @@ export type Entry = {
64
80
  }
65
81
  | undefined
66
82
  /** Redacted upstream provider failure details, when an adapter request fails. */
67
- provider?:
68
- | {
69
- /** Stable provider error code, when one is safe to retain. */
70
- code?: string | undefined
71
- /** Failure boundary that rejected the provider request. */
72
- failure: 'http' | 'network' | 'payload' | 'unknown'
73
- /** Provider identifier. */
74
- id: string
75
- /** Provider operation identifier. */
76
- operation: string
77
- /** Upstream HTTP status, when a response was received. */
78
- status?: number | undefined
79
- }
80
- | undefined
83
+ provider?: ProviderFailure | undefined
84
+ /** Redacted failures for every provider when multiple adapters fail. */
85
+ providerFailures?: readonly ProviderFailure[] | undefined
81
86
  /** Raw query string (no leading `?`) with API-key tokens redacted, when present. */
82
87
  query?: string | undefined
83
88
  /** Rate limit state from the `RateLimit-*` response headers, when enforced. */
@@ -136,6 +141,7 @@ export function middleware<environment extends Environment = Environment>(
136
141
  const principal = c.get('principal' as never) as Auth.Principal | undefined
137
142
  const errorCode = c.get('errorCode' as never) as string | undefined
138
143
  const provider = c.get('providerFailure' as never) as Entry['provider']
144
+ const providerFailures = c.get('providerFailures' as never) as Entry['providerFailures']
139
145
 
140
146
  // The `request` total metric duplicates `duration` (measured here, one
141
147
  // layer further out), so only operation timings are kept.
@@ -186,6 +192,7 @@ export function middleware<environment extends Environment = Environment>(
186
192
  },
187
193
  }),
188
194
  ...(provider === undefined ? {} : { provider }),
195
+ ...(providerFailures === undefined ? {} : { providerFailures }),
189
196
  ...(query ? { query } : {}),
190
197
  ...(rateLimit === undefined ? {} : { rateLimit }),
191
198
  requestId: c.get('requestId' as never) as string,