tapimo 0.9.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 (198) hide show
  1. package/dist/App.d.ts +4887 -5074
  2. package/dist/App.d.ts.map +1 -1
  3. package/dist/Webhooks.d.ts +21 -20
  4. package/dist/Webhooks.d.ts.map +1 -1
  5. package/dist/Webhooks.js +66 -23
  6. package/dist/Webhooks.js.map +1 -1
  7. package/dist/admin/apps/verified-tokens.d.ts +168 -168
  8. package/dist/apps/Zones.d.ts +34 -22
  9. package/dist/apps/Zones.d.ts.map +1 -1
  10. package/dist/apps/Zones.js +9 -8
  11. package/dist/apps/Zones.js.map +1 -1
  12. package/dist/apps/data/App.d.ts +5198 -5099
  13. package/dist/apps/data/App.d.ts.map +1 -1
  14. package/dist/apps/data/FxOracle.d.ts +4 -5
  15. package/dist/apps/data/FxOracle.d.ts.map +1 -1
  16. package/dist/apps/data/FxOracle.js +19 -4
  17. package/dist/apps/data/FxOracle.js.map +1 -1
  18. package/dist/apps/data/routes/activities.d.ts +270 -270
  19. package/dist/apps/data/routes/activities.d.ts.map +1 -1
  20. package/dist/apps/data/routes/activities.js +32 -27
  21. package/dist/apps/data/routes/activities.js.map +1 -1
  22. package/dist/apps/data/routes/addresses.d.ts +52 -52
  23. package/dist/apps/data/routes/balances.d.ts +36 -36
  24. package/dist/apps/data/routes/balances.d.ts.map +1 -1
  25. package/dist/apps/data/routes/balances.js +135 -81
  26. package/dist/apps/data/routes/balances.js.map +1 -1
  27. package/dist/apps/data/routes/blocks.d.ts +125 -125
  28. package/dist/apps/data/routes/coingecko.d.ts +54 -54
  29. package/dist/apps/data/routes/earn.d.ts +512 -58
  30. package/dist/apps/data/routes/earn.d.ts.map +1 -1
  31. package/dist/apps/data/routes/earn.js +368 -44
  32. package/dist/apps/data/routes/earn.js.map +1 -1
  33. package/dist/apps/data/routes/exchanges.d.ts +120 -120
  34. package/dist/apps/data/routes/exchanges.d.ts.map +1 -1
  35. package/dist/apps/data/routes/exchanges.js +114 -118
  36. package/dist/apps/data/routes/exchanges.js.map +1 -1
  37. package/dist/apps/data/routes/receipts.d.ts.map +1 -1
  38. package/dist/apps/data/routes/receipts.js +68 -47
  39. package/dist/apps/data/routes/receipts.js.map +1 -1
  40. package/dist/apps/data/routes/rpc.d.ts +31 -31
  41. package/dist/apps/data/routes/tokens.d.ts +461 -459
  42. package/dist/apps/data/routes/tokens.d.ts.map +1 -1
  43. package/dist/apps/data/routes/transactions.d.ts +59 -51
  44. package/dist/apps/data/routes/transactions.d.ts.map +1 -1
  45. package/dist/apps/data/routes/transactions.js +25 -23
  46. package/dist/apps/data/routes/transactions.js.map +1 -1
  47. package/dist/apps/data/routes/transfers.d.ts +108 -86
  48. package/dist/apps/data/routes/transfers.d.ts.map +1 -1
  49. package/dist/apps/data/routes/transfers.js +618 -105
  50. package/dist/apps/data/routes/transfers.js.map +1 -1
  51. package/dist/apps/data/routes/valuation.d.ts +21 -21
  52. package/dist/apps/data/routes/valuation.d.ts.map +1 -1
  53. package/dist/apps/data/routes/valuation.js +2 -2
  54. package/dist/apps/data/routes/valuation.js.map +1 -1
  55. package/dist/apps/data/routes/verified-tokens.d.ts +37 -37
  56. package/dist/apps/data/routes/webhooks.d.ts +102 -505
  57. package/dist/apps/data/routes/webhooks.d.ts.map +1 -1
  58. package/dist/apps/data/routes/webhooks.js +484 -342
  59. package/dist/apps/data/routes/webhooks.js.map +1 -1
  60. package/dist/apps/data/routes/zones.d.ts +12 -12
  61. package/dist/apps/data/routes/zones.d.ts.map +1 -1
  62. package/dist/apps/data/routes/zones.js +3 -1
  63. package/dist/apps/data/routes/zones.js.map +1 -1
  64. package/dist/apps/funding/App.d.ts +96 -96
  65. package/dist/apps/funding/routes/deposit-addresses.d.ts +52 -52
  66. package/dist/apps/funding/routes/quotes.d.ts +89 -89
  67. package/dist/apps/funding/routes/transfers.d.ts +4 -4
  68. package/dist/apps/management/App.d.ts +429 -727
  69. package/dist/apps/management/App.d.ts.map +1 -1
  70. package/dist/apps/management/routes/api-keys.d.ts +24 -24
  71. package/dist/apps/management/routes/billing.d.ts +72 -72
  72. package/dist/apps/management/routes/faucet.d.ts +6 -6
  73. package/dist/apps/management/routes/invitations.d.ts +134 -134
  74. package/dist/apps/management/routes/invite-links.d.ts +38 -38
  75. package/dist/apps/management/routes/me.d.ts +6 -6
  76. package/dist/apps/management/routes/members.d.ts +30 -30
  77. package/dist/apps/management/routes/orgs.d.ts +98 -98
  78. package/dist/apps/management/routes/projects.d.ts +20 -20
  79. package/dist/apps/management/routes/usage.d.ts +24 -24
  80. package/dist/apps/management/routes/verified-token-requests.d.ts +83 -83
  81. package/dist/apps/management/routes/webhooks.d.ts +158 -573
  82. package/dist/apps/management/routes/webhooks.d.ts.map +1 -1
  83. package/dist/apps/mpp/App.d.ts +56 -44
  84. package/dist/apps/mpp/App.d.ts.map +1 -1
  85. package/dist/apps/mpp/Mpp.d.ts +3 -0
  86. package/dist/apps/mpp/Mpp.d.ts.map +1 -1
  87. package/dist/apps/mpp/Mpp.js +3 -0
  88. package/dist/apps/mpp/Mpp.js.map +1 -1
  89. package/dist/apps/mpp/internal/Adapter.d.ts.map +1 -1
  90. package/dist/apps/mpp/internal/Adapter.js +9 -2
  91. package/dist/apps/mpp/internal/Adapter.js.map +1 -1
  92. package/dist/db/Db.d.ts +2 -2
  93. package/dist/db/Db.d.ts.map +1 -1
  94. package/dist/db/Db.js +17 -15
  95. package/dist/db/Db.js.map +1 -1
  96. package/dist/db/tables/sponsoredTransactions.d.ts.map +1 -1
  97. package/dist/db/tables/sponsoredTransactions.js +4 -1
  98. package/dist/db/tables/sponsoredTransactions.js.map +1 -1
  99. package/dist/db/tables/webhookSubscriptions.d.ts +56 -3
  100. package/dist/db/tables/webhookSubscriptions.d.ts.map +1 -1
  101. package/dist/db/tables/webhookSubscriptions.js +76 -3
  102. package/dist/db/tables/webhookSubscriptions.js.map +1 -1
  103. package/dist/internal/MetricSink.d.ts +112 -3
  104. package/dist/internal/MetricSink.d.ts.map +1 -1
  105. package/dist/internal/MetricSink.js +116 -3
  106. package/dist/internal/MetricSink.js.map +1 -1
  107. package/dist/internal/Schema.js +1 -1
  108. package/dist/internal/Schema.js.map +1 -1
  109. package/dist/internal/Tidx.d.ts +1 -3
  110. package/dist/internal/Tidx.d.ts.map +1 -1
  111. package/dist/internal/Tidx.js +1 -3
  112. package/dist/internal/Tidx.js.map +1 -1
  113. package/dist/internal/Viem.d.ts.map +1 -1
  114. package/dist/internal/Viem.js +2 -1
  115. package/dist/internal/Viem.js.map +1 -1
  116. package/dist/internal/WebhookDestination.d.ts +2 -0
  117. package/dist/internal/WebhookDestination.d.ts.map +1 -1
  118. package/dist/internal/WebhookDestination.js +11 -0
  119. package/dist/internal/WebhookDestination.js.map +1 -1
  120. package/dist/internal/WebhookTransfer.d.ts +55 -0
  121. package/dist/internal/WebhookTransfer.d.ts.map +1 -0
  122. package/dist/internal/WebhookTransfer.js +296 -0
  123. package/dist/internal/WebhookTransfer.js.map +1 -0
  124. package/dist/internal/Webhooks.d.ts +37 -0
  125. package/dist/internal/Webhooks.d.ts.map +1 -1
  126. package/dist/internal/Webhooks.js +87 -13
  127. package/dist/internal/Webhooks.js.map +1 -1
  128. package/dist/internal/funding/Provider.d.ts +4 -0
  129. package/dist/internal/funding/Provider.d.ts.map +1 -1
  130. package/dist/internal/funding/Provider.js +11 -7
  131. package/dist/internal/funding/Provider.js.map +1 -1
  132. package/dist/internal/funding/Reconciliation.d.ts.map +1 -1
  133. package/dist/internal/funding/Reconciliation.js +128 -0
  134. package/dist/internal/funding/Reconciliation.js.map +1 -1
  135. package/dist/internal/funding/providers/relay.d.ts +3 -0
  136. package/dist/internal/funding/providers/relay.d.ts.map +1 -1
  137. package/dist/internal/funding/providers/relay.js +12 -1
  138. package/dist/internal/funding/providers/relay.js.map +1 -1
  139. package/package.json +3 -3
  140. package/src/Client.test-d.ts +60 -5
  141. package/src/Webhooks.bench.ts +714 -0
  142. package/src/Webhooks.memory.bench.ts +250 -0
  143. package/src/Webhooks.test.ts +77 -6
  144. package/src/Webhooks.ts +83 -29
  145. package/src/apps/Zones.test.ts +5 -4
  146. package/src/apps/Zones.ts +9 -8
  147. package/src/apps/data/FxOracle.test.ts +18 -0
  148. package/src/apps/data/FxOracle.ts +28 -5
  149. package/src/apps/data/routes/activities.test.ts +19 -0
  150. package/src/apps/data/routes/activities.ts +34 -30
  151. package/src/apps/data/routes/balances.test.ts +119 -10
  152. package/src/apps/data/routes/balances.ts +154 -95
  153. package/src/apps/data/routes/blocks.test.ts +12 -6
  154. package/src/apps/data/routes/earn.test.ts +197 -0
  155. package/src/apps/data/routes/earn.ts +486 -44
  156. package/src/apps/data/routes/exchanges.test.ts +7 -6
  157. package/src/apps/data/routes/exchanges.ts +124 -117
  158. package/src/apps/data/routes/receipts.test.ts +24 -7
  159. package/src/apps/data/routes/receipts.ts +67 -50
  160. package/src/apps/data/routes/tokens.test.ts +51 -0
  161. package/src/apps/data/routes/transactions.test.ts +42 -0
  162. package/src/apps/data/routes/transactions.ts +44 -39
  163. package/src/apps/data/routes/transfers.test.ts +402 -24
  164. package/src/apps/data/routes/transfers.ts +708 -101
  165. package/src/apps/data/routes/valuation.test.ts +26 -0
  166. package/src/apps/data/routes/valuation.ts +6 -2
  167. package/src/apps/data/routes/webhooks.test.ts +414 -130
  168. package/src/apps/data/routes/webhooks.ts +603 -419
  169. package/src/apps/data/routes/zones.ts +6 -1
  170. package/src/apps/funding/routes/quotes.test.ts +1 -1
  171. package/src/apps/mpp/Mpp.test.ts +13 -1
  172. package/src/apps/mpp/Mpp.ts +8 -0
  173. package/src/apps/mpp/internal/Adapter.ts +10 -2
  174. package/src/db/Db.test.ts +40 -6
  175. package/src/db/Db.ts +33 -19
  176. package/src/db/tables/sponsoredTransactions.ts +9 -2
  177. package/src/db/tables/webhookSubscriptions.ts +116 -4
  178. package/src/internal/EarnVaults.test.ts +2 -2
  179. package/src/internal/MetricSink.test.ts +518 -0
  180. package/src/internal/MetricSink.ts +266 -9
  181. package/src/internal/Schema.ts +1 -1
  182. package/src/internal/Tidx.test.ts +4 -4
  183. package/src/internal/Tidx.ts +1 -3
  184. package/src/internal/Viem.test.ts +8 -0
  185. package/src/internal/Viem.ts +2 -1
  186. package/src/internal/WebhookDestination.test.ts +20 -0
  187. package/src/internal/WebhookDestination.ts +10 -0
  188. package/src/internal/WebhookTransfer.ts +426 -0
  189. package/src/internal/Webhooks.test.ts +137 -0
  190. package/src/internal/Webhooks.ts +131 -17
  191. package/src/internal/funding/Provider.test.ts +47 -7
  192. package/src/internal/funding/Provider.ts +12 -7
  193. package/src/internal/funding/Reconciliation.local.test.ts +3 -1
  194. package/src/internal/funding/Reconciliation.test.ts +1240 -0
  195. package/src/internal/funding/Reconciliation.ts +136 -0
  196. package/src/internal/funding/SourceObservation.test.ts +1 -0
  197. package/src/internal/funding/providers/relay.test.ts +1 -4
  198. package/src/internal/funding/providers/relay.ts +16 -1
@@ -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
  }),
@@ -198,7 +203,12 @@ export namespace schema {
198
203
  export const Query = z
199
204
  .strictObject({
200
205
  chainId: Schema.ChainIdQuery,
201
- 'valuation.currency': Schema.Denomination,
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
+ ),
202
212
  })
203
213
  .check(z.describe('Query parameters for reading one account token balance.'))
204
214
 
@@ -229,7 +239,7 @@ export function addresses(options: addresses.Options = {}) {
229
239
  }),
230
240
  OpenApi.describeRoute({
231
241
  description:
232
- '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.',
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.',
233
243
  operationId: 'getAddressBalances',
234
244
  responses: OpenApi.responses({
235
245
  errors: { 502: 'Could not read balance or token data from an upstream service.' },
@@ -269,26 +279,13 @@ export function addresses(options: addresses.Options = {}) {
269
279
  const verified = query.verified || query.currency !== undefined
270
280
  const feeEligible = query.feeEligible ?? false
271
281
 
272
- // The fee-token set powers both the per-row `feeEligible` flag (always)
273
- // and the optional `feeEligible=true` page restriction. It is memoized
274
- // per chain (a near-constant), so one read feeds the page, the count,
275
- // and the row enrichment. It is a best-effort enrichment: a lookup
276
- // failure omits `feeEligible` rather than failing the page — unless the
277
- // caller explicitly asked to filter by `feeEligible`, where the filter
278
- // can't be honored, so the failure surfaces as the declared `502`.
279
- const feeTokens = await FeeAmm.feeTokenSet(c, chainId).catch((cause) => {
280
- if (feeEligible) throw cause
281
- return undefined
282
- })
283
-
284
282
  const balances = await getAddressBalances(c, {
285
283
  address,
286
284
  chainId,
287
285
  currency: query.currency,
288
286
  cursor: query.cursor,
289
- denomination: query['valuation.currency'] ?? 'USD',
287
+ denomination: query['valuation.currency'],
290
288
  feeEligible,
291
- feeTokens,
292
289
  includeTotalCount: query.include.includes('totalCount'),
293
290
  limit: query.limit,
294
291
  oracle,
@@ -356,10 +353,28 @@ export function addresses(options: addresses.Options = {}) {
356
353
  const { address, token } = c.req.valid('param')
357
354
  const query = c.req.valid('query')
358
355
  const chainId = query.chainId ?? c.get('chainId')
356
+ const denomination = query['valuation.currency']
359
357
 
360
358
  try {
361
- const snapshot = await VerifiedTokens.snapshot(c, chainId)
362
- const [balance, resolvedToken] = await Promise.all([
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([
363
378
  Timing.time(c, 'balance_rpc', async () => {
364
379
  const result = await Actions.token.getBalance(c.get('getClient')(chainId), {
365
380
  account: address,
@@ -367,18 +382,10 @@ export function addresses(options: addresses.Options = {}) {
367
382
  })
368
383
  return result.amount
369
384
  }),
370
- Timing.time(c, 'token', () =>
371
- Tokens.resolveToken(c, { address: token, chainId, include: [], snapshot }),
372
- ),
385
+ FeeAmm.feeTokenSet(c, chainId).catch(() => undefined),
386
+ snapshotPromise,
387
+ resolvedTokenPromise,
373
388
  ])
374
- const denomination = query['valuation.currency'] ?? 'USD'
375
- const rates = await Valuation.ratesFor(c, {
376
- currencies: snapshot.byAddress.has(token) ? [resolvedToken.currency] : [],
377
- denomination,
378
- oracle,
379
- })
380
- const feeTokens = await FeeAmm.feeTokenSet(c, chainId).catch(() => undefined)
381
-
382
389
  return c.json(
383
390
  Response.validated(schema.getAddressBalance.Response, {
384
391
  amount: balance.toString(),
@@ -388,12 +395,16 @@ export function addresses(options: addresses.Options = {}) {
388
395
  formatted: core_Value.format(balance, resolvedToken.decimals),
389
396
  id: token,
390
397
  token: resolvedToken,
391
- valuation: Valuation.valuationFor({
392
- amount: balance,
393
- denomination,
394
- rates,
395
- token: snapshot.byAddress.get(token),
396
- }),
398
+ ...(denomination
399
+ ? {
400
+ valuation: Valuation.valuationFor({
401
+ amount: balance,
402
+ denomination,
403
+ rates,
404
+ token: snapshot.byAddress.get(token),
405
+ }),
406
+ }
407
+ : {}),
397
408
  }),
398
409
  200,
399
410
  )
@@ -430,7 +441,6 @@ async function getAddressBalances(
430
441
  currency,
431
442
  denomination,
432
443
  feeEligible,
433
- feeTokens,
434
444
  includeTotalCount,
435
445
  limit,
436
446
  oracle,
@@ -440,8 +450,9 @@ async function getAddressBalances(
440
450
  const cursor = options.cursor ? Cursor.decode(options.cursor, ['uint', 'address']) : undefined
441
451
  const offset = options.page !== undefined && options.page > 1 ? (options.page - 1) * limit : 0
442
452
 
443
- // Validate the requested denomination independently of the current page.
444
- 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 })
445
456
 
446
457
  // Build the optional `token IN (…)` restriction before hitting TIDX. The
447
458
  // verified/`currency` filter and the `feeEligible` filter each contribute a set
@@ -450,17 +461,32 @@ async function getAddressBalances(
450
461
  // intersection. If any active filter rules every token out, return an empty
451
462
  // page without a round trip. The snapshot is reused for the page enrichment
452
463
  // below.
453
- const snapshot = await VerifiedTokens.snapshot(c, chainId)
454
- 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
+ })
455
479
  const tokenFilter = allowed?.map((address) => `'${address}'`)
456
- if (tokenFilter?.length === 0)
480
+ if (tokenFilter?.length === 0) {
481
+ await snapshotPromise
457
482
  return {
458
483
  data: [],
459
484
  ...(includeTotalCount ? { meta: { totalCount: 0, totalCountCapped: false } } : {}),
460
485
  nextCursor: null,
461
486
  }
487
+ }
462
488
 
463
- const holdings = await Timing.time(c, 'balances_discovery', async () => {
489
+ const holdingsPromise = Timing.time(c, 'balances_discovery', async () => {
464
490
  const tokenIn = tokenFilter ? ` AND token IN (${tokenFilter.join(', ')})` : ''
465
491
 
466
492
  const result = await tidx.fetch({
@@ -481,36 +507,54 @@ async function getAddressBalances(
481
507
  })
482
508
  })
483
509
 
484
- 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) {
485
552
  return {
486
553
  data: [],
487
554
  ...(includeTotalCount ? { meta: { totalCount: 0, totalCountCapped: false } } : {}),
488
555
  nextCursor: null,
489
556
  }
490
-
491
- const client = c.get('getClient')(chainId)
492
- const corrected = await Timing.time(c, 'balances_rpc', async () => {
493
- // Concurrent reads enter viem's shared batch window and become one
494
- // deployless multicall. A failed read fails the request rather than serving
495
- // a stale or partial balance page.
496
- const results = await Promise.all(
497
- holdings.map(async (holding) => {
498
- const { amount } = await Actions.token.getBalance(client, {
499
- account: address,
500
- token: holding.address,
501
- })
502
- return amount === 0n ? undefined : { address: holding.address, amount: amount.toString() }
503
- }),
504
- )
505
- const rows = results.filter((holding) => holding !== undefined)
506
-
507
- rows.sort((a, b) => {
508
- const delta = BigInt(b.amount) - BigInt(a.amount)
509
- if (delta !== 0n) return delta > 0n ? 1 : -1
510
- return a.address < b.address ? -1 : a.address > b.address ? 1 : 0
511
- })
512
- return rows
513
- })
557
+ }
514
558
 
515
559
  const positioned = (() => {
516
560
  if (!cursor) return corrected.slice(offset)
@@ -528,7 +572,7 @@ async function getAddressBalances(
528
572
  key: (holding) => [holding.amount, holding.address],
529
573
  })
530
574
 
531
- if (page.rows.length === 0)
575
+ if (page.rows.length === 0) {
532
576
  return {
533
577
  data: [],
534
578
  ...(includeTotalCount
@@ -536,22 +580,38 @@ async function getAddressBalances(
536
580
  : {}),
537
581
  nextCursor: page.nextCursor,
538
582
  }
583
+ }
539
584
 
540
- const rates = await Valuation.ratesFor(c, {
541
- currencies: page.rows.flatMap((row) => {
542
- const held = snapshot.byAddress.get(row.address.toLowerCase())?.currency
543
- return held === undefined ? [] : [held]
544
- }),
545
- denomination,
546
- oracle,
547
- })
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)
548
595
 
549
596
  // Balance tokens retain their logo and verification metadata by default.
550
- const resolved = await Timing.time(c, 'tokens', () =>
597
+ const resolvedPromise = Timing.time(c, 'tokens', () =>
551
598
  Promise.all(
552
599
  page.rows.map(async (holding) => {
553
600
  try {
554
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
+ }
555
615
  const [logoUri, metadata] = await Promise.all([
556
616
  Timing.time(c, 'token_logo', () =>
557
617
  Tokens.getTokenLogo(c, { address: token, chainId }),
@@ -567,6 +627,7 @@ async function getAddressBalances(
567
627
  }),
568
628
  ),
569
629
  )
630
+ const [rates, resolved] = await Promise.all([ratesPromise, resolvedPromise])
570
631
 
571
632
  const data: z.output<typeof schema.getAddressBalances.Balance>[] = []
572
633
  for (const entry of resolved) {
@@ -591,12 +652,16 @@ async function getAddressBalances(
591
652
  symbol: entry.metadata.symbol,
592
653
  verified: snapshot.byAddress.has(entry.token),
593
654
  },
594
- valuation: Valuation.valuationFor({
595
- amount: BigInt(entry.amount),
596
- denomination,
597
- rates,
598
- token: snapshot.byAddress.get(entry.token),
599
- }),
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
+ : {}),
600
665
  })
601
666
  }
602
667
 
@@ -620,15 +685,9 @@ declare namespace getAddressBalances {
620
685
  /** Opaque keyset cursor anchoring the page; omit for the head page. */
621
686
  cursor?: string | undefined
622
687
  /** Currency the per-row valuations are denominated in (uppercase). */
623
- denomination: string
624
- /** 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. */
625
690
  feeEligible?: boolean | undefined
626
- /**
627
- * The chain's fee-token set, if resolved (best-effort): flags each row's
628
- * `feeEligible` and backs the `feeEligible` filter. Undefined when the lookup
629
- * failed, in which case `feeEligible` is omitted from rows.
630
- */
631
- feeTokens?: ReadonlySet<string> | undefined
632
691
  /** When true, include the corrected candidate count in response metadata. */
633
692
  includeTotalCount: boolean
634
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 () => {
@@ -88,6 +88,33 @@ describe('schema.getEarnVaults.Query', () => {
88
88
  })
89
89
  })
90
90
 
91
+ describe('schema.getEarnAddressPositions.Query', () => {
92
+ test('parses collection filters', () => {
93
+ const query = Earn.schema.getEarnAddressPositions.Query.parse({
94
+ limit: '5',
95
+ 'valuation.currency': 'usd',
96
+ verified: 'true',
97
+ })
98
+
99
+ expect(query.limit).toBe(5)
100
+ expect(query['valuation.currency']).toBe('USD')
101
+ expect(query.verified).toBe(true)
102
+ })
103
+
104
+ test('defaults the page size', () => {
105
+ expect(Earn.schema.getEarnAddressPositions.Query.parse({}).limit).toBe(10)
106
+ })
107
+
108
+ test('rejects vault-collection filters', () => {
109
+ expect(() =>
110
+ Earn.schema.getEarnAddressPositions.Query.parse({ capability: 'deposit' }),
111
+ ).toThrow()
112
+ expect(() =>
113
+ Earn.schema.getEarnAddressPositions.Query.parse({ asset: `0x${'aa'.repeat(20)}` }),
114
+ ).toThrow()
115
+ })
116
+ })
117
+
91
118
  describe('schema.getEarnVaultEarnings.Response', () => {
92
119
  test('enforces status-specific earnings fields', () => {
93
120
  const response = {
@@ -540,6 +567,144 @@ describe('GET /v1/earn/vaults/verified', () => {
540
567
  )
541
568
  })
542
569
 
570
+ describe('GET /v1/earn/addresses/:address/positions', () => {
571
+ test('returns chain selection errors', async () => {
572
+ const app = TestApp.create({ auth: false, cache })
573
+ const invalid = await app.request(
574
+ `/v1/earn/addresses/${TestEarn.positionAddress}/positions?chainId=abc`,
575
+ )
576
+ const unsupported = await app.request(
577
+ `/v1/earn/addresses/${TestEarn.positionAddress}/positions?chainId=999999`,
578
+ )
579
+
580
+ expect([
581
+ (await TestApp.json(invalid, Schema.ErrorResponse)).error.code,
582
+ (await TestApp.json(unsupported, Schema.ErrorResponse)).error.code,
583
+ ]).toStrictEqual(['chain_id_invalid', 'chain_id_unsupported'])
584
+ })
585
+
586
+ test('rejects a malformed account address', async () => {
587
+ const response = await TestApp.create({ auth: false, cache }).request(
588
+ '/v1/earn/addresses/not-an-address/positions',
589
+ )
590
+
591
+ expect(response.status).toBe(400)
592
+ expect(await TestApp.json(response, Schema.ErrorResponse)).toMatchObject({
593
+ error: { code: 'positions_invalid' },
594
+ })
595
+ })
596
+
597
+ test('returns an empty verified page without curated vaults', async () => {
598
+ const app = TestApp.create({ auth: false, cache })
599
+ const response = await app.request(
600
+ `/v1/earn/addresses/${TestEarn.positionAddress}/positions?verified=true`,
601
+ )
602
+
603
+ expect(response.status).toBe(200)
604
+ expect(
605
+ await TestApp.json(response, Earn.schema.getEarnAddressPositions.Response),
606
+ ).toStrictEqual({ data: [], nextCursor: null })
607
+ })
608
+
609
+ test.runIf(Runtime.get().mode === 'testnet')(
610
+ 'lists non-zero verified positions for an account',
611
+ async () => {
612
+ const db = TestApp.database()
613
+ // Valuation resolves through the curated verified list, so seed it.
614
+ await TestApp.verifiedSeed(db, TestEarn.chain.id)
615
+ await core_EarnVaults.upsert(db, TestEarn.verifiedVault)
616
+ await core_EarnVaults.upsert(db, {
617
+ ...TestEarn.verifiedVault,
618
+ description: 'Active-earnings Earn deployment.',
619
+ label: 'Active btPATHUSD Earn',
620
+ vaultAddress: TestEarn.activeEarningsVaultAddress,
621
+ })
622
+ const app = TestApp.create({ auth: false, cache, db, zones: [TestEarn.zone] })
623
+ const response = await app.request(
624
+ `/v1/earn/addresses/${TestEarn.activeEarningsAddress}/positions?chainId=${TestEarn.chain.id}&verified=true&valuation.currency=USD`,
625
+ )
626
+
627
+ expect(response.status).toBe(200)
628
+ const body = await TestApp.json(response, Earn.schema.getEarnAddressPositions.Response)
629
+ expect(body.data.every((position) => BigInt(position.shareAmount.amount) > 0n)).toBe(true)
630
+ const row = body.data.find(
631
+ (position) => position.vaultAddress === TestEarn.activeEarningsVaultAddress,
632
+ )
633
+ expect(row).toBeDefined()
634
+ if (!row) return
635
+ // Amounts render in their own token's display currency and precision.
636
+ expect(row.shareAmount.currency).toBe(row.shareToken.currency)
637
+ expect(row.shareAmount.decimals).toBe(row.shareToken.decimals)
638
+ expect(row.assetAmount.currency).toBe(row.assetToken.currency)
639
+ expect(row.assetAmount.decimals).toBe(row.assetToken.decimals)
640
+ // A USD-denominated asset values identically without consulting rates.
641
+ expect(row.valuation).toStrictEqual({ amount: row.assetAmount.formatted, currency: 'USD' })
642
+ expect(body.meta).toBeUndefined()
643
+ expect({
644
+ assetToken: row.assetToken.address,
645
+ formattedShape: /^\d+(\.\d+)?$/.test(row.assetAmount.formatted),
646
+ id: row.id,
647
+ sharesHeld: BigInt(row.shareAmount.amount) > 0n,
648
+ valued: BigInt(row.assetAmount.amount) > 0n,
649
+ vaultAddress: row.vaultAddress,
650
+ verified: row.verified,
651
+ }).toMatchInlineSnapshot(`
652
+ {
653
+ "assetToken": "0x20c0000000000000000000000000000000000001",
654
+ "formattedShape": true,
655
+ "id": "0x3e4405801a1bee1b58394228ea0b44b667e222fc",
656
+ "sharesHeld": true,
657
+ "valued": true,
658
+ "vaultAddress": "0x3e4405801a1bee1b58394228ea0b44b667e222fc",
659
+ "verified": true,
660
+ }
661
+ `)
662
+ },
663
+ 60_000,
664
+ )
665
+
666
+ test.runIf(Runtime.get().mode === 'testnet')(
667
+ 'discovers held vaults from indexed share balances',
668
+ async () => {
669
+ const app = TestApp.create({ auth: false, cache, zones: [TestEarn.zone] })
670
+ const response = await app.request(
671
+ `/v1/earn/addresses/${TestEarn.activeEarningsAddress}/positions?chainId=${TestEarn.chain.id}`,
672
+ )
673
+
674
+ expect(response.status).toBe(200)
675
+ const body = await TestApp.json(response, Earn.schema.getEarnAddressPositions.Response)
676
+ expect(body.data.map((position) => position.vaultAddress)).toContain(
677
+ TestEarn.activeEarningsVaultAddress,
678
+ )
679
+ expect(
680
+ body.data.every(
681
+ (position) =>
682
+ position.id === position.vaultAddress &&
683
+ !position.verified &&
684
+ /^\d+$/.test(position.assetAmount.amount) &&
685
+ BigInt(position.shareAmount.amount) > 0n &&
686
+ position.assetToken.address.startsWith('0x20c0') &&
687
+ position.shareToken.address.startsWith('0x20c0'),
688
+ ),
689
+ ).toBe(true)
690
+
691
+ // An address-keyset cursor excludes every vault at or before it.
692
+ const cursor = Cursor.encode([TestEarn.activeEarningsVaultAddress])
693
+ const paged = await app.request(
694
+ `/v1/earn/addresses/${TestEarn.activeEarningsAddress}/positions?chainId=${TestEarn.chain.id}&cursor=${encodeURIComponent(cursor)}`,
695
+ )
696
+ expect(paged.status).toBe(200)
697
+ const pagedBody = await TestApp.json(paged, Earn.schema.getEarnAddressPositions.Response)
698
+ expect(
699
+ pagedBody.data.every(
700
+ (position) => position.vaultAddress > TestEarn.activeEarningsVaultAddress,
701
+ ),
702
+ ).toBe(true)
703
+ },
704
+ 90_000,
705
+ )
706
+ })
707
+
543
708
  describe('GET /v1/earn/vaults/:vaultId', () => {
544
709
  test('rejects a malformed vault address', async () => {
545
710
  const app = TestApp.create({ auth: false, cache })
@@ -1375,6 +1540,7 @@ describe('OpenAPI', () => {
1375
1540
 
1376
1541
  expect(Object.keys(spec.paths)).toEqual(
1377
1542
  expect.arrayContaining([
1543
+ '/v1/earn/addresses/{address}/positions',
1378
1544
  '/v1/earn/vaults',
1379
1545
  '/v1/earn/vaults/verified',
1380
1546
  '/v1/earn/vaults/{vaultId}',
@@ -1446,6 +1612,37 @@ describe('OpenAPI', () => {
1446
1612
  expect(vaultId?.schema?.examples).toStrictEqual([TestEarn.mainnetVaultAddress])
1447
1613
  })
1448
1614
 
1615
+ test('documents address-position inputs and responses', async () => {
1616
+ const app = TestApp.create({ auth: false, cache })
1617
+ const spec = (await (await app.request('/openapi.json')).json()) as OpenApiDocument
1618
+ const operation = spec.paths['/v1/earn/addresses/{address}/positions']?.get
1619
+ const address = operation?.parameters?.find((parameter) => parameter.name === 'address')
1620
+ const denomination = operation?.parameters?.find(
1621
+ (parameter) => parameter.name === 'valuation.currency',
1622
+ )
1623
+ const verified = operation?.parameters?.find((parameter) => parameter.name === 'verified')
1624
+ const response = JSON.stringify(operation?.responses)
1625
+
1626
+ expect(operation?.description).toContain('currently holds shares')
1627
+ expect(address?.schema?.examples).toStrictEqual([TestEarn.positionAddress])
1628
+ expect(denomination?.description).toContain('nominal value')
1629
+ expect(verified?.description).toContain('registry-curated')
1630
+ expect(verified?.schema?.examples).toStrictEqual([true])
1631
+ expect(response).toContain('"chain_id_invalid"')
1632
+ expect(response).toContain('"chain_id_unsupported"')
1633
+ expect(response).toContain('"positions_invalid"')
1634
+ expect(response).toContain('"query_invalid"')
1635
+ expect(response).toContain('Stable resource ID for this position')
1636
+ expect(response).toContain('Current asset value of the held shares')
1637
+ expect(response).toContain('"assetAmount"')
1638
+ expect(response).toContain('"shareAmount"')
1639
+ expect(response).toContain('"valuation"')
1640
+ expect(response).toContain('"vaultAddress"')
1641
+ expect(response).toContain('"nextCursor"')
1642
+ // Token references embed the trimmed token object, not a bare address.
1643
+ expect(response).toContain('"symbol"')
1644
+ })
1645
+
1449
1646
  test('documents vault-earnings variants', async () => {
1450
1647
  const app = TestApp.create({ auth: false, cache })
1451
1648
  const spec = (await (await app.request('/openapi.json')).json()) as OpenApiDocument