tapimo 0.8.0 → 0.10.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 (223) hide show
  1. package/dist/App.d.ts +7410 -7613
  2. package/dist/App.d.ts.map +1 -1
  3. package/dist/App.js +0 -1
  4. package/dist/App.js.map +1 -1
  5. package/dist/Webhooks.d.ts +21 -20
  6. package/dist/Webhooks.d.ts.map +1 -1
  7. package/dist/Webhooks.js +66 -23
  8. package/dist/Webhooks.js.map +1 -1
  9. package/dist/admin/apps/verified-tokens.d.ts +74 -74
  10. package/dist/apps/Zones.d.ts +34 -22
  11. package/dist/apps/Zones.d.ts.map +1 -1
  12. package/dist/apps/Zones.js +9 -8
  13. package/dist/apps/Zones.js.map +1 -1
  14. package/dist/apps/data/App.d.ts +3120 -2782
  15. package/dist/apps/data/App.d.ts.map +1 -1
  16. package/dist/apps/data/FxOracle.d.ts +4 -5
  17. package/dist/apps/data/FxOracle.d.ts.map +1 -1
  18. package/dist/apps/data/FxOracle.js +19 -4
  19. package/dist/apps/data/FxOracle.js.map +1 -1
  20. package/dist/apps/data/routes/activities.d.ts.map +1 -1
  21. package/dist/apps/data/routes/activities.js +32 -27
  22. package/dist/apps/data/routes/activities.js.map +1 -1
  23. package/dist/apps/data/routes/addresses.d.ts +283 -52
  24. package/dist/apps/data/routes/addresses.d.ts.map +1 -1
  25. package/dist/apps/data/routes/balances.d.ts +291 -21
  26. package/dist/apps/data/routes/balances.d.ts.map +1 -1
  27. package/dist/apps/data/routes/balances.js +232 -66
  28. package/dist/apps/data/routes/balances.js.map +1 -1
  29. package/dist/apps/data/routes/blocks.d.ts +4 -4
  30. package/dist/apps/data/routes/coingecko.d.ts +54 -54
  31. package/dist/apps/data/routes/earn.d.ts +642 -126
  32. package/dist/apps/data/routes/earn.d.ts.map +1 -1
  33. package/dist/apps/data/routes/earn.js +627 -58
  34. package/dist/apps/data/routes/earn.js.map +1 -1
  35. package/dist/apps/data/routes/exchanges.d.ts +120 -120
  36. package/dist/apps/data/routes/exchanges.d.ts.map +1 -1
  37. package/dist/apps/data/routes/exchanges.js +114 -118
  38. package/dist/apps/data/routes/exchanges.js.map +1 -1
  39. package/dist/apps/data/routes/receipts.d.ts.map +1 -1
  40. package/dist/apps/data/routes/receipts.js +68 -47
  41. package/dist/apps/data/routes/receipts.js.map +1 -1
  42. package/dist/apps/data/routes/tokens.d.ts +2 -0
  43. package/dist/apps/data/routes/tokens.d.ts.map +1 -1
  44. package/dist/apps/data/routes/transactions.d.ts +59 -51
  45. package/dist/apps/data/routes/transactions.d.ts.map +1 -1
  46. package/dist/apps/data/routes/transactions.js +25 -23
  47. package/dist/apps/data/routes/transactions.js.map +1 -1
  48. package/dist/apps/data/routes/transfers.d.ts +40 -18
  49. package/dist/apps/data/routes/transfers.d.ts.map +1 -1
  50. package/dist/apps/data/routes/transfers.js +618 -105
  51. package/dist/apps/data/routes/transfers.js.map +1 -1
  52. package/dist/apps/data/routes/valuation.d.ts +21 -21
  53. package/dist/apps/data/routes/valuation.d.ts.map +1 -1
  54. package/dist/apps/data/routes/valuation.js +2 -2
  55. package/dist/apps/data/routes/valuation.js.map +1 -1
  56. package/dist/apps/data/routes/verified-tokens.d.ts +37 -37
  57. package/dist/apps/data/routes/webhooks.d.ts +102 -505
  58. package/dist/apps/data/routes/webhooks.d.ts.map +1 -1
  59. package/dist/apps/data/routes/webhooks.js +484 -342
  60. package/dist/apps/data/routes/webhooks.js.map +1 -1
  61. package/dist/apps/data/routes/zones.d.ts +12 -12
  62. package/dist/apps/data/routes/zones.d.ts.map +1 -1
  63. package/dist/apps/data/routes/zones.js +3 -1
  64. package/dist/apps/data/routes/zones.js.map +1 -1
  65. package/dist/apps/funding/App.d.ts +127 -386
  66. package/dist/apps/funding/App.d.ts.map +1 -1
  67. package/dist/apps/funding/App.js +20 -5
  68. package/dist/apps/funding/App.js.map +1 -1
  69. package/dist/apps/funding/routes/deposit-addresses.d.ts +141 -532
  70. package/dist/apps/funding/routes/deposit-addresses.d.ts.map +1 -1
  71. package/dist/apps/funding/routes/deposit-addresses.js +21 -103
  72. package/dist/apps/funding/routes/deposit-addresses.js.map +1 -1
  73. package/dist/apps/funding/routes/transfers.d.ts +4 -4
  74. package/dist/apps/funding/routes/transfers.js +5 -5
  75. package/dist/apps/funding/routes/transfers.js.map +1 -1
  76. package/dist/apps/management/App.d.ts +429 -727
  77. package/dist/apps/management/App.d.ts.map +1 -1
  78. package/dist/apps/management/routes/api-keys.d.ts +24 -24
  79. package/dist/apps/management/routes/billing.d.ts +72 -72
  80. package/dist/apps/management/routes/faucet.d.ts +6 -6
  81. package/dist/apps/management/routes/invite-links.d.ts +38 -38
  82. package/dist/apps/management/routes/me.d.ts +6 -6
  83. package/dist/apps/management/routes/members.d.ts +30 -30
  84. package/dist/apps/management/routes/projects.d.ts +20 -20
  85. package/dist/apps/management/routes/usage.d.ts +24 -24
  86. package/dist/apps/management/routes/webhooks.d.ts +158 -573
  87. package/dist/apps/management/routes/webhooks.d.ts.map +1 -1
  88. package/dist/apps/mpp/App.d.ts +12 -0
  89. package/dist/apps/mpp/App.d.ts.map +1 -1
  90. package/dist/apps/mpp/Mpp.d.ts +3 -0
  91. package/dist/apps/mpp/Mpp.d.ts.map +1 -1
  92. package/dist/apps/mpp/Mpp.js +3 -0
  93. package/dist/apps/mpp/Mpp.js.map +1 -1
  94. package/dist/apps/mpp/internal/Adapter.d.ts.map +1 -1
  95. package/dist/apps/mpp/internal/Adapter.js +9 -2
  96. package/dist/apps/mpp/internal/Adapter.js.map +1 -1
  97. package/dist/db/Db.d.ts +2 -2
  98. package/dist/db/Db.d.ts.map +1 -1
  99. package/dist/db/Db.js +25 -15
  100. package/dist/db/Db.js.map +1 -1
  101. package/dist/db/tables/fundingDeposits.d.ts +18 -1
  102. package/dist/db/tables/fundingDeposits.d.ts.map +1 -1
  103. package/dist/db/tables/fundingDeposits.js +46 -2
  104. package/dist/db/tables/fundingDeposits.js.map +1 -1
  105. package/dist/db/tables/sponsoredTransactions.d.ts.map +1 -1
  106. package/dist/db/tables/sponsoredTransactions.js +4 -1
  107. package/dist/db/tables/sponsoredTransactions.js.map +1 -1
  108. package/dist/db/tables/webhookSubscriptions.d.ts +56 -3
  109. package/dist/db/tables/webhookSubscriptions.d.ts.map +1 -1
  110. package/dist/db/tables/webhookSubscriptions.js +76 -3
  111. package/dist/db/tables/webhookSubscriptions.js.map +1 -1
  112. package/dist/internal/Auth.d.ts +4 -0
  113. package/dist/internal/Auth.d.ts.map +1 -1
  114. package/dist/internal/Auth.js +11 -2
  115. package/dist/internal/Auth.js.map +1 -1
  116. package/dist/internal/MetricSink.d.ts +112 -3
  117. package/dist/internal/MetricSink.d.ts.map +1 -1
  118. package/dist/internal/MetricSink.js +116 -3
  119. package/dist/internal/MetricSink.js.map +1 -1
  120. package/dist/internal/Schema.js +1 -1
  121. package/dist/internal/Schema.js.map +1 -1
  122. package/dist/internal/Tidx.d.ts +1 -3
  123. package/dist/internal/Tidx.d.ts.map +1 -1
  124. package/dist/internal/Tidx.js +1 -3
  125. package/dist/internal/Tidx.js.map +1 -1
  126. package/dist/internal/Viem.d.ts.map +1 -1
  127. package/dist/internal/Viem.js +2 -1
  128. package/dist/internal/Viem.js.map +1 -1
  129. package/dist/internal/WebhookDestination.d.ts +2 -0
  130. package/dist/internal/WebhookDestination.d.ts.map +1 -1
  131. package/dist/internal/WebhookDestination.js +11 -0
  132. package/dist/internal/WebhookDestination.js.map +1 -1
  133. package/dist/internal/WebhookTransfer.d.ts +55 -0
  134. package/dist/internal/WebhookTransfer.d.ts.map +1 -0
  135. package/dist/internal/WebhookTransfer.js +296 -0
  136. package/dist/internal/WebhookTransfer.js.map +1 -0
  137. package/dist/internal/Webhooks.d.ts +37 -0
  138. package/dist/internal/Webhooks.d.ts.map +1 -1
  139. package/dist/internal/Webhooks.js +87 -13
  140. package/dist/internal/Webhooks.js.map +1 -1
  141. package/dist/internal/funding/Provider.d.ts +10 -6
  142. package/dist/internal/funding/Provider.d.ts.map +1 -1
  143. package/dist/internal/funding/Provider.js +11 -7
  144. package/dist/internal/funding/Provider.js.map +1 -1
  145. package/dist/internal/funding/Reconciliation.d.ts.map +1 -1
  146. package/dist/internal/funding/Reconciliation.js +229 -16
  147. package/dist/internal/funding/Reconciliation.js.map +1 -1
  148. package/dist/internal/funding/providers/relay.d.ts +3 -0
  149. package/dist/internal/funding/providers/relay.d.ts.map +1 -1
  150. package/dist/internal/funding/providers/relay.js +18 -3
  151. package/dist/internal/funding/providers/relay.js.map +1 -1
  152. package/package.json +3 -3
  153. package/src/App.test.ts +32 -0
  154. package/src/App.ts +4 -1
  155. package/src/Client.test-d.ts +116 -38
  156. package/src/Webhooks.bench.ts +714 -0
  157. package/src/Webhooks.memory.bench.ts +250 -0
  158. package/src/Webhooks.test.ts +77 -6
  159. package/src/Webhooks.ts +83 -29
  160. package/src/apps/Zones.test.ts +5 -4
  161. package/src/apps/Zones.ts +9 -8
  162. package/src/apps/composition.test.ts +39 -10
  163. package/src/apps/data/FxOracle.test.ts +18 -0
  164. package/src/apps/data/FxOracle.ts +28 -5
  165. package/src/apps/data/routes/activities.test.ts +19 -0
  166. package/src/apps/data/routes/activities.ts +34 -30
  167. package/src/apps/data/routes/balances.test.ts +161 -10
  168. package/src/apps/data/routes/balances.ts +339 -147
  169. package/src/apps/data/routes/blocks.test.ts +12 -6
  170. package/src/apps/data/routes/earn.test.ts +286 -9
  171. package/src/apps/data/routes/earn.ts +841 -58
  172. package/src/apps/data/routes/exchanges.test.ts +7 -6
  173. package/src/apps/data/routes/exchanges.ts +124 -117
  174. package/src/apps/data/routes/receipts.test.ts +24 -7
  175. package/src/apps/data/routes/receipts.ts +67 -50
  176. package/src/apps/data/routes/tokens.test.ts +51 -0
  177. package/src/apps/data/routes/transactions.test.ts +42 -0
  178. package/src/apps/data/routes/transactions.ts +44 -39
  179. package/src/apps/data/routes/transfers.test.ts +402 -24
  180. package/src/apps/data/routes/transfers.ts +708 -101
  181. package/src/apps/data/routes/valuation.test.ts +26 -0
  182. package/src/apps/data/routes/valuation.ts +6 -2
  183. package/src/apps/data/routes/webhooks.test.ts +414 -130
  184. package/src/apps/data/routes/webhooks.ts +603 -419
  185. package/src/apps/data/routes/zones.ts +6 -1
  186. package/src/apps/funding/App.ts +20 -5
  187. package/src/apps/funding/routes/deposit-addresses.test.ts +43 -22
  188. package/src/apps/funding/routes/deposit-addresses.ts +34 -117
  189. package/src/apps/funding/routes/quotes.test.ts +1 -1
  190. package/src/apps/funding/routes/transfers.test.ts +6 -25
  191. package/src/apps/funding/routes/transfers.ts +5 -5
  192. package/src/apps/mpp/Mpp.test.ts +13 -1
  193. package/src/apps/mpp/Mpp.ts +8 -0
  194. package/src/apps/mpp/internal/Adapter.ts +10 -2
  195. package/src/db/Db.test.ts +40 -6
  196. package/src/db/Db.ts +42 -19
  197. package/src/db/tables/fundingDeposits.test.ts +101 -0
  198. package/src/db/tables/fundingDeposits.ts +71 -2
  199. package/src/db/tables/sponsoredTransactions.ts +9 -2
  200. package/src/db/tables/webhookSubscriptions.ts +116 -4
  201. package/src/internal/Auth.test.ts +28 -0
  202. package/src/internal/Auth.ts +15 -1
  203. package/src/internal/EarnVaults.test.ts +2 -2
  204. package/src/internal/MetricSink.test.ts +518 -0
  205. package/src/internal/MetricSink.ts +266 -9
  206. package/src/internal/Schema.ts +1 -1
  207. package/src/internal/Tidx.test.ts +4 -4
  208. package/src/internal/Tidx.ts +1 -3
  209. package/src/internal/Viem.test.ts +8 -0
  210. package/src/internal/Viem.ts +2 -1
  211. package/src/internal/WebhookDestination.test.ts +20 -0
  212. package/src/internal/WebhookDestination.ts +10 -0
  213. package/src/internal/WebhookTransfer.ts +426 -0
  214. package/src/internal/Webhooks.test.ts +137 -0
  215. package/src/internal/Webhooks.ts +131 -17
  216. package/src/internal/funding/Provider.test.ts +47 -7
  217. package/src/internal/funding/Provider.ts +16 -11
  218. package/src/internal/funding/Reconciliation.local.test.ts +3 -1
  219. package/src/internal/funding/Reconciliation.test.ts +1532 -54
  220. package/src/internal/funding/Reconciliation.ts +296 -53
  221. package/src/internal/funding/SourceObservation.test.ts +3 -2
  222. package/src/internal/funding/providers/relay.test.ts +1 -4
  223. package/src/internal/funding/providers/relay.ts +23 -3
@@ -66,7 +66,12 @@ export namespace schema {
66
66
  include: Schema.totalCountInclude,
67
67
  limit: Schema.Limit,
68
68
  page: Schema.Page,
69
- 'valuation.currency': Schema.Denomination,
69
+ 'valuation.currency': Schema.Denomination.check(
70
+ z.describe(
71
+ 'When present, include each holding’s nominal value in this denomination. ' +
72
+ 'Case-insensitive and must be priced by the configured FX oracle.',
73
+ ),
74
+ ),
70
75
  verified: z
71
76
  .optional(Schema.booleanQuery())
72
77
  .check(
@@ -141,11 +146,11 @@ export namespace schema {
141
146
  ),
142
147
  token: Tokens.schema.Token,
143
148
  valuation: z
144
- .nullable(Valuation.schema.Value)
149
+ .optional(z.nullable(Valuation.schema.Value))
145
150
  .check(
146
151
  z.describe(
147
- 'The holding’s nominal value in the requested `valuation.currency`, or `null` when the ' +
148
- 'token is unverified, its display currency has no rate, or rates were unavailable.',
152
+ 'The holding’s nominal value when `valuation.currency` is requested, or `null` when ' +
153
+ 'the token is unverified, its display currency has no rate, or rates were unavailable.',
149
154
  ),
150
155
  ),
151
156
  }),
@@ -181,102 +186,241 @@ export namespace schema {
181
186
  })
182
187
  .check(z.describe('A page of TIP-20 token balances held by this account.'))
183
188
  }
189
+
190
+ /** Schemas for the getAddressBalance operation. */
191
+ export namespace getAddressBalance {
192
+ /** Path parameters for a single address balance request. */
193
+ export const Params = z
194
+ .object({
195
+ address: Schema.Address.check(
196
+ z.describe('The account address whose token balance you want to read.'),
197
+ ),
198
+ token: Schema.TokenAddress.check(z.describe('The TIP-20 token contract address.')),
199
+ })
200
+ .check(z.describe('Path parameters for reading one account token balance.'))
201
+
202
+ /** Query parameters for a single address balance request. */
203
+ export const Query = z
204
+ .strictObject({
205
+ chainId: Schema.ChainIdQuery,
206
+ 'valuation.currency': Schema.Denomination.check(
207
+ z.describe(
208
+ 'When present, include the holding’s nominal value in this denomination. ' +
209
+ 'Case-insensitive and must be priced by the configured FX oracle.',
210
+ ),
211
+ ),
212
+ })
213
+ .check(z.describe('Query parameters for reading one account token balance.'))
214
+
215
+ /** A single TIP-20 token balance held by an address. */
216
+ export const Response = getAddressBalances.Balance.check(
217
+ z.describe('One TIP-20 token balance held by this account.'),
218
+ )
219
+ }
184
220
  }
185
221
 
186
222
  /**
187
223
  * Creates address-scoped balance handlers, mounted under the `/addresses`
188
- * composer. Exposes `GET /:address/balances`, the held-balance page.
224
+ * composer. Exposes the held-balance page and individual token balances.
189
225
  */
190
226
  export function addresses(options: addresses.Options = {}) {
191
227
  const oracle = options.fx?.oracle ?? FxOracle.ecb()
192
- return new Hono<App.Environment>().get(
193
- '/v1/addresses/:address/balances',
194
- Auth.policy({ apiKey: { scopes: ['data:read'] }, mpp: true, public: true }),
195
- OpenApi.validate('param', schema.getAddressBalances.Params, {
196
- code: 'address_invalid',
197
- message: 'Invalid account address',
198
- }),
199
- OpenApi.validate('query', schema.getAddressBalances.Query, {
200
- code: 'query_invalid',
201
- message: 'Invalid query parameters',
202
- }),
203
- OpenApi.describeRoute({
204
- description:
205
- 'Lists how much of each TIP-20 token an account holds, ordered from largest to smallest balance. Amounts are returned in raw base units and human-readable form, and each verified holding carries its nominal value in the requested denomination.',
206
- operationId: 'getAddressBalances',
207
- responses: OpenApi.responses({
208
- errors: { 502: 'Could not read balance or token data from an upstream service.' },
209
- success: {
210
- description: 'A page of token balances for this account.',
211
- schema: schema.getAddressBalances.Response,
212
- },
228
+ return new Hono<App.Environment>()
229
+ .get(
230
+ '/v1/addresses/:address/balances',
231
+ Auth.policy({ apiKey: { scopes: ['data:read'] }, mpp: true, public: true }),
232
+ OpenApi.validate('param', schema.getAddressBalances.Params, {
233
+ code: 'address_invalid',
234
+ message: 'Invalid account address',
213
235
  }),
214
- summary: 'List address balances',
215
- tags: ['Balances'],
216
- }),
217
- Cache.response<App.Environment>({
218
- cacheControl: Cache.policies.noStore,
219
- name: 'tempo-api:addresses:v1',
220
- key: (c) => Cache.urlKey(c, schema.getAddressBalances.Query),
221
- }),
222
- async (c) => {
223
- if (Auth.narrowAccess) return Auth.paidAccessError(c)
224
- if (OpenApi.narrowValidation)
225
- return OpenApi.validationError(c, {
226
- code: 'address_invalid',
227
- message: 'Invalid request parameters',
228
- })
229
- if (OpenApi.narrowValidation)
230
- return OpenApi.validationError(c, {
231
- code: 'query_invalid',
232
- message: 'Invalid request parameters',
233
- })
234
-
235
- const { address } = c.req.valid('param')
236
- const query = c.req.valid('query')
237
-
238
- try {
236
+ OpenApi.validate('query', schema.getAddressBalances.Query, {
237
+ code: 'query_invalid',
238
+ message: 'Invalid query parameters',
239
+ }),
240
+ OpenApi.describeRoute({
241
+ description:
242
+ 'Lists how much of each TIP-20 token an account holds, ordered from largest to smallest balance. Amounts are returned in raw base units and human-readable form. Pass `valuation.currency` to include nominal values.',
243
+ operationId: 'getAddressBalances',
244
+ responses: OpenApi.responses({
245
+ errors: { 502: 'Could not read balance or token data from an upstream service.' },
246
+ success: {
247
+ description: 'A page of token balances for this account.',
248
+ schema: schema.getAddressBalances.Response,
249
+ },
250
+ }),
251
+ summary: 'List address balances',
252
+ tags: ['Balances'],
253
+ }),
254
+ Cache.response<App.Environment>({
255
+ cacheControl: Cache.policies.noStore,
256
+ name: 'tempo-api:addresses:v1',
257
+ key: (c) => Cache.urlKey(c, schema.getAddressBalances.Query),
258
+ }),
259
+ async (c) => {
260
+ if (Auth.narrowAccess) return Auth.paidAccessError(c)
261
+ if (OpenApi.narrowValidation)
262
+ return OpenApi.validationError(c, {
263
+ code: 'address_invalid',
264
+ message: 'Invalid request parameters',
265
+ })
266
+ if (OpenApi.narrowValidation)
267
+ return OpenApi.validationError(c, {
268
+ code: 'query_invalid',
269
+ message: 'Invalid request parameters',
270
+ })
271
+
272
+ const { address } = c.req.valid('param')
273
+ const query = c.req.valid('query')
274
+
275
+ try {
276
+ const chainId = query.chainId ?? c.get('chainId')
277
+ // `currency` only makes sense against verified tokens, so it implies
278
+ // `verified=true` from the caller's perspective.
279
+ const verified = query.verified || query.currency !== undefined
280
+ const feeEligible = query.feeEligible ?? false
281
+
282
+ const balances = await getAddressBalances(c, {
283
+ address,
284
+ chainId,
285
+ currency: query.currency,
286
+ cursor: query.cursor,
287
+ denomination: query['valuation.currency'],
288
+ feeEligible,
289
+ includeTotalCount: query.include.includes('totalCount'),
290
+ limit: query.limit,
291
+ oracle,
292
+ page: query.page,
293
+ verified,
294
+ })
295
+
296
+ return c.json(Response.validated(schema.getAddressBalances.Response, balances), 200)
297
+ } catch (cause) {
298
+ if (cause instanceof Valuation.UnsupportedDenominationError)
299
+ return Response.error(c, { code: 'query_invalid', message: cause.message, status: 400 })
300
+ return Response.upstream(c, cause)
301
+ }
302
+ },
303
+ )
304
+ .get(
305
+ '/v1/addresses/:address/balances/:token',
306
+ Auth.policy({ apiKey: { scopes: ['data:read'] }, mpp: true, public: true }),
307
+ OpenApi.validate('param', schema.getAddressBalance.Params, {
308
+ code: 'address_invalid',
309
+ message: 'Invalid address or token address',
310
+ }),
311
+ OpenApi.validate('query', schema.getAddressBalance.Query, {
312
+ code: 'query_invalid',
313
+ message: 'Invalid query parameters',
314
+ }),
315
+ OpenApi.describeRoute({
316
+ description:
317
+ 'Returns the live balance of one TIP-20 token held by an account, including token metadata and a human-readable amount.',
318
+ operationId: 'getAddressBalance',
319
+ responses: OpenApi.responses({
320
+ errors: {
321
+ 404: {
322
+ description: 'The token is not a registered TIP-20 token.',
323
+ codes: ['token_not_found'],
324
+ },
325
+ 502: 'Could not read balance or token data from an upstream service.',
326
+ },
327
+ success: {
328
+ description: 'The requested token balance for this account.',
329
+ schema: schema.getAddressBalance.Response,
330
+ },
331
+ }),
332
+ summary: 'Get address balance',
333
+ tags: ['Balances'],
334
+ }),
335
+ Cache.response<App.Environment>({
336
+ cacheControl: Cache.policies.noStore,
337
+ name: 'tempo-api:addresses:v1',
338
+ key: (c) => Cache.urlKey(c, schema.getAddressBalance.Query),
339
+ }),
340
+ async (c) => {
341
+ if (Auth.narrowAccess) return Auth.paidAccessError(c)
342
+ if (OpenApi.narrowValidation)
343
+ return OpenApi.validationError(c, {
344
+ code: 'address_invalid',
345
+ message: 'Invalid request parameters',
346
+ })
347
+ if (OpenApi.narrowValidation)
348
+ return OpenApi.validationError(c, {
349
+ code: 'query_invalid',
350
+ message: 'Invalid request parameters',
351
+ })
352
+
353
+ const { address, token } = c.req.valid('param')
354
+ const query = c.req.valid('query')
239
355
  const chainId = query.chainId ?? c.get('chainId')
240
- // `currency` only makes sense against verified tokens, so it implies
241
- // `verified=true` from the caller's perspective.
242
- const verified = query.verified || query.currency !== undefined
243
- const feeEligible = query.feeEligible ?? false
244
-
245
- // The fee-token set powers both the per-row `feeEligible` flag (always)
246
- // and the optional `feeEligible=true` page restriction. It is memoized
247
- // per chain (a near-constant), so one read feeds the page, the count,
248
- // and the row enrichment. It is a best-effort enrichment: a lookup
249
- // failure omits `feeEligible` rather than failing the page — unless the
250
- // caller explicitly asked to filter by `feeEligible`, where the filter
251
- // can't be honored, so the failure surfaces as the declared `502`.
252
- const feeTokens = await FeeAmm.feeTokenSet(c, chainId).catch((cause) => {
253
- if (feeEligible) throw cause
254
- return undefined
255
- })
256
-
257
- const balances = await getAddressBalances(c, {
258
- address,
259
- chainId,
260
- currency: query.currency,
261
- cursor: query.cursor,
262
- denomination: query['valuation.currency'] ?? 'USD',
263
- feeEligible,
264
- feeTokens,
265
- includeTotalCount: query.include.includes('totalCount'),
266
- limit: query.limit,
267
- oracle,
268
- page: query.page,
269
- verified,
270
- })
271
-
272
- return c.json(Response.validated(schema.getAddressBalances.Response, balances), 200)
273
- } catch (cause) {
274
- if (cause instanceof Valuation.UnsupportedDenominationError)
275
- return Response.error(c, { code: 'query_invalid', message: cause.message, status: 400 })
276
- return Response.upstream(c, cause)
277
- }
278
- },
279
- )
356
+ const denomination = query['valuation.currency']
357
+
358
+ try {
359
+ const rates = denomination
360
+ ? await Valuation.rateSet(c, oracle)
361
+ .then((rates) => {
362
+ if (denomination !== rates.base && rates.rates[denomination] === undefined)
363
+ throw new Valuation.UnsupportedDenominationError(denomination, oracle.name)
364
+ return rates
365
+ })
366
+ .catch((cause) => {
367
+ if (cause instanceof Valuation.UnsupportedDenominationError) throw cause
368
+ return undefined
369
+ })
370
+ : undefined
371
+ const snapshotPromise = VerifiedTokens.snapshot(c, chainId)
372
+ const resolvedTokenPromise = snapshotPromise.then((snapshot) =>
373
+ Timing.time(c, 'token', () =>
374
+ Tokens.resolveToken(c, { address: token, chainId, include: [], snapshot }),
375
+ ),
376
+ )
377
+ const [balance, feeTokens, snapshot, resolvedToken] = await Promise.all([
378
+ Timing.time(c, 'balance_rpc', async () => {
379
+ const result = await Actions.token.getBalance(c.get('getClient')(chainId), {
380
+ account: address,
381
+ token,
382
+ })
383
+ return result.amount
384
+ }),
385
+ FeeAmm.feeTokenSet(c, chainId).catch(() => undefined),
386
+ snapshotPromise,
387
+ resolvedTokenPromise,
388
+ ])
389
+ return c.json(
390
+ Response.validated(schema.getAddressBalance.Response, {
391
+ amount: balance.toString(),
392
+ currency: resolvedToken.currency,
393
+ decimals: resolvedToken.decimals,
394
+ ...(feeTokens ? { feeEligible: feeTokens.has(token) } : {}),
395
+ formatted: core_Value.format(balance, resolvedToken.decimals),
396
+ id: token,
397
+ token: resolvedToken,
398
+ ...(denomination
399
+ ? {
400
+ valuation: Valuation.valuationFor({
401
+ amount: balance,
402
+ denomination,
403
+ rates,
404
+ token: snapshot.byAddress.get(token),
405
+ }),
406
+ }
407
+ : {}),
408
+ }),
409
+ 200,
410
+ )
411
+ } catch (cause) {
412
+ if (Tokens.isTokenNotFound(cause))
413
+ return Response.error(c, {
414
+ code: 'token_not_found',
415
+ message: 'Token not found',
416
+ status: 404,
417
+ })
418
+ if (cause instanceof Valuation.UnsupportedDenominationError)
419
+ return Response.error(c, { code: 'query_invalid', message: cause.message, status: 400 })
420
+ return Response.upstream(c, cause)
421
+ }
422
+ },
423
+ )
280
424
  }
281
425
 
282
426
  export declare namespace addresses {
@@ -297,7 +441,6 @@ async function getAddressBalances(
297
441
  currency,
298
442
  denomination,
299
443
  feeEligible,
300
- feeTokens,
301
444
  includeTotalCount,
302
445
  limit,
303
446
  oracle,
@@ -307,8 +450,9 @@ async function getAddressBalances(
307
450
  const cursor = options.cursor ? Cursor.decode(options.cursor, ['uint', 'address']) : undefined
308
451
  const offset = options.page !== undefined && options.page > 1 ? (options.page - 1) * limit : 0
309
452
 
310
- // Validate the requested denomination independently of the current page.
311
- await Valuation.ratesFor(c, { currencies: [], denomination, oracle })
453
+ // Validate an explicit denomination before balance discovery and correction
454
+ // so dependency failures cannot mask a caller error.
455
+ if (denomination) await Valuation.ratesFor(c, { currencies: [], denomination, oracle })
312
456
 
313
457
  // Build the optional `token IN (…)` restriction before hitting TIDX. The
314
458
  // verified/`currency` filter and the `feeEligible` filter each contribute a set
@@ -317,17 +461,32 @@ async function getAddressBalances(
317
461
  // intersection. If any active filter rules every token out, return an empty
318
462
  // page without a round trip. The snapshot is reused for the page enrichment
319
463
  // below.
320
- const snapshot = await VerifiedTokens.snapshot(c, chainId)
321
- const allowed = allowedTokens({ currency, feeEligible, feeTokens, snapshot, verified })
464
+ const snapshotPromise = VerifiedTokens.snapshot(c, chainId)
465
+ // Fee filtering can fail before the concurrently started snapshot is awaited.
466
+ // Observing this branch prevents an unhandled rejection; the later await still propagates it.
467
+ void snapshotPromise.catch(() => undefined)
468
+ const [snapshot_filter, feeTokens_filter] = await Promise.all([
469
+ verified ? snapshotPromise : Promise.resolve(undefined),
470
+ feeEligible ? FeeAmm.feeTokenSet(c, chainId) : Promise.resolve(undefined),
471
+ ])
472
+ const allowed = allowedTokens({
473
+ currency,
474
+ feeEligible,
475
+ feeTokens: feeTokens_filter,
476
+ snapshot: snapshot_filter,
477
+ verified,
478
+ })
322
479
  const tokenFilter = allowed?.map((address) => `'${address}'`)
323
- if (tokenFilter?.length === 0)
480
+ if (tokenFilter?.length === 0) {
481
+ await snapshotPromise
324
482
  return {
325
483
  data: [],
326
484
  ...(includeTotalCount ? { meta: { totalCount: 0, totalCountCapped: false } } : {}),
327
485
  nextCursor: null,
328
486
  }
487
+ }
329
488
 
330
- const holdings = await Timing.time(c, 'balances_discovery', async () => {
489
+ const holdingsPromise = Timing.time(c, 'balances_discovery', async () => {
331
490
  const tokenIn = tokenFilter ? ` AND token IN (${tokenFilter.join(', ')})` : ''
332
491
 
333
492
  const result = await tidx.fetch({
@@ -348,36 +507,54 @@ async function getAddressBalances(
348
507
  })
349
508
  })
350
509
 
351
- if (holdings.length === 0)
510
+ // Correction starts as soon as discovery completes. Snapshot and fee-token
511
+ // cache misses continue concurrently until enrichment needs their results.
512
+ const correctedPromise = holdingsPromise.then(async (holdings) => {
513
+ if (holdings.length === 0) return []
514
+ const client = c.get('getClient')(chainId)
515
+ return Timing.time(c, 'balances_rpc', async () => {
516
+ // Concurrent reads enter viem's shared batch window and become one
517
+ // deployless multicall. A failed read fails the request rather than serving
518
+ // a stale or partial balance page.
519
+ const results = await Promise.all(
520
+ holdings.map(async (holding) => {
521
+ const { amount } = await Actions.token.getBalance(client, {
522
+ account: address,
523
+ token: holding.address,
524
+ })
525
+ return amount === 0n ? undefined : { address: holding.address, amount: amount.toString() }
526
+ }),
527
+ )
528
+ const rows = results.filter((holding) => holding !== undefined)
529
+
530
+ rows.sort((a, b) => {
531
+ const delta = BigInt(b.amount) - BigInt(a.amount)
532
+ if (delta !== 0n) return delta > 0n ? 1 : -1
533
+ return a.address < b.address ? -1 : a.address > b.address ? 1 : 0
534
+ })
535
+ return rows
536
+ })
537
+ })
538
+ const feeTokensPromise = feeEligible
539
+ ? Promise.resolve(feeTokens_filter)
540
+ : holdingsPromise.then((holdings) =>
541
+ holdings.length === 0 ? undefined : FeeAmm.feeTokenSet(c, chainId).catch(() => undefined),
542
+ )
543
+
544
+ const [holdings, snapshot, feeTokens, corrected] = await Promise.all([
545
+ holdingsPromise,
546
+ snapshotPromise,
547
+ feeTokensPromise,
548
+ correctedPromise,
549
+ ])
550
+
551
+ if (holdings.length === 0) {
352
552
  return {
353
553
  data: [],
354
554
  ...(includeTotalCount ? { meta: { totalCount: 0, totalCountCapped: false } } : {}),
355
555
  nextCursor: null,
356
556
  }
357
-
358
- const client = c.get('getClient')(chainId)
359
- const corrected = await Timing.time(c, 'balances_rpc', async () => {
360
- // Concurrent reads enter viem's shared batch window and become one
361
- // deployless multicall. A failed read fails the request rather than serving
362
- // a stale or partial balance page.
363
- const results = await Promise.all(
364
- holdings.map(async (holding) => {
365
- const { amount } = await Actions.token.getBalance(client, {
366
- account: address,
367
- token: holding.address,
368
- })
369
- return amount === 0n ? undefined : { address: holding.address, amount: amount.toString() }
370
- }),
371
- )
372
- const rows = results.filter((holding) => holding !== undefined)
373
-
374
- rows.sort((a, b) => {
375
- const delta = BigInt(b.amount) - BigInt(a.amount)
376
- if (delta !== 0n) return delta > 0n ? 1 : -1
377
- return a.address < b.address ? -1 : a.address > b.address ? 1 : 0
378
- })
379
- return rows
380
- })
557
+ }
381
558
 
382
559
  const positioned = (() => {
383
560
  if (!cursor) return corrected.slice(offset)
@@ -395,7 +572,7 @@ async function getAddressBalances(
395
572
  key: (holding) => [holding.amount, holding.address],
396
573
  })
397
574
 
398
- if (page.rows.length === 0)
575
+ if (page.rows.length === 0) {
399
576
  return {
400
577
  data: [],
401
578
  ...(includeTotalCount
@@ -403,22 +580,38 @@ async function getAddressBalances(
403
580
  : {}),
404
581
  nextCursor: page.nextCursor,
405
582
  }
583
+ }
406
584
 
407
- const rates = await Valuation.ratesFor(c, {
408
- currencies: page.rows.flatMap((row) => {
409
- const held = snapshot.byAddress.get(row.address.toLowerCase())?.currency
410
- return held === undefined ? [] : [held]
411
- }),
412
- denomination,
413
- oracle,
414
- })
585
+ const ratesPromise = denomination
586
+ ? Valuation.ratesFor(c, {
587
+ currencies: page.rows.flatMap((row) => {
588
+ const held = snapshot.byAddress.get(row.address.toLowerCase())?.currency
589
+ return held === undefined ? [] : [held]
590
+ }),
591
+ denomination,
592
+ oracle,
593
+ })
594
+ : Promise.resolve(undefined)
415
595
 
416
596
  // Balance tokens retain their logo and verification metadata by default.
417
- const resolved = await Timing.time(c, 'tokens', () =>
597
+ const resolvedPromise = Timing.time(c, 'tokens', () =>
418
598
  Promise.all(
419
599
  page.rows.map(async (holding) => {
420
600
  try {
421
601
  const token = Schema.Address.parse(holding.address.toLowerCase())
602
+ const verifiedToken = snapshot.byAddress.get(token)
603
+ // The curated list owns verified-token metadata, but uploaded logos live only in the asset store.
604
+ if (verifiedToken)
605
+ return {
606
+ amount: holding.amount,
607
+ logoUri:
608
+ verifiedToken.logoUri ??
609
+ (await Timing.time(c, 'token_logo', () =>
610
+ Tokens.getTokenLogo(c, { address: token, chainId }),
611
+ ).catch(() => undefined)),
612
+ metadata: verifiedToken,
613
+ token,
614
+ }
422
615
  const [logoUri, metadata] = await Promise.all([
423
616
  Timing.time(c, 'token_logo', () =>
424
617
  Tokens.getTokenLogo(c, { address: token, chainId }),
@@ -434,6 +627,7 @@ async function getAddressBalances(
434
627
  }),
435
628
  ),
436
629
  )
630
+ const [rates, resolved] = await Promise.all([ratesPromise, resolvedPromise])
437
631
 
438
632
  const data: z.output<typeof schema.getAddressBalances.Balance>[] = []
439
633
  for (const entry of resolved) {
@@ -458,12 +652,16 @@ async function getAddressBalances(
458
652
  symbol: entry.metadata.symbol,
459
653
  verified: snapshot.byAddress.has(entry.token),
460
654
  },
461
- valuation: Valuation.valuationFor({
462
- amount: BigInt(entry.amount),
463
- denomination,
464
- rates,
465
- token: snapshot.byAddress.get(entry.token),
466
- }),
655
+ ...(denomination
656
+ ? {
657
+ valuation: Valuation.valuationFor({
658
+ amount: BigInt(entry.amount),
659
+ denomination,
660
+ rates,
661
+ token: snapshot.byAddress.get(entry.token),
662
+ }),
663
+ }
664
+ : {}),
467
665
  })
468
666
  }
469
667
 
@@ -487,15 +685,9 @@ declare namespace getAddressBalances {
487
685
  /** Opaque keyset cursor anchoring the page; omit for the head page. */
488
686
  cursor?: string | undefined
489
687
  /** Currency the per-row valuations are denominated in (uppercase). */
490
- denomination: string
491
- /** When true, restrict the page to fee-usable tokens (members of `feeTokens`). */
688
+ denomination?: string | undefined
689
+ /** When true, restrict the page to fee-usable tokens. */
492
690
  feeEligible?: boolean | undefined
493
- /**
494
- * The chain's fee-token set, if resolved (best-effort): flags each row's
495
- * `feeEligible` and backs the `feeEligible` filter. Undefined when the lookup
496
- * failed, in which case `feeEligible` is omitted from rows.
497
- */
498
- feeTokens?: ReadonlySet<string> | undefined
499
691
  /** When true, include the corrected candidate count in response metadata. */
500
692
  includeTotalCount: boolean
501
693
  limit: number
@@ -232,20 +232,26 @@ describe('GET /blocks', () => {
232
232
 
233
233
  describe('GET /blocks/:block (ISO 8601 timestamp)', () => {
234
234
  test('resolves an ISO 8601 timestamp to the first block at or after it', async () => {
235
- expect(recent).toBeDefined()
236
- const recentBlock = await Viem.getClient().getBlock({ blockNumber: recent!.number })
237
- const seconds = Number(recentBlock.timestamp)
238
- const iso = new Date(seconds * 1000).toISOString()
239
235
  const client = TestApp.client()
236
+ // Seed from TIDX rather than the RPC head, which can advance beyond the
237
+ // indexer's timestamp lookup watermark.
238
+ const blocksResponse = await client.v1.blocks.$get({ query: { limit: '5' } }, TestApp.auth)
239
+ const blocks = await TestApp.json(blocksResponse, Blocks.schema.getBlocks.Response)
240
+ const indexed = blocks.data[0]
241
+ expect(blocksResponse.status).toBe(200)
242
+ expect(indexed).toBeDefined()
243
+
240
244
  const response = await client.v1.blocks[':block'].$get(
241
- { param: { block: iso }, query: {} },
245
+ { param: { block: indexed!.timestamp }, query: {} },
242
246
  TestApp.auth,
243
247
  )
244
248
  const body = await TestApp.json(response, Blocks.schema.getBlock.Response)
245
249
  expect(response.status).toBe(200)
246
250
  expect(typeof body.number).toBe('number')
247
251
  // The returned block's timestamp must be >= the requested timestamp.
248
- expect(new Date(body.timestamp).getTime() / 1000).toBeGreaterThanOrEqual(seconds)
252
+ expect(new Date(body.timestamp).getTime()).toBeGreaterThanOrEqual(
253
+ new Date(indexed!.timestamp).getTime(),
254
+ )
249
255
  })
250
256
 
251
257
  test('returns 404 for a far-future timestamp with no indexed block', async () => {