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
@@ -18,7 +18,12 @@ async function document(app: { request: (path: string) => Promise<Response> | Re
18
18
  string,
19
19
  Partial<Record<'get' | 'post', { operationId: string; tags?: readonly string[] | undefined }>>
20
20
  >
21
- tags: readonly { description: string; name: string }[]
21
+ tags: readonly {
22
+ description: string
23
+ name: string
24
+ 'x-displayName'?: string | undefined
25
+ 'x-pagePath'?: string | undefined
26
+ }[]
22
27
  webhooks?: Record<string, unknown> | undefined
23
28
  'x-tagGroups'?: readonly { name: string; tags: readonly string[] }[] | undefined
24
29
  }
@@ -294,7 +299,6 @@ describe('funding()', () => {
294
299
  '/v1/funding/chains',
295
300
  '/v1/funding/deposit-addresses',
296
301
  '/v1/funding/deposit-addresses/{id}',
297
- '/v1/funding/deposit-addresses/{id}/deposits',
298
302
  '/v1/funding/deposit-addresses/{id}/reconcile',
299
303
  '/v1/funding/deposits',
300
304
  '/v1/funding/deposits/{id}',
@@ -311,7 +315,7 @@ describe('funding()', () => {
311
315
  expect(spec['x-tagGroups']).toEqual([
312
316
  {
313
317
  name: 'Funding & Bridge API',
314
- tags: ['Chains', 'Deposit Addresses', 'Providers', 'Quotes', 'Transfers'],
318
+ tags: ['Chains', 'Deposit Addresses', 'Providers', 'Quotes', 'Funding Transfers'],
315
319
  },
316
320
  ])
317
321
  expect(
@@ -325,13 +329,12 @@ describe('funding()', () => {
325
329
  'reconcileFundingDepositAddress',
326
330
  'getFundingDepositAddress',
327
331
  'getFundingDeposit',
328
- 'listFundingDepositAddressDeposits',
329
332
  'listFundingDeposits',
330
333
  ])
331
334
  expect((await app.request('/v1/tokens')).status).toBe(404)
332
335
  })
333
336
 
334
- test('maps operations to five tags and reuses Transfers across groups', async () => {
337
+ test('keeps data and funding transfers separate with the same display name', async () => {
335
338
  const app = App.create({ auth: false, db: TestApp.database() })
336
339
  .route('/', data())
337
340
  .route('/', funding())
@@ -344,9 +347,10 @@ describe('funding()', () => {
344
347
  expect({
345
348
  chains: operationTag('/v1/funding/chains', 'get'),
346
349
  depositAddress: operationTag('/v1/funding/deposit-addresses', 'post'),
347
- deposits: operationTag('/v1/funding/deposit-addresses/{id}/deposits', 'get'),
350
+ deposits: operationTag('/v1/funding/deposits', 'get'),
348
351
  providers: operationTag('/v1/funding/providers', 'get'),
349
352
  quotes: operationTag('/v1/funding/quotes', 'get'),
353
+ tokenTransfers: operationTag('/v1/transfers', 'get'),
350
354
  transfers: operationTag('/v1/funding/transfers', 'get'),
351
355
  vault: operationTag('/v1/funding/transfers/vault', 'post'),
352
356
  zone: operationTag('/v1/funding/transfers/zone', 'post'),
@@ -356,17 +360,42 @@ describe('funding()', () => {
356
360
  deposits: ['Deposit Addresses'],
357
361
  providers: ['Providers'],
358
362
  quotes: ['Quotes'],
359
- transfers: ['Transfers'],
360
- vault: ['Transfers'],
361
- zone: ['Transfers'],
363
+ tokenTransfers: ['Transfers'],
364
+ transfers: ['Funding Transfers'],
365
+ vault: ['Funding Transfers'],
366
+ zone: ['Funding Transfers'],
362
367
  })
363
368
 
364
369
  const groups = Object.fromEntries(
365
370
  (spec['x-tagGroups'] ?? []).map((group) => [group.name, group.tags]),
366
371
  )
367
372
  expect(groups['Data API']).toContain('Transfers')
368
- expect(groups['Funding & Bridge API']).toContain('Transfers')
373
+ expect(groups['Data API']).not.toContain('Funding Transfers')
374
+ expect(groups['Funding & Bridge API']).toContain('Funding Transfers')
375
+ expect(groups['Funding & Bridge API']).not.toContain('Transfers')
369
376
  expect(spec.tags.filter((tag) => tag.name === 'Transfers')).toHaveLength(1)
377
+ expect(spec.tags.filter((tag) => tag.name === 'Funding Transfers')).toEqual([
378
+ {
379
+ description: 'Inbound funding transfers.',
380
+ name: 'Funding Transfers',
381
+ 'x-displayName': 'Transfers',
382
+ 'x-pagePath': 'funding/transfers',
383
+ },
384
+ ])
385
+ expect(
386
+ Object.fromEntries(
387
+ (groups['Funding & Bridge API'] ?? []).map((name) => {
388
+ const tag = spec.tags.find((tag) => tag.name === name)
389
+ return [tag?.['x-displayName'] ?? tag?.name, tag?.['x-pagePath']]
390
+ }),
391
+ ),
392
+ ).toEqual({
393
+ Chains: 'funding/chains',
394
+ 'Deposit Addresses': 'funding/deposit-addresses',
395
+ Providers: 'funding/providers',
396
+ Quotes: 'funding/quotes',
397
+ Transfers: 'funding/transfers',
398
+ })
370
399
  })
371
400
  })
372
401
 
@@ -41,6 +41,24 @@ describe('from', () => {
41
41
  })
42
42
 
43
43
  describe('ecb', () => {
44
+ test('caches the daily feed for one day', () => {
45
+ expect(FxOracle.ecb({ url: feedUrl('1.25') }).ttl).toMatchInlineSnapshot(`86400000`)
46
+ })
47
+
48
+ test('rotates the cache key after the daily publication window', () => {
49
+ vi.useFakeTimers()
50
+ try {
51
+ const oracle = FxOracle.ecb({ url: feedUrl('1.25') })
52
+
53
+ vi.setSystemTime('2026-01-02T15:59:00.000Z')
54
+ expect(oracle.cacheKey?.()).toMatchInlineSnapshot(`"ecb:2026-01-01"`)
55
+ vi.setSystemTime('2026-01-02T16:00:00.000Z')
56
+ expect(oracle.cacheKey?.()).toMatchInlineSnapshot(`"ecb:2026-01-02"`)
57
+ } finally {
58
+ vi.useRealTimers()
59
+ }
60
+ })
61
+
44
62
  test('fetches and parses the daily reference rates', async () => {
45
63
  const rates = await FxOracle.ecb({ url: feedUrl('1.25') }).rates()
46
64
  expect(rates).toMatchInlineSnapshot(`
@@ -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. */
@@ -3,6 +3,7 @@
3
3
  import { Schema } from 'tapimo'
4
4
  import { FxOracle } from 'tapimo/apps'
5
5
  import { Value as core_Value } from 'ox'
6
+ import { generatePrivateKey, privateKeyToAccount } from 'viem/accounts'
6
7
  import { Actions } from 'viem/tempo'
7
8
 
8
9
  import * as TestApp from '../../../../test/App.js'
@@ -296,8 +297,8 @@ describe('GET /addresses/:address/balances', () => {
296
297
  currency: 'USD',
297
298
  decimals: 6,
298
299
  logoUri: 'https://example.com/pathusd.svg',
299
- name: 'PathUSD',
300
- symbol: 'pathUSD',
300
+ name: 'Curated PathUSD',
301
+ symbol: 'curatedUSD',
301
302
  },
302
303
  ],
303
304
  })
@@ -320,9 +321,49 @@ describe('GET /addresses/:address/balances', () => {
320
321
  const body = await TestApp.json(response, Balances.schema.getAddressBalances.Response)
321
322
  expect(response.status).toBe(200)
322
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=')
323
326
  for (const balance of body.data) expect(balance.token.verified).toBe(true)
324
327
  const pathUsd = body.data.find((balance) => balance.token.address === TestApp.tokenWithHolders)
325
- 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
+ )
326
367
  })
327
368
 
328
369
  test('narrows balances by currency and retains verification', async () => {
@@ -359,7 +400,23 @@ describe('GET /addresses/:address/balances', () => {
359
400
  for (const balance of body.data) expect(typeof balance.feeEligible).toBe('boolean')
360
401
  })
361
402
 
362
- 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 () => {
363
420
  await TestApp.verifiedSeed(db, runtime.chainId)
364
421
  const client = app()
365
422
  const holder = await holderWithBalance
@@ -370,12 +427,8 @@ describe('GET /addresses/:address/balances', () => {
370
427
  )
371
428
  const body = await TestApp.json(response, Balances.schema.getAddressBalances.Response)
372
429
  expect(response.status).toBe(200)
373
-
374
- // The guaranteed pathUSD holding is curated USD, so its USD value is the
375
- // holding itself; unverified holdings are never valued.
376
- const pathUsd = body.data.find((entry) => entry.token.address === TestApp.tokenWithHolders)
377
- expect(pathUsd?.valuation).toEqual({ amount: pathUsd!.formatted, currency: 'USD' })
378
- 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)
379
432
  })
380
433
 
381
434
  test('values holdings in a requested denomination', async () => {
@@ -443,6 +496,26 @@ describe('GET /addresses/:address/balances', () => {
443
496
  expect(stable.error.code).toMatchInlineSnapshot(`"query_invalid"`)
444
497
  })
445
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
+
446
519
  test('restricts the page to fee tokens when feeEligible=true', async () => {
447
520
  const client = app()
448
521
  const holder = await holderWithBalance
@@ -469,3 +542,81 @@ describe('GET /addresses/:address/balances', () => {
469
542
  expect(body.meta!.totalCount).toBeGreaterThanOrEqual(body.data.length)
470
543
  })
471
544
  })
545
+
546
+ describe('GET /v1/addresses/:address/balances/:token', () => {
547
+ test('returns one live balance without indexed holder discovery', async () => {
548
+ const client = app()
549
+ const holder = await holderWithBalance
550
+ const response = await client.v1.addresses[':address'].balances[':token'].$get(
551
+ {
552
+ param: { address: holder, token: TestApp.tokenWithHolders },
553
+ query: {},
554
+ },
555
+ TestApp.auth,
556
+ )
557
+ const body = await TestApp.json(response, Balances.schema.getAddressBalance.Response)
558
+
559
+ expect(response.status).toBe(200)
560
+ expect(body.id).toBe(TestApp.tokenWithHolders)
561
+ expect(body.token.address).toBe(TestApp.tokenWithHolders)
562
+ expect(body.amount).toMatch(/^\d+$/)
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)
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"`)
602
+ })
603
+
604
+ test('returns zero for a token the account does not hold', async () => {
605
+ const client = app()
606
+ const response = await client.v1.addresses[':address'].balances[':token'].$get(
607
+ {
608
+ param: {
609
+ address: privateKeyToAccount(generatePrivateKey()).address,
610
+ token: TestApp.tokenWithHolders,
611
+ },
612
+ query: {},
613
+ },
614
+ TestApp.auth,
615
+ )
616
+ const body = await TestApp.json(response, Balances.schema.getAddressBalance.Response)
617
+
618
+ expect(response.status).toBe(200)
619
+ expect(body.amount).toBe('0')
620
+ expect(body.formatted).toBe('0')
621
+ })
622
+ })