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
@@ -4,6 +4,8 @@ import * as Ttl from '../../internal/Ttl.js'
4
4
 
5
5
  /** A pluggable fiat FX rate oracle consumed by the valuation endpoint. */
6
6
  export type Oracle = {
7
+ /** Returns the cache-key suffix for the oracle's current publication window. */
8
+ cacheKey?: (() => string) | undefined
7
9
  /** Oracle identifier, surfaced as valuation rate provenance (e.g. `ecb`). */
8
10
  name: string
9
11
  /** Fetches the oracle's current rate set. */
@@ -34,6 +36,7 @@ export const ecbUrl = 'https://www.ecb.europa.eu/stats/eurofxref/eurofxref-daily
34
36
  */
35
37
  export function from(oracle: from.Value): Oracle {
36
38
  return {
39
+ ...(oracle.cacheKey ? { cacheKey: oracle.cacheKey } : {}),
37
40
  name: oracle.name,
38
41
  rates: async () => {
39
42
  try {
@@ -48,19 +51,17 @@ export function from(oracle: from.Value): Oracle {
48
51
 
49
52
  export declare namespace from {
50
53
  /** Oracle definition accepted by {@link from}. */
51
- type Value = Pick<Oracle, 'name' | 'rates'> & {
54
+ type Value = Pick<Oracle, 'cacheKey' | 'name' | 'rates'> & {
52
55
  /** Cache lifetime for a fetched rate set, in milliseconds. @default `Ttl.hours(1)` */
53
56
  ttl?: number | undefined
54
57
  }
55
58
  }
56
59
 
57
- /**
58
- * European Central Bank euro reference rates: one EUR-based set covering ~30
59
- * currencies, published once per working day.
60
- */
60
+ /** European Central Bank reference rates, cached for one day to match their publication cadence. */
61
61
  export function ecb(options: ecb.Options = {}): Oracle {
62
62
  const { fetch = globalThis.fetch, url = ecbUrl } = options
63
63
  return from({
64
+ cacheKey: () => `ecb:${ecbPublication(new Date())}`,
64
65
  name: 'ecb',
65
66
  rates: async () => {
66
67
  const response = await fetch(url, {
@@ -90,9 +91,31 @@ export function ecb(options: ecb.Options = {}): Oracle {
90
91
  if (!asOf || rates['USD'] === undefined) throw new ParseError()
91
92
  return { asOf: `${asOf}T00:00:00.000Z`, base: 'EUR', rates }
92
93
  },
94
+ ttl: Ttl.days(1),
93
95
  })
94
96
  }
95
97
 
98
+ const ecbTime = new Intl.DateTimeFormat('en-CA', {
99
+ day: '2-digit',
100
+ hour: '2-digit',
101
+ hourCycle: 'h23',
102
+ month: '2-digit',
103
+ timeZone: 'Europe/Brussels',
104
+ year: 'numeric',
105
+ })
106
+
107
+ function ecbPublication(now: Date) {
108
+ const parts = Object.fromEntries(
109
+ ecbTime.formatToParts(now).map((part) => [part.type, part.value]),
110
+ )
111
+ // Rotate one hour after the ECB's usual 16:00 CET publication time.
112
+ const date = Number(parts['hour']) >= 17 ? now : new Date(now.getTime() - Ttl.days(1))
113
+ const publication = Object.fromEntries(
114
+ ecbTime.formatToParts(date).map((part) => [part.type, part.value]),
115
+ )
116
+ return `${publication['year']}-${publication['month']}-${publication['day']}`
117
+ }
118
+
96
119
  export declare namespace ecb {
97
120
  /** Options for the ECB oracle. */
98
121
  type Options = {
@@ -328,6 +328,25 @@ describe('GET /addresses/:address/activities', () => {
328
328
  `)
329
329
  })
330
330
 
331
+ test.skipIf(runtime.mode !== 'localnet')('omits activity valuations by default', async () => {
332
+ const response = await client.v1.transactions[':transactionHash'].activities.$get(
333
+ {
334
+ param: { transactionHash: runtime.fixtures!.faucetTransactionHashes[0]! },
335
+ query: {},
336
+ },
337
+ TestApp.auth,
338
+ )
339
+ const body = await TestApp.json(response, Activities.schema.getTransactionActivities.Response)
340
+ expect(response.status).toBe(200)
341
+
342
+ const amounts = body.data.flatMap((item) =>
343
+ item.type === 'transfer' || item.type === 'mint' ? [item.data.sourceAmount] : [],
344
+ )
345
+ expect(amounts.length).toBeGreaterThan(0)
346
+ for (const amount of amounts) expect('valuation' in amount).toBe(false)
347
+ expect(response.headers.get('server-timing')).not.toContain('valuation_rates;dur=')
348
+ })
349
+
331
350
  test('returns every item at the root by default (no group entries)', async () => {
332
351
  const body = await activities(accounts.channel)
333
352
  expect(body.data.every((entry) => entry.type !== 'group')).toBe(true)
@@ -1853,7 +1853,7 @@ export function addresses(options: addresses.Options = {}) {
1853
1853
  const { address } = c.req.valid('param')
1854
1854
  const query = c.req.valid('query')
1855
1855
 
1856
- // Cursor pages carry current valuations and curated verification data.
1856
+ // Cursor pages can carry current valuations and curated verification data.
1857
1857
  if (query.cursor !== undefined) Cache.setPolicy(c, Cache.policies.metadata)
1858
1858
 
1859
1859
  try {
@@ -1884,7 +1884,7 @@ export function addresses(options: addresses.Options = {}) {
1884
1884
  // Build immutable token and structured amount copies from RPC metadata.
1885
1885
  const { items, rates } = await enrichTokens(c, {
1886
1886
  chainId,
1887
- denomination: query['valuation.currency'] ?? 'USD',
1887
+ denomination: query['valuation.currency'],
1888
1888
  include: query.include,
1889
1889
  items: unenrichedItems,
1890
1890
  oracle,
@@ -1964,7 +1964,7 @@ export function transactions(options: transactions.Options = {}) {
1964
1964
  }),
1965
1965
  Cache.response<App.Environment>({
1966
1966
  // Default to no-store so a 404 does not mask the eventual mined result.
1967
- // Successful responses use the metadata policy for current valuations.
1967
+ // Successful responses use the metadata policy for optional valuations.
1968
1968
  cacheControl: Cache.policies.noStore,
1969
1969
  name: 'tempo-api:transactions:v1',
1970
1970
  key: (c) => Cache.urlKey(c, schema.getTransactionActivities.Query),
@@ -2012,13 +2012,13 @@ export function transactions(options: transactions.Options = {}) {
2012
2012
  status: 404,
2013
2013
  })
2014
2014
 
2015
- // Mined activity still carries current valuations and verification data.
2015
+ // Mined activity can carry current valuations and verification data.
2016
2016
  Cache.setPolicy(c, Cache.policies.metadata)
2017
2017
 
2018
2018
  // Build immutable token and structured amount copies from RPC metadata.
2019
2019
  const { items, rates } = await enrichTokens(c, {
2020
2020
  chainId,
2021
- denomination: query['valuation.currency'] ?? 'USD',
2021
+ denomination: query['valuation.currency'],
2022
2022
  include: query.include,
2023
2023
  items: unenrichedItems,
2024
2024
  oracle,
@@ -4088,27 +4088,27 @@ async function enrichTokens(
4088
4088
  const { chainId, denomination, include, items, oracle } = options
4089
4089
  const addresses = new Set<Address.Address>()
4090
4090
  for (const item of items) collectTokens(item, addresses)
4091
- // Valuation keys off the curated snapshot; a snapshot failure degrades the
4092
- // values to `null` rather than failing the feed.
4091
+ const includeLogoUri = include.includes('token.logoUri')
4092
+ const includeVerified = include.includes('token.verified')
4093
+ // Curated data is needed only for requested valuation or token fields.
4093
4094
  const snapshot =
4094
- addresses.size > 0
4095
+ addresses.size > 0 && (denomination !== undefined || includeLogoUri || includeVerified)
4095
4096
  ? await VerifiedTokens.snapshot(c, chainId).catch(() => undefined)
4096
4097
  : undefined
4097
- const rates = await Valuation.ratesFor(c, {
4098
- currencies: snapshot
4099
- ? [...addresses].flatMap((address) => {
4100
- const held = snapshot.byAddress.get(address)?.currency
4101
- return held === undefined ? [] : [held]
4102
- })
4103
- : [],
4104
- denomination,
4105
- oracle,
4106
- })
4098
+ const rates = denomination
4099
+ ? await Valuation.ratesFor(c, {
4100
+ currencies: snapshot
4101
+ ? [...addresses].flatMap((address) => {
4102
+ const held = snapshot.byAddress.get(address)?.currency
4103
+ return held === undefined ? [] : [held]
4104
+ })
4105
+ : [],
4106
+ denomination,
4107
+ oracle,
4108
+ })
4109
+ : undefined
4107
4110
  if (addresses.size === 0) return { items: items as Item[], rates }
4108
4111
 
4109
- const includeLogoUri = include.includes('token.logoUri')
4110
- const includeVerified = include.includes('token.verified')
4111
-
4112
4112
  const resolved = await Timing.time(c, 'tokens', () =>
4113
4113
  Promise.all(
4114
4114
  [...addresses].map(async (address) => {
@@ -4145,7 +4145,7 @@ declare namespace enrichTokens {
4145
4145
  type Options = {
4146
4146
  chainId: z.output<typeof Schema.ChainId>
4147
4147
  /** Currency the amount valuations are denominated in (uppercase). */
4148
- denomination: string
4148
+ denomination?: string | undefined
4149
4149
  include: readonly z.output<typeof schema.Include>[]
4150
4150
  items: readonly UnenrichedItem[]
4151
4151
  /** FX rate oracle backing amount valuation. */
@@ -4173,13 +4173,17 @@ function enrichItem(
4173
4173
  currency: meta.currency,
4174
4174
  decimals: meta.decimals,
4175
4175
  }),
4176
- // Valued via the curated display currency, never the on-chain metadata.
4177
- valuation: Valuation.valuationFor({
4178
- amount: BigInt(t.amount),
4179
- denomination: valuation.denomination,
4180
- rates: valuation.rates,
4181
- token: valuation.snapshot?.byAddress.get(t.address.toLowerCase()),
4182
- }),
4176
+ ...(valuation.denomination
4177
+ ? {
4178
+ // Valued via the curated display currency, never the on-chain metadata.
4179
+ valuation: Valuation.valuationFor({
4180
+ amount: BigInt(t.amount),
4181
+ denomination: valuation.denomination,
4182
+ rates: valuation.rates,
4183
+ token: valuation.snapshot?.byAddress.get(t.address.toLowerCase()),
4184
+ }),
4185
+ }
4186
+ : {}),
4183
4187
  }
4184
4188
  data[key] = {
4185
4189
  address: t.address,
@@ -4198,7 +4202,7 @@ declare namespace enrichItem {
4198
4202
  /** Valuation inputs threaded from {@link enrichTokens}. */
4199
4203
  type Valuation = {
4200
4204
  /** Currency the amount valuations are denominated in (uppercase). */
4201
- denomination: string
4205
+ denomination?: string | undefined
4202
4206
  /** Oracle rate set, when loaded. */
4203
4207
  rates: FxOracle.RateSet | undefined
4204
4208
  /** Compiled verified-token snapshot, when available. */
@@ -297,8 +297,8 @@ describe('GET /addresses/:address/balances', () => {
297
297
  currency: 'USD',
298
298
  decimals: 6,
299
299
  logoUri: 'https://example.com/pathusd.svg',
300
- name: 'PathUSD',
301
- symbol: 'pathUSD',
300
+ name: 'Curated PathUSD',
301
+ symbol: 'curatedUSD',
302
302
  },
303
303
  ],
304
304
  })
@@ -321,9 +321,49 @@ describe('GET /addresses/:address/balances', () => {
321
321
  const body = await TestApp.json(response, Balances.schema.getAddressBalances.Response)
322
322
  expect(response.status).toBe(200)
323
323
  expect(body.data.length).toBeGreaterThan(0)
324
+ expect(response.headers.get('server-timing')).not.toContain('token_logo;dur=')
325
+ expect(response.headers.get('server-timing')).not.toContain('token_metadata;dur=')
324
326
  for (const balance of body.data) expect(balance.token.verified).toBe(true)
325
327
  const pathUsd = body.data.find((balance) => balance.token.address === TestApp.tokenWithHolders)
326
- expect(pathUsd?.token.logoUri).toMatch(/^http/)
328
+ expect(pathUsd?.token).toMatchObject({
329
+ logoUri: 'https://example.com/pathusd.svg',
330
+ name: 'Curated PathUSD',
331
+ symbol: 'curatedUSD',
332
+ })
333
+ })
334
+
335
+ test('includes uploaded logos for verified tokens without a logo URI', async () => {
336
+ const logoDb = TestApp.database()
337
+ await TestApp.verifiedSeed(logoDb, runtime.chainId, {
338
+ tokens: [
339
+ {
340
+ address: TestApp.tokenWithHolders,
341
+ currency: 'USD',
342
+ decimals: 6,
343
+ name: 'Curated PathUSD',
344
+ symbol: 'curatedUSD',
345
+ },
346
+ ],
347
+ })
348
+ const client = app({ db: logoDb })
349
+ const holder = await holderWithBalance
350
+
351
+ const response = await client.v1.addresses[':address'].balances.$get(
352
+ {
353
+ param: { address: holder },
354
+ query: { limit: '20', verified: 'true' },
355
+ },
356
+ TestApp.auth,
357
+ )
358
+ const body = await TestApp.json(response, Balances.schema.getAddressBalances.Response)
359
+ const pathUsd = body.data.find((balance) => balance.token.address === TestApp.tokenWithHolders)
360
+
361
+ expect(response.status).toBe(200)
362
+ expect(response.headers.get('server-timing')).toContain('token_logo;dur=')
363
+ expect(response.headers.get('server-timing')).not.toContain('token_metadata;dur=')
364
+ expect(pathUsd?.token.logoUri).toBe(
365
+ `http://localhost/assets/${runtime.chainId}/icons/${TestApp.tokenWithHolders}`,
366
+ )
327
367
  })
328
368
 
329
369
  test('narrows balances by currency and retains verification', async () => {
@@ -360,7 +400,23 @@ describe('GET /addresses/:address/balances', () => {
360
400
  for (const balance of body.data) expect(typeof balance.feeEligible).toBe('boolean')
361
401
  })
362
402
 
363
- test('values each verified holding in the default denomination', async () => {
403
+ test('skips the fee-token lookup when an address has no holdings', async () => {
404
+ const client = app()
405
+ const response = await client.v1.addresses[':address'].balances.$get(
406
+ {
407
+ param: { address: privateKeyToAccount(generatePrivateKey()).address },
408
+ query: {},
409
+ },
410
+ TestApp.auth,
411
+ )
412
+ const body = await TestApp.json(response, Balances.schema.getAddressBalances.Response)
413
+
414
+ expect(response.status).toBe(200)
415
+ expect(body.data).toEqual([])
416
+ expect(response.headers.get('server-timing')).not.toContain('fee_token_set;dur=')
417
+ })
418
+
419
+ test('omits valuations unless a denomination is requested', async () => {
364
420
  await TestApp.verifiedSeed(db, runtime.chainId)
365
421
  const client = app()
366
422
  const holder = await holderWithBalance
@@ -371,12 +427,8 @@ describe('GET /addresses/:address/balances', () => {
371
427
  )
372
428
  const body = await TestApp.json(response, Balances.schema.getAddressBalances.Response)
373
429
  expect(response.status).toBe(200)
374
-
375
- // The guaranteed pathUSD holding is curated USD, so its USD value is the
376
- // holding itself; unverified holdings are never valued.
377
- const pathUsd = body.data.find((entry) => entry.token.address === TestApp.tokenWithHolders)
378
- expect(pathUsd?.valuation).toEqual({ amount: pathUsd!.formatted, currency: 'USD' })
379
- for (const entry of body.data) if (!entry.token.verified) expect(entry.valuation).toBeNull()
430
+ expect(response.headers.get('server-timing')).not.toContain('valuation_rates;dur=')
431
+ for (const entry of body.data) expect('valuation' in entry).toBe(false)
380
432
  })
381
433
 
382
434
  test('values holdings in a requested denomination', async () => {
@@ -444,6 +496,26 @@ describe('GET /addresses/:address/balances', () => {
444
496
  expect(stable.error.code).toMatchInlineSnapshot(`"query_invalid"`)
445
497
  })
446
498
 
499
+ test('rejects unsupported denominations before balance RPC failures', async () => {
500
+ const client = app({
501
+ fx: { oracle: fixed },
502
+ rpc: { url: () => 'http://127.0.0.1:1' },
503
+ })
504
+ const holder = await holderWithBalance
505
+
506
+ const response = await client.v1.addresses[':address'].balances.$get(
507
+ {
508
+ param: { address: holder },
509
+ query: { 'valuation.currency': 'JPY' },
510
+ },
511
+ TestApp.auth,
512
+ )
513
+ const body = await TestApp.json(response, Schema.ErrorResponse)
514
+
515
+ expect(response.status).toMatchInlineSnapshot(`400`)
516
+ expect(body.error.code).toMatchInlineSnapshot(`"query_invalid"`)
517
+ })
518
+
447
519
  test('restricts the page to fee tokens when feeEligible=true', async () => {
448
520
  const client = app()
449
521
  const holder = await holderWithBalance
@@ -489,7 +561,44 @@ describe('GET /v1/addresses/:address/balances/:token', () => {
489
561
  expect(body.token.address).toBe(TestApp.tokenWithHolders)
490
562
  expect(body.amount).toMatch(/^\d+$/)
491
563
  expect(core_Value.from(body.formatted, body.decimals)).toBe(BigInt(body.amount))
564
+ expect('valuation' in body).toBe(false)
565
+ expect(response.headers.get('server-timing')).not.toContain('valuation_rates;dur=')
566
+ })
567
+
568
+ test('values one balance when a denomination is requested', async () => {
569
+ const client = app()
570
+ const holder = await holderWithBalance
571
+ const response = await client.v1.addresses[':address'].balances[':token'].$get(
572
+ {
573
+ param: { address: holder, token: TestApp.tokenWithHolders },
574
+ query: { 'valuation.currency': 'USD' },
575
+ },
576
+ TestApp.auth,
577
+ )
578
+ const body = await TestApp.json(response, Balances.schema.getAddressBalance.Response)
579
+
580
+ expect(response.status).toBe(200)
492
581
  expect(body.valuation).toEqual({ amount: body.formatted, currency: 'USD' })
582
+ expect(response.headers.get('server-timing')).toContain('valuation_rates;dur=')
583
+ })
584
+
585
+ test('rejects an unsupported denomination before a balance RPC failure', async () => {
586
+ const client = app({
587
+ fx: { oracle: fixed },
588
+ rpc: { url: () => 'http://127.0.0.1:1' },
589
+ })
590
+ const holder = await holderWithBalance
591
+ const response = await client.v1.addresses[':address'].balances[':token'].$get(
592
+ {
593
+ param: { address: holder, token: TestApp.tokenWithHolders },
594
+ query: { 'valuation.currency': 'JPY' },
595
+ },
596
+ TestApp.auth,
597
+ )
598
+ const body = await TestApp.json(response, Schema.ErrorResponse)
599
+
600
+ expect(response.status).toMatchInlineSnapshot(`400`)
601
+ expect(body.error.code).toMatchInlineSnapshot(`"query_invalid"`)
493
602
  })
494
603
 
495
604
  test('returns zero for a token the account does not hold', async () => {