tapimo 0.4.4 → 0.5.0

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 (162) hide show
  1. package/dist/App.d.ts +7403 -6127
  2. package/dist/App.d.ts.map +1 -1
  3. package/dist/App.js +4 -0
  4. package/dist/App.js.map +1 -1
  5. package/dist/Scope.d.ts +18 -1
  6. package/dist/Scope.d.ts.map +1 -1
  7. package/dist/Scope.js +24 -3
  8. package/dist/Scope.js.map +1 -1
  9. package/dist/admin/apps/verified-tokens.d.ts +115 -115
  10. package/dist/apps/data/App.d.ts +1838 -1534
  11. package/dist/apps/data/App.d.ts.map +1 -1
  12. package/dist/apps/data/App.js +135 -44
  13. package/dist/apps/data/App.js.map +1 -1
  14. package/dist/apps/data/{FundingRouteProviders.d.ts → FundingQuoteProviders.d.ts} +7 -7
  15. package/dist/apps/data/{FundingRouteProviders.d.ts.map → FundingQuoteProviders.d.ts.map} +1 -1
  16. package/dist/apps/data/{FundingRouteProviders.js → FundingQuoteProviders.js} +36 -91
  17. package/dist/apps/data/FundingQuoteProviders.js.map +1 -0
  18. package/dist/apps/data/index.d.ts +1 -1
  19. package/dist/apps/data/index.js +1 -1
  20. package/dist/apps/data/routes/activities.d.ts +242 -242
  21. package/dist/apps/data/routes/addresses.d.ts +227 -227
  22. package/dist/apps/data/routes/balances.d.ts +12 -12
  23. package/dist/apps/data/routes/balances.d.ts.map +1 -1
  24. package/dist/apps/data/routes/balances.js +67 -149
  25. package/dist/apps/data/routes/balances.js.map +1 -1
  26. package/dist/apps/data/routes/blocks.d.ts +125 -126
  27. package/dist/apps/data/routes/blocks.d.ts.map +1 -1
  28. package/dist/apps/data/routes/blocks.js +3 -5
  29. package/dist/apps/data/routes/blocks.js.map +1 -1
  30. package/dist/apps/data/routes/coingecko.d.ts +55 -54
  31. package/dist/apps/data/routes/coingecko.d.ts.map +1 -1
  32. package/dist/apps/data/routes/coingecko.js +3 -8
  33. package/dist/apps/data/routes/coingecko.js.map +1 -1
  34. package/dist/apps/data/routes/funding.d.ts +207 -388
  35. package/dist/apps/data/routes/funding.d.ts.map +1 -1
  36. package/dist/apps/data/routes/funding.js +143 -221
  37. package/dist/apps/data/routes/funding.js.map +1 -1
  38. package/dist/apps/data/routes/rpc.d.ts.map +1 -1
  39. package/dist/apps/data/routes/rpc.js +86 -16
  40. package/dist/apps/data/routes/rpc.js.map +1 -1
  41. package/dist/apps/data/routes/tokenlist.d.ts +33 -33
  42. package/dist/apps/data/routes/transactions.d.ts +281 -281
  43. package/dist/apps/data/routes/transfers.d.ts +58 -58
  44. package/dist/apps/data/routes/verified-tokens.d.ts +86 -86
  45. package/dist/apps/data/routes/webhooks.d.ts +340 -0
  46. package/dist/apps/data/routes/webhooks.d.ts.map +1 -1
  47. package/dist/apps/data/routes/webhooks.js +13 -1
  48. package/dist/apps/data/routes/webhooks.js.map +1 -1
  49. package/dist/apps/index.d.ts +1 -1
  50. package/dist/apps/index.js +1 -1
  51. package/dist/apps/management/App.d.ts +1090 -108
  52. package/dist/apps/management/App.d.ts.map +1 -1
  53. package/dist/apps/management/App.js +4 -0
  54. package/dist/apps/management/App.js.map +1 -1
  55. package/dist/apps/management/routes/api-keys.d.ts +118 -118
  56. package/dist/apps/management/routes/api-keys.js +2 -2
  57. package/dist/apps/management/routes/api-keys.js.map +1 -1
  58. package/dist/apps/management/routes/billing.d.ts +207 -207
  59. package/dist/apps/management/routes/invite-links.d.ts +1100 -0
  60. package/dist/apps/management/routes/invite-links.d.ts.map +1 -0
  61. package/dist/apps/management/routes/invite-links.js +411 -0
  62. package/dist/apps/management/routes/invite-links.js.map +1 -0
  63. package/dist/apps/management/routes/me.d.ts +17 -17
  64. package/dist/apps/management/routes/members.d.ts +71 -71
  65. package/dist/apps/management/routes/orgs.d.ts +12 -12
  66. package/dist/apps/management/routes/projects.d.ts +70 -70
  67. package/dist/apps/management/routes/scopes.d.ts +11 -11
  68. package/dist/apps/management/routes/scopes.d.ts.map +1 -1
  69. package/dist/apps/management/routes/scopes.js +2 -2
  70. package/dist/apps/management/routes/scopes.js.map +1 -1
  71. package/dist/apps/management/routes/usage.d.ts +75 -75
  72. package/dist/apps/relay/App.d.ts.map +1 -1
  73. package/dist/apps/relay/App.js +42 -4
  74. package/dist/apps/relay/App.js.map +1 -1
  75. package/dist/db/Db.d.ts +3 -0
  76. package/dist/db/Db.d.ts.map +1 -1
  77. package/dist/db/Db.js +57 -0
  78. package/dist/db/Db.js.map +1 -1
  79. package/dist/db/Schema.d.ts +31 -0
  80. package/dist/db/Schema.d.ts.map +1 -1
  81. package/dist/db/Schema.js +31 -0
  82. package/dist/db/Schema.js.map +1 -1
  83. package/dist/db/tables/inviteLinks.d.ts +42 -0
  84. package/dist/db/tables/inviteLinks.d.ts.map +1 -0
  85. package/dist/db/tables/inviteLinks.js +148 -0
  86. package/dist/db/tables/inviteLinks.js.map +1 -0
  87. package/dist/db/tables/organizations.d.ts.map +1 -1
  88. package/dist/db/tables/organizations.js +2 -0
  89. package/dist/db/tables/organizations.js.map +1 -1
  90. package/dist/handlers/relay.d.ts.map +1 -1
  91. package/dist/handlers/relay.js +13 -3
  92. package/dist/handlers/relay.js.map +1 -1
  93. package/dist/internal/Auth.d.ts +9 -1
  94. package/dist/internal/Auth.d.ts.map +1 -1
  95. package/dist/internal/Auth.js +130 -10
  96. package/dist/internal/Auth.js.map +1 -1
  97. package/dist/internal/EdgeCache.d.ts +3 -1
  98. package/dist/internal/EdgeCache.d.ts.map +1 -1
  99. package/dist/internal/EdgeCache.js +17 -2
  100. package/dist/internal/EdgeCache.js.map +1 -1
  101. package/dist/internal/{FundingRoutes.d.ts → FundingQuotes.d.ts} +185 -428
  102. package/dist/internal/FundingQuotes.d.ts.map +1 -0
  103. package/dist/internal/{FundingRoutes.js → FundingQuotes.js} +93 -218
  104. package/dist/internal/FundingQuotes.js.map +1 -0
  105. package/dist/internal/Viem.d.ts +3 -1
  106. package/dist/internal/Viem.d.ts.map +1 -1
  107. package/dist/internal/Viem.js +11 -2
  108. package/dist/internal/Viem.js.map +1 -1
  109. package/dist/internal/index.d.ts +1 -1
  110. package/dist/internal/index.js +1 -1
  111. package/package.json +1 -1
  112. package/src/ApiKeys.test.ts +23 -0
  113. package/src/App.test-d.ts +2 -0
  114. package/src/App.ts +6 -0
  115. package/src/Client.test-d.ts +20 -11
  116. package/src/Scope.test.ts +1 -0
  117. package/src/Scope.ts +30 -4
  118. package/src/apps/data/App.test.ts +354 -0
  119. package/src/apps/data/App.ts +159 -52
  120. package/src/apps/data/FundingQuoteProviders.test.ts +151 -0
  121. package/src/apps/data/{FundingRouteProviders.ts → FundingQuoteProviders.ts} +40 -101
  122. package/src/apps/data/index.ts +1 -1
  123. package/src/apps/data/routes/balances.test.ts +64 -18
  124. package/src/apps/data/routes/balances.ts +76 -177
  125. package/src/apps/data/routes/blocks.ts +3 -5
  126. package/src/apps/data/routes/coingecko.ts +3 -8
  127. package/src/apps/data/routes/funding.test.ts +151 -206
  128. package/src/apps/data/routes/funding.ts +156 -291
  129. package/src/apps/data/routes/indexer.test.ts +97 -1
  130. package/src/apps/data/routes/rpc.test.ts +219 -0
  131. package/src/apps/data/routes/rpc.ts +92 -17
  132. package/src/apps/data/routes/webhooks.test.ts +21 -0
  133. package/src/apps/data/routes/webhooks.ts +13 -1
  134. package/src/apps/index.ts +1 -1
  135. package/src/apps/management/App.ts +4 -0
  136. package/src/apps/management/routes/api-keys.test.ts +47 -3
  137. package/src/apps/management/routes/api-keys.ts +2 -2
  138. package/src/apps/management/routes/invite-links.test.ts +385 -0
  139. package/src/apps/management/routes/invite-links.ts +570 -0
  140. package/src/apps/management/routes/scopes.test.ts +3 -2
  141. package/src/apps/management/routes/scopes.ts +4 -2
  142. package/src/apps/relay/App.test.ts +324 -4
  143. package/src/apps/relay/App.ts +40 -4
  144. package/src/db/Db.ts +65 -0
  145. package/src/db/Schema.ts +39 -0
  146. package/src/db/tables/inviteLinks.ts +187 -0
  147. package/src/db/tables/organizations.ts +2 -0
  148. package/src/handlers/relay.ts +16 -4
  149. package/src/internal/Auth.test.ts +132 -22
  150. package/src/internal/Auth.ts +171 -14
  151. package/src/internal/EdgeCache.test.ts +24 -3
  152. package/src/internal/EdgeCache.ts +18 -2
  153. package/src/internal/FundingQuotes.test.ts +174 -0
  154. package/src/internal/{FundingRoutes.ts → FundingQuotes.ts} +153 -312
  155. package/src/internal/Viem.ts +14 -5
  156. package/src/internal/index.test.ts +1 -1
  157. package/src/internal/index.ts +1 -1
  158. package/dist/apps/data/FundingRouteProviders.js.map +0 -1
  159. package/dist/internal/FundingRoutes.d.ts.map +0 -1
  160. package/dist/internal/FundingRoutes.js.map +0 -1
  161. package/src/apps/data/FundingRouteProviders.test.ts +0 -225
  162. package/src/internal/FundingRoutes.test.ts +0 -213
@@ -1,15 +1,19 @@
1
- import { Hono } from 'hono'
1
+ import { type Context, Hono } from 'hono'
2
+ import { matchedRoutes } from 'hono/route'
3
+ import { ZoneRpcAuthentication } from 'ox/tempo'
2
4
  import * as z from 'zod/mini'
3
5
 
4
6
  import type * as Assets from '../../Assets.js'
5
7
  import type * as App from '../../App.js'
6
- import * as Metadata from '../metadata.js'
7
8
  import * as Auth from '../../internal/Auth.js'
9
+ import * as EdgeCache from '../../internal/EdgeCache.js'
8
10
  import * as Path from '../../internal/Path.js'
9
11
  import * as Response from '../../internal/Response.js'
10
12
  import * as Schema from '../../internal/Schema.js'
11
13
  import * as Tidx from '../../internal/Tidx.js'
12
14
  import * as Viem from '../../internal/Viem.js'
15
+ import * as Scope from '../../Scope.js'
16
+ import * as Metadata from '../metadata.js'
13
17
 
14
18
  import { transactions as transactionActivities } from './routes/activities.js'
15
19
  import { addresses } from './routes/addresses.js'
@@ -40,6 +44,10 @@ import { schema as webhookSchema, webhooks } from './routes/webhooks.js'
40
44
  export function data(options: data.Options = {}) {
41
45
  const { assets, defaultChainId = Viem.defaultChainId, rpc: rpc_option, tidx } = options
42
46
 
47
+ // Zone endpoints are explicit trust boundaries; the API never derives them.
48
+ const zones = new Map((options.zones ?? []).map((zone) => [zone.chainId, zone]))
49
+ const dataRouteHandlers = new Set<unknown>()
50
+
43
51
  // The chain ids this deployment serves (built-in public chains plus
44
52
  // `defaultChainId` and any explicit `supportedChainIds`). Published on context
45
53
  // so handlers with a path `chainId` can guard it too.
@@ -48,6 +56,7 @@ export function data(options: data.Options = {}) {
48
56
  ...Object.keys(Tidx.url).map(Number),
49
57
  defaultChainId,
50
58
  ...(options.supportedChainIds ?? []),
59
+ ...zones.keys(),
51
60
  ])
52
61
 
53
62
  // Memoize one viem client per resolved upstream (stateless HTTP transports).
@@ -64,8 +73,7 @@ export function data(options: data.Options = {}) {
64
73
  return client
65
74
  }
66
75
 
67
- // Memoize TIDX clients per `chainId` so the base URL resolver runs once per
68
- // chain instead of on every request.
76
+ // Memoize TIDX clients per resolved upstream.
69
77
  const getTidx = Tidx.createGetClient({ defaultChainId, tidx })
70
78
 
71
79
  const app = new Hono<App.Environment>()
@@ -82,49 +90,75 @@ export function data(options: data.Options = {}) {
82
90
  c.set('tidx', tidx)
83
91
  c.set('verifiedTokens', options.verifiedTokens === false ? undefined : (options.verifiedTokens ?? {})) // prettier-ignore
84
92
  c.set('webhook', options.webhook === false ? undefined : (options.webhook ?? {}))
93
+ c.set('zones', zones)
85
94
  await next()
86
95
  })
87
- // Reject a well-formed but unsupported `chainId` before it reaches an
88
- // upstream, which would otherwise return an opaque `upstream_error`.
96
+ // Resolve one chain for every data route before auth and handlers use it.
89
97
  .use('*', async (c, next) => {
90
- const raw = resolveChainSelector({
91
- basePath: c.get('basePath') ?? '',
92
- path: c.req.path,
93
- query: c.req.query('chainId') ?? c.req.query('chain_id'),
98
+ const selection = resolveChainSelector({
99
+ c,
100
+ defaultChainId,
101
+ handlers: dataRouteHandlers,
94
102
  })
95
- if (raw === undefined) return next()
96
- // Only reject a well-formed-but-unsupported chain here; malformed values
97
- // (e.g. `abc`, `0`) fall through to the route's own query validation.
98
- const parsed = Schema.ChainId.safeParse(raw)
99
- if (!parsed.success || supportedChainIds.has(parsed.data)) return next()
100
- return Response.unsupportedChainId(c, parsed.data, supportedChainIds)
103
+ if (!selection.matched) return next()
104
+ if (selection.conflict || selection.invalid)
105
+ return Response.error(c, {
106
+ code: 'chain_id_invalid',
107
+ message: selection.conflict ? 'Conflicting chain ids' : 'Invalid chain id',
108
+ status: 400,
109
+ })
110
+ if (!supportedChainIds.has(selection.chainId))
111
+ return Response.unsupportedChainId(c, selection.chainId, supportedChainIds)
112
+ c.set('chainId', selection.chainId)
113
+ return next()
114
+ })
115
+ // Zone data requires an issued scope. Raw RPC may instead forward the
116
+ // caller's Zone token to the node that enforces account scoping.
117
+ .use('*', async (c, next) => {
118
+ if (zones.size === 0) return next()
119
+ const selection = resolveChainSelector({
120
+ c,
121
+ defaultChainId,
122
+ handlers: dataRouteHandlers,
123
+ })
124
+ if (!selection.matched) return next()
125
+ const chainId = c.get('chainId')
126
+ if (!zones.has(chainId)) return next()
127
+
128
+ const principal = Auth.getPrincipal(c)
129
+ const scopes = principal?.type === 'api_key' ? principal.apiKey.scopes : undefined
130
+ const allowed =
131
+ scopes?.includes(Scope.wildcard) ||
132
+ scopes?.includes(`zone:${chainId}:read`) ||
133
+ (selection.rpc && Boolean(c.req.header(ZoneRpcAuthentication.headerName)?.trim()))
134
+ if (!allowed)
135
+ return Response.error(c, {
136
+ code: 'api_key_forbidden',
137
+ message: selection.rpc
138
+ ? `Chain id ${chainId} is a zone. Use an API key granting \`zone:${chainId}:read\`, or pass a Zone token via \`X-Authorization-Token\`.`
139
+ : `Chain id ${chainId} is a zone. Use an API key granting \`zone:${chainId}:read\`.`,
140
+ status: 403,
141
+ })
142
+
143
+ await next()
144
+ // Defense in depth for cache misses; pre-auth lookups are disabled by
145
+ // the route eligibility predicate below.
146
+ c.set('edgeCache', undefined)
101
147
  })
102
148
  // Sandbox keys are testnet-only. A chain-scoped data route defaults to
103
149
  // mainnet, so a sandbox key that omits `chainId` (or targets mainnet) is
104
150
  // refused with a clear message telling the caller to pass a testnet
105
- // `chainId`. The relay resolves its chain from the request body, not the
106
- // query, so it is skipped and enforces the rule in its sponsor validation.
151
+ // `chainId`.
107
152
  .use('*', async (c, next) => {
108
- const rpc = Path.join(c.get('basePath') ?? '', 'rpc')
109
- const relay = Path.join(rpc, 'relay')
110
- if (c.req.path === relay || c.req.path.startsWith(`${relay}/`)) return next()
111
-
112
153
  const principal = Auth.getPrincipal(c)
113
154
  if (principal?.type !== 'api_key' || principal.environment !== 'sandbox') return next()
114
155
 
115
- const raw = resolveChainSelector({
116
- basePath: c.get('basePath') ?? '',
117
- path: c.req.path,
118
- query: c.req.query('chainId') ?? c.req.query('chain_id'),
156
+ const selection = resolveChainSelector({
157
+ c,
158
+ defaultChainId,
159
+ handlers: dataRouteHandlers,
119
160
  })
120
- // Absent falls back to the group default (mainnet); a malformed value
121
- // stays with the route's own validator rather than the sandbox message.
122
- const chainId = (() => {
123
- if (raw === undefined) return defaultChainId
124
- const parsed = Schema.ChainId.safeParse(raw)
125
- return parsed.success ? parsed.data : undefined
126
- })()
127
- if (chainId === undefined || !Viem.isMainnet(chainId)) return next()
161
+ if (!selection.matched || !Viem.isMainnet(c.get('chainId'))) return next()
128
162
 
129
163
  return Response.error(c, {
130
164
  code: 'api_key_forbidden',
@@ -149,7 +183,7 @@ export function data(options: data.Options = {}) {
149
183
  .route('/', blocks())
150
184
  .route('/', exchanges())
151
185
  .route('/', feeAmm())
152
- .route('/', funding(options.fundingRoutes))
186
+ .route('/', funding(options.funding))
153
187
  .route('/', indexer())
154
188
  .route('/', receipts())
155
189
  .route('/', tokenlist())
@@ -162,21 +196,23 @@ export function data(options: data.Options = {}) {
162
196
  .route('/', rpc())
163
197
  .route('/', coingecko())
164
198
  .route('/', blocksByTimestampCompat())
199
+ const edgeCacheEligible = (c: Context<App.Environment>) => {
200
+ const selection = resolveChainSelector({ c, defaultChainId, handlers: dataRouteHandlers })
201
+ return (
202
+ selection.matched &&
203
+ !selection.conflict &&
204
+ !selection.invalid &&
205
+ !zones.has(selection.chainId)
206
+ )
207
+ }
208
+ for (const route of app.routes) {
209
+ if (route.method === 'ALL') continue
210
+ dataRouteHandlers.add(route.handler)
211
+ if (zones.size > 0) EdgeCache.setEligibility(route.handler, edgeCacheEligible)
212
+ }
165
213
  return Metadata.attach(app, openapi)
166
214
  }
167
215
 
168
- function resolveChainSelector(options: {
169
- basePath: string
170
- path: string
171
- query: string | undefined
172
- }) {
173
- const rpc = Path.join(options.basePath, 'rpc')
174
- if (options.path === rpc) return undefined
175
- if (options.path.startsWith(`${rpc}/`))
176
- return options.path.slice(rpc.length + 1).split('/')[0] || undefined
177
- return options.query
178
- }
179
-
180
216
  export declare namespace data {
181
217
  /** Options for the data route group. */
182
218
  type Options = {
@@ -184,8 +220,8 @@ export declare namespace data {
184
220
  assets?: Assets.Assets | undefined
185
221
  /** Default Tempo chain id for requests that omit `chainId`. */
186
222
  defaultChainId?: Viem.ChainId | undefined
187
- /** Funding route provider adapter configuration. Omit to serve static inventory only. */
188
- fundingRoutes?: funding.Options | undefined
223
+ /** Funding quote provider configuration. */
224
+ funding?: funding.Options | undefined
189
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. */
190
226
  rpc?: Viem.getClient.Rpc | undefined
191
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. */
@@ -196,6 +232,19 @@ export declare namespace data {
196
232
  verifiedTokens?: VerifiedTokens | false | undefined
197
233
  /** Webhook feature configuration. Enabled by default (rows live in `db`); pass `false` to disable, leaving `/webhooks` mounted but returning `404` and hidden from the OpenAPI document. */
198
234
  webhook?: Webhook | false | undefined
235
+ /**
236
+ * Zone chains served by this deployment. REST and TIDX require a
237
+ * `zone:<chainId>:read` scope; raw RPC may forward a Zone token instead.
238
+ */
239
+ zones?: readonly Zone[] | undefined
240
+ }
241
+
242
+ /** One zone chain served by this deployment. */
243
+ type Zone = {
244
+ /** Zone chain id. Consumer-supplied literal; the API never derives zone chain ids. */
245
+ chainId: number
246
+ /** Zone RPC used when forwarding a caller-supplied Zone token. */
247
+ forwardRpcUrl: string
199
248
  }
200
249
 
201
250
  /** Verified-token feature configuration. */
@@ -219,6 +268,66 @@ export declare namespace data {
219
268
  }
220
269
  }
221
270
 
271
+ function resolveChainSelector(options: {
272
+ c: Context<App.Environment>
273
+ defaultChainId: Viem.ChainId
274
+ handlers: ReadonlySet<unknown>
275
+ }) {
276
+ let matched = false
277
+ let path: string | undefined
278
+ let rpc = false
279
+ for (const route of matchedRoutes(options.c)) {
280
+ if (!options.handlers.has(route.handler)) continue
281
+ matched = true
282
+ const selector = pathChainSelector(route.path, options.c.req.path)
283
+ if (selector.name === undefined) continue
284
+ path = selector.value
285
+ rpc = selector.name === 'chain'
286
+ break
287
+ }
288
+
289
+ if (!matched)
290
+ return {
291
+ chainId: options.defaultChainId,
292
+ conflict: false,
293
+ invalid: false,
294
+ matched: false,
295
+ rpc: false,
296
+ }
297
+
298
+ const search = new URL(options.c.req.url).searchParams
299
+ const raw = [
300
+ ...(path === undefined ? [] : [path]),
301
+ ...(rpc ? [] : search.getAll('chainId')),
302
+ ...(rpc ? [] : search.getAll('chain_id')),
303
+ ]
304
+ const parsed = raw.map((value) => Schema.ChainId.safeParse(value))
305
+ const chainIds = [...new Set(parsed.flatMap((result) => (result.success ? [result.data] : [])))]
306
+ return {
307
+ chainId: chainIds[0] ?? options.defaultChainId,
308
+ conflict: chainIds.length > 1,
309
+ invalid: parsed.some((result) => !result.success),
310
+ matched: true,
311
+ rpc,
312
+ }
313
+ }
314
+
315
+ function pathChainSelector(routePath: string, requestPath: string) {
316
+ const route = routePath.split('/')
317
+ const request = requestPath.split('/')
318
+ for (let index = 0; index < route.length; index++) {
319
+ const segment = route[index] ?? ''
320
+ for (const name of ['chainId', 'chain'] as const) {
321
+ const prefix = `:${name}`
322
+ if (!segment.startsWith(prefix)) continue
323
+ const suffix = segment.slice(prefix.length)
324
+ if (suffix !== '' && suffix !== '?' && !suffix.startsWith('{')) continue
325
+ return { name, value: request[index] || undefined }
326
+ }
327
+ }
328
+ return { name: undefined, value: undefined }
329
+ }
330
+
222
331
  /**
223
332
  * The OpenAPI metadata this group owns: its operation tags and the outbound
224
333
  * `webhooks` block. Tagged onto the {@link data} instance so `App.create`
@@ -231,8 +340,7 @@ const openapi = {
231
340
  { name: 'Blocks', description: 'The ordered batches of transactions making up the chain.' },
232
341
  { name: 'Exchange', description: "Tempo's built-in stablecoin exchange: pairs, swaps, orders, prices." }, // prettier-ignore
233
342
  { name: 'Fee AMM', description: 'Pools that convert stablecoins to pay fees.' },
234
- { name: 'Funding', description: 'Routes and inventory for moving stablecoins into Tempo.' },
235
- { name: 'Providers', description: 'Provider metadata, capabilities, and integration status.' },
343
+ { name: 'Funding', description: 'Live quotes and supported inputs for funding Tempo.' },
236
344
  { name: 'Tokens', description: 'TIP-20 token details, supply, and holders.' },
237
345
  { name: 'Transactions', description: 'Transactions submitted to Tempo, and their receipts.' },
238
346
  { name: 'Transfers', description: 'Token movements from executing transactions.' },
@@ -252,7 +360,6 @@ const openapi = {
252
360
  'Exchange',
253
361
  'Fee AMM',
254
362
  'Funding',
255
- 'Providers',
256
363
  'Tokens',
257
364
  'Transactions',
258
365
  'Transfers',
@@ -0,0 +1,151 @@
1
+ import * as FundingQuotes from '../../internal/FundingQuotes.js'
2
+ import * as FundingQuoteProviders from './FundingQuoteProviders.js'
3
+
4
+ const now = () => new Date('2026-01-01T00:00:00.000Z')
5
+
6
+ describe('FundingQuoteProviders', () => {
7
+ test('maps successful Relay quotes to available quote probes', async () => {
8
+ const requests: RecordedRequest[] = []
9
+ const candidate = quoteCandidate()
10
+ const fetch = recordingFetch(requests, async () =>
11
+ jsonResponse({
12
+ details: {
13
+ currencyOut: {
14
+ amount: '990000',
15
+ minimumAmount: '980000',
16
+ },
17
+ timeEstimate: 15,
18
+ totalImpact: { percent: '-1.20' },
19
+ },
20
+ }),
21
+ )
22
+ const adapter = FundingQuoteProviders.relay({
23
+ apiKey: 'relay-api-key',
24
+ fetch,
25
+ now,
26
+ userAddress: '0x1111111111111111111111111111111111111111',
27
+ })
28
+
29
+ const result = await adapter.probeQuote(probeInput(candidate), new AbortController().signal)
30
+
31
+ expect(requests.map((request) => request.url.pathname)).toEqual(['/quote/v2'])
32
+ expect(new Headers(requests[0]?.init?.headers).get('x-api-key')).toBe('relay-api-key')
33
+ expect(jsonRequestBody(requests[0])).toMatchObject({
34
+ amount: '1000000',
35
+ destinationChainId: 4217,
36
+ destinationCurrency: candidate.destinationToken.address,
37
+ originChainId: 8453,
38
+ originCurrency: candidate.sourceToken.address,
39
+ tradeType: 'EXACT_INPUT',
40
+ user: '0x1111111111111111111111111111111111111111',
41
+ })
42
+ expect(result).toMatchObject({
43
+ minimumOutputAmount: '980000',
44
+ outputAmount: '990000',
45
+ outputAmountFormatted: '0.99',
46
+ quality: {
47
+ estimatedSeconds: 15,
48
+ liquiditySource: 'providerQuote',
49
+ sourceDetail: 'relay:quote-v2',
50
+ tier: 'liquid',
51
+ },
52
+ sampledAt: '2026-01-01T00:00:00.000Z',
53
+ status: 'available',
54
+ })
55
+ })
56
+
57
+ test('calls the runtime fetch method with its global receiver', async () => {
58
+ const candidate = quoteCandidate()
59
+ const fetch = vi.fn(function (this: unknown) {
60
+ expect(this).toBe(globalThis)
61
+ return Promise.resolve(
62
+ jsonResponse({
63
+ details: {
64
+ currencyOut: { amount: '990000', minimumAmount: '980000' },
65
+ timeEstimate: 15,
66
+ },
67
+ }),
68
+ )
69
+ })
70
+ vi.stubGlobal('fetch', fetch)
71
+
72
+ try {
73
+ const adapter = FundingQuoteProviders.relay({
74
+ now,
75
+ userAddress: '0x1111111111111111111111111111111111111111',
76
+ })
77
+ const result = await adapter.probeQuote(probeInput(candidate), new AbortController().signal)
78
+
79
+ expect(result.outputAmount).toBe('990000')
80
+ expect(fetch).toHaveBeenCalledTimes(1)
81
+ } finally {
82
+ vi.unstubAllGlobals()
83
+ }
84
+ })
85
+ })
86
+
87
+ type RecordedRequest = {
88
+ init: RequestInit | undefined
89
+ url: URL
90
+ }
91
+
92
+ function quoteCandidate(): FundingQuotes.QuoteProbeInput['candidate'] {
93
+ const [candidate] = FundingQuotes.listQuoteCandidates({
94
+ provider: 'relay',
95
+ sourceAmount: '1000000',
96
+ sourceAmountUnits: 'baseUnits',
97
+ sourceToken: 'USDC',
98
+ sourceChain: 'base',
99
+ })
100
+ if (!candidate) throw new Error('Missing Relay quote candidate')
101
+ return candidate
102
+ }
103
+
104
+ function probeInput(
105
+ candidate: FundingQuotes.QuoteProbeInput['candidate'],
106
+ ): FundingQuotes.QuoteProbeInput {
107
+ return {
108
+ candidate,
109
+ cache: { bypass: false },
110
+ destinationChain: candidate.destinationChain,
111
+ destinationToken: candidate.destinationToken,
112
+ request: {
113
+ provider: candidate.provider.id,
114
+ sourceAmount: candidate.sourceAmount.amount,
115
+ sourceAmountUnits: 'baseUnits',
116
+ sourceChain: candidate.sourceChain.id,
117
+ sourceToken: candidate.sourceToken.symbol,
118
+ },
119
+ sourceAmount: candidate.sourceAmount,
120
+ sourceChain: candidate.sourceChain,
121
+ sourceToken: candidate.sourceToken,
122
+ }
123
+ }
124
+
125
+ function recordingFetch(
126
+ requests: RecordedRequest[],
127
+ respond: (request: RecordedRequest) => Response | Promise<Response>,
128
+ ): typeof globalThis.fetch {
129
+ return async (input, init) => {
130
+ const request = { init, url: toUrl(input) }
131
+ requests.push(request)
132
+ return respond(request)
133
+ }
134
+ }
135
+
136
+ function toUrl(input: Parameters<typeof globalThis.fetch>[0]) {
137
+ if (input instanceof Request) return new URL(input.url)
138
+ return new URL(String(input))
139
+ }
140
+
141
+ function jsonResponse(body: unknown, status = 200) {
142
+ return new Response(JSON.stringify(body), {
143
+ headers: { 'content-type': 'application/json' },
144
+ status,
145
+ })
146
+ }
147
+
148
+ function jsonRequestBody(request: RecordedRequest | undefined) {
149
+ if (typeof request?.init?.body !== 'string') throw new Error('Expected JSON request body')
150
+ return JSON.parse(request.init.body) as unknown
151
+ }