tapimo 0.8.0 → 0.9.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 (80) hide show
  1. package/dist/App.d.ts +2095 -2111
  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/admin/apps/verified-tokens.d.ts +166 -166
  6. package/dist/apps/data/App.d.ts +4084 -3845
  7. package/dist/apps/data/App.d.ts.map +1 -1
  8. package/dist/apps/data/routes/activities.d.ts +270 -270
  9. package/dist/apps/data/routes/addresses.d.ts +248 -17
  10. package/dist/apps/data/routes/addresses.d.ts.map +1 -1
  11. package/dist/apps/data/routes/balances.d.ts +272 -2
  12. package/dist/apps/data/routes/balances.d.ts.map +1 -1
  13. package/dist/apps/data/routes/balances.js +114 -2
  14. package/dist/apps/data/routes/balances.js.map +1 -1
  15. package/dist/apps/data/routes/blocks.d.ts +123 -123
  16. package/dist/apps/data/routes/earn.d.ts +65 -3
  17. package/dist/apps/data/routes/earn.d.ts.map +1 -1
  18. package/dist/apps/data/routes/earn.js +259 -14
  19. package/dist/apps/data/routes/earn.js.map +1 -1
  20. package/dist/apps/data/routes/rpc.d.ts +31 -31
  21. package/dist/apps/data/routes/tokens.d.ts +454 -454
  22. package/dist/apps/data/routes/transfers.d.ts +81 -81
  23. package/dist/apps/funding/App.d.ts +40 -299
  24. package/dist/apps/funding/App.d.ts.map +1 -1
  25. package/dist/apps/funding/App.js +20 -5
  26. package/dist/apps/funding/App.js.map +1 -1
  27. package/dist/apps/funding/routes/deposit-addresses.d.ts +43 -434
  28. package/dist/apps/funding/routes/deposit-addresses.d.ts.map +1 -1
  29. package/dist/apps/funding/routes/deposit-addresses.js +21 -103
  30. package/dist/apps/funding/routes/deposit-addresses.js.map +1 -1
  31. package/dist/apps/funding/routes/quotes.d.ts +89 -89
  32. package/dist/apps/funding/routes/transfers.js +5 -5
  33. package/dist/apps/funding/routes/transfers.js.map +1 -1
  34. package/dist/apps/management/routes/invitations.d.ts +134 -134
  35. package/dist/apps/management/routes/orgs.d.ts +98 -98
  36. package/dist/apps/management/routes/verified-token-requests.d.ts +83 -83
  37. package/dist/apps/mpp/App.d.ts +44 -44
  38. package/dist/db/Db.d.ts.map +1 -1
  39. package/dist/db/Db.js +8 -0
  40. package/dist/db/Db.js.map +1 -1
  41. package/dist/db/tables/fundingDeposits.d.ts +18 -1
  42. package/dist/db/tables/fundingDeposits.d.ts.map +1 -1
  43. package/dist/db/tables/fundingDeposits.js +46 -2
  44. package/dist/db/tables/fundingDeposits.js.map +1 -1
  45. package/dist/internal/Auth.d.ts +4 -0
  46. package/dist/internal/Auth.d.ts.map +1 -1
  47. package/dist/internal/Auth.js +11 -2
  48. package/dist/internal/Auth.js.map +1 -1
  49. package/dist/internal/funding/Provider.d.ts +6 -6
  50. package/dist/internal/funding/Provider.d.ts.map +1 -1
  51. package/dist/internal/funding/Reconciliation.d.ts.map +1 -1
  52. package/dist/internal/funding/Reconciliation.js +101 -16
  53. package/dist/internal/funding/Reconciliation.js.map +1 -1
  54. package/dist/internal/funding/providers/relay.d.ts.map +1 -1
  55. package/dist/internal/funding/providers/relay.js +6 -2
  56. package/dist/internal/funding/providers/relay.js.map +1 -1
  57. package/package.json +1 -1
  58. package/src/App.test.ts +32 -0
  59. package/src/App.ts +4 -1
  60. package/src/Client.test-d.ts +56 -33
  61. package/src/apps/composition.test.ts +39 -10
  62. package/src/apps/data/routes/balances.test.ts +42 -0
  63. package/src/apps/data/routes/balances.ts +218 -85
  64. package/src/apps/data/routes/earn.test.ts +89 -9
  65. package/src/apps/data/routes/earn.ts +355 -14
  66. package/src/apps/funding/App.ts +20 -5
  67. package/src/apps/funding/routes/deposit-addresses.test.ts +43 -22
  68. package/src/apps/funding/routes/deposit-addresses.ts +34 -117
  69. package/src/apps/funding/routes/transfers.test.ts +6 -25
  70. package/src/apps/funding/routes/transfers.ts +5 -5
  71. package/src/db/Db.ts +9 -0
  72. package/src/db/tables/fundingDeposits.test.ts +101 -0
  73. package/src/db/tables/fundingDeposits.ts +71 -2
  74. package/src/internal/Auth.test.ts +28 -0
  75. package/src/internal/Auth.ts +15 -1
  76. package/src/internal/funding/Provider.ts +4 -4
  77. package/src/internal/funding/Reconciliation.test.ts +292 -54
  78. package/src/internal/funding/Reconciliation.ts +160 -53
  79. package/src/internal/funding/SourceObservation.test.ts +2 -2
  80. package/src/internal/funding/providers/relay.ts +7 -2
@@ -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
 
@@ -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'
@@ -469,3 +470,44 @@ describe('GET /addresses/:address/balances', () => {
469
470
  expect(body.meta!.totalCount).toBeGreaterThanOrEqual(body.data.length)
470
471
  })
471
472
  })
473
+
474
+ describe('GET /v1/addresses/:address/balances/:token', () => {
475
+ test('returns one live balance without indexed holder discovery', async () => {
476
+ const client = app()
477
+ const holder = await holderWithBalance
478
+ const response = await client.v1.addresses[':address'].balances[':token'].$get(
479
+ {
480
+ param: { address: holder, token: TestApp.tokenWithHolders },
481
+ query: {},
482
+ },
483
+ TestApp.auth,
484
+ )
485
+ const body = await TestApp.json(response, Balances.schema.getAddressBalance.Response)
486
+
487
+ expect(response.status).toBe(200)
488
+ expect(body.id).toBe(TestApp.tokenWithHolders)
489
+ expect(body.token.address).toBe(TestApp.tokenWithHolders)
490
+ expect(body.amount).toMatch(/^\d+$/)
491
+ expect(core_Value.from(body.formatted, body.decimals)).toBe(BigInt(body.amount))
492
+ expect(body.valuation).toEqual({ amount: body.formatted, currency: 'USD' })
493
+ })
494
+
495
+ test('returns zero for a token the account does not hold', async () => {
496
+ const client = app()
497
+ const response = await client.v1.addresses[':address'].balances[':token'].$get(
498
+ {
499
+ param: {
500
+ address: privateKeyToAccount(generatePrivateKey()).address,
501
+ token: TestApp.tokenWithHolders,
502
+ },
503
+ query: {},
504
+ },
505
+ TestApp.auth,
506
+ )
507
+ const body = await TestApp.json(response, Balances.schema.getAddressBalance.Response)
508
+
509
+ expect(response.status).toBe(200)
510
+ expect(body.amount).toBe('0')
511
+ expect(body.formatted).toBe('0')
512
+ })
513
+ })
@@ -181,102 +181,235 @@ export namespace schema {
181
181
  })
182
182
  .check(z.describe('A page of TIP-20 token balances held by this account.'))
183
183
  }
184
+
185
+ /** Schemas for the getAddressBalance operation. */
186
+ export namespace getAddressBalance {
187
+ /** Path parameters for a single address balance request. */
188
+ export const Params = z
189
+ .object({
190
+ address: Schema.Address.check(
191
+ z.describe('The account address whose token balance you want to read.'),
192
+ ),
193
+ token: Schema.TokenAddress.check(z.describe('The TIP-20 token contract address.')),
194
+ })
195
+ .check(z.describe('Path parameters for reading one account token balance.'))
196
+
197
+ /** Query parameters for a single address balance request. */
198
+ export const Query = z
199
+ .strictObject({
200
+ chainId: Schema.ChainIdQuery,
201
+ 'valuation.currency': Schema.Denomination,
202
+ })
203
+ .check(z.describe('Query parameters for reading one account token balance.'))
204
+
205
+ /** A single TIP-20 token balance held by an address. */
206
+ export const Response = getAddressBalances.Balance.check(
207
+ z.describe('One TIP-20 token balance held by this account.'),
208
+ )
209
+ }
184
210
  }
185
211
 
186
212
  /**
187
213
  * Creates address-scoped balance handlers, mounted under the `/addresses`
188
- * composer. Exposes `GET /:address/balances`, the held-balance page.
214
+ * composer. Exposes the held-balance page and individual token balances.
189
215
  */
190
216
  export function addresses(options: addresses.Options = {}) {
191
217
  const oracle = options.fx?.oracle ?? FxOracle.ecb()
192
- return new Hono<App.Environment>().get(
193
- '/v1/addresses/:address/balances',
194
- Auth.policy({ apiKey: { scopes: ['data:read'] }, mpp: true, public: true }),
195
- OpenApi.validate('param', schema.getAddressBalances.Params, {
196
- code: 'address_invalid',
197
- message: 'Invalid account address',
198
- }),
199
- OpenApi.validate('query', schema.getAddressBalances.Query, {
200
- code: 'query_invalid',
201
- message: 'Invalid query parameters',
202
- }),
203
- OpenApi.describeRoute({
204
- description:
205
- 'Lists how much of each TIP-20 token an account holds, ordered from largest to smallest balance. Amounts are returned in raw base units and human-readable form, and each verified holding carries its nominal value in the requested denomination.',
206
- operationId: 'getAddressBalances',
207
- responses: OpenApi.responses({
208
- errors: { 502: 'Could not read balance or token data from an upstream service.' },
209
- success: {
210
- description: 'A page of token balances for this account.',
211
- schema: schema.getAddressBalances.Response,
212
- },
218
+ return new Hono<App.Environment>()
219
+ .get(
220
+ '/v1/addresses/:address/balances',
221
+ Auth.policy({ apiKey: { scopes: ['data:read'] }, mpp: true, public: true }),
222
+ OpenApi.validate('param', schema.getAddressBalances.Params, {
223
+ code: 'address_invalid',
224
+ message: 'Invalid account address',
213
225
  }),
214
- summary: 'List address balances',
215
- tags: ['Balances'],
216
- }),
217
- Cache.response<App.Environment>({
218
- cacheControl: Cache.policies.noStore,
219
- name: 'tempo-api:addresses:v1',
220
- key: (c) => Cache.urlKey(c, schema.getAddressBalances.Query),
221
- }),
222
- async (c) => {
223
- if (Auth.narrowAccess) return Auth.paidAccessError(c)
224
- if (OpenApi.narrowValidation)
225
- return OpenApi.validationError(c, {
226
- code: 'address_invalid',
227
- message: 'Invalid request parameters',
228
- })
229
- if (OpenApi.narrowValidation)
230
- return OpenApi.validationError(c, {
231
- code: 'query_invalid',
232
- message: 'Invalid request parameters',
233
- })
234
-
235
- const { address } = c.req.valid('param')
236
- const query = c.req.valid('query')
226
+ OpenApi.validate('query', schema.getAddressBalances.Query, {
227
+ code: 'query_invalid',
228
+ message: 'Invalid query parameters',
229
+ }),
230
+ OpenApi.describeRoute({
231
+ 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.',
233
+ operationId: 'getAddressBalances',
234
+ responses: OpenApi.responses({
235
+ errors: { 502: 'Could not read balance or token data from an upstream service.' },
236
+ success: {
237
+ description: 'A page of token balances for this account.',
238
+ schema: schema.getAddressBalances.Response,
239
+ },
240
+ }),
241
+ summary: 'List address balances',
242
+ tags: ['Balances'],
243
+ }),
244
+ Cache.response<App.Environment>({
245
+ cacheControl: Cache.policies.noStore,
246
+ name: 'tempo-api:addresses:v1',
247
+ key: (c) => Cache.urlKey(c, schema.getAddressBalances.Query),
248
+ }),
249
+ async (c) => {
250
+ if (Auth.narrowAccess) return Auth.paidAccessError(c)
251
+ if (OpenApi.narrowValidation)
252
+ return OpenApi.validationError(c, {
253
+ code: 'address_invalid',
254
+ message: 'Invalid request parameters',
255
+ })
256
+ if (OpenApi.narrowValidation)
257
+ return OpenApi.validationError(c, {
258
+ code: 'query_invalid',
259
+ message: 'Invalid request parameters',
260
+ })
261
+
262
+ const { address } = c.req.valid('param')
263
+ const query = c.req.valid('query')
237
264
 
238
- try {
265
+ try {
266
+ const chainId = query.chainId ?? c.get('chainId')
267
+ // `currency` only makes sense against verified tokens, so it implies
268
+ // `verified=true` from the caller's perspective.
269
+ const verified = query.verified || query.currency !== undefined
270
+ const feeEligible = query.feeEligible ?? false
271
+
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
+ const balances = await getAddressBalances(c, {
285
+ address,
286
+ chainId,
287
+ currency: query.currency,
288
+ cursor: query.cursor,
289
+ denomination: query['valuation.currency'] ?? 'USD',
290
+ feeEligible,
291
+ feeTokens,
292
+ includeTotalCount: query.include.includes('totalCount'),
293
+ limit: query.limit,
294
+ oracle,
295
+ page: query.page,
296
+ verified,
297
+ })
298
+
299
+ return c.json(Response.validated(schema.getAddressBalances.Response, balances), 200)
300
+ } catch (cause) {
301
+ if (cause instanceof Valuation.UnsupportedDenominationError)
302
+ return Response.error(c, { code: 'query_invalid', message: cause.message, status: 400 })
303
+ return Response.upstream(c, cause)
304
+ }
305
+ },
306
+ )
307
+ .get(
308
+ '/v1/addresses/:address/balances/:token',
309
+ Auth.policy({ apiKey: { scopes: ['data:read'] }, mpp: true, public: true }),
310
+ OpenApi.validate('param', schema.getAddressBalance.Params, {
311
+ code: 'address_invalid',
312
+ message: 'Invalid address or token address',
313
+ }),
314
+ OpenApi.validate('query', schema.getAddressBalance.Query, {
315
+ code: 'query_invalid',
316
+ message: 'Invalid query parameters',
317
+ }),
318
+ OpenApi.describeRoute({
319
+ description:
320
+ 'Returns the live balance of one TIP-20 token held by an account, including token metadata and a human-readable amount.',
321
+ operationId: 'getAddressBalance',
322
+ responses: OpenApi.responses({
323
+ errors: {
324
+ 404: {
325
+ description: 'The token is not a registered TIP-20 token.',
326
+ codes: ['token_not_found'],
327
+ },
328
+ 502: 'Could not read balance or token data from an upstream service.',
329
+ },
330
+ success: {
331
+ description: 'The requested token balance for this account.',
332
+ schema: schema.getAddressBalance.Response,
333
+ },
334
+ }),
335
+ summary: 'Get address balance',
336
+ tags: ['Balances'],
337
+ }),
338
+ Cache.response<App.Environment>({
339
+ cacheControl: Cache.policies.noStore,
340
+ name: 'tempo-api:addresses:v1',
341
+ key: (c) => Cache.urlKey(c, schema.getAddressBalance.Query),
342
+ }),
343
+ async (c) => {
344
+ if (Auth.narrowAccess) return Auth.paidAccessError(c)
345
+ if (OpenApi.narrowValidation)
346
+ return OpenApi.validationError(c, {
347
+ code: 'address_invalid',
348
+ message: 'Invalid request parameters',
349
+ })
350
+ if (OpenApi.narrowValidation)
351
+ return OpenApi.validationError(c, {
352
+ code: 'query_invalid',
353
+ message: 'Invalid request parameters',
354
+ })
355
+
356
+ const { address, token } = c.req.valid('param')
357
+ const query = c.req.valid('query')
239
358
  const chainId = query.chainId ?? c.get('chainId')
240
- // `currency` only makes sense against verified tokens, so it implies
241
- // `verified=true` from the caller's perspective.
242
- const verified = query.verified || query.currency !== undefined
243
- const feeEligible = query.feeEligible ?? false
244
-
245
- // The fee-token set powers both the per-row `feeEligible` flag (always)
246
- // and the optional `feeEligible=true` page restriction. It is memoized
247
- // per chain (a near-constant), so one read feeds the page, the count,
248
- // and the row enrichment. It is a best-effort enrichment: a lookup
249
- // failure omits `feeEligible` rather than failing the page — unless the
250
- // caller explicitly asked to filter by `feeEligible`, where the filter
251
- // can't be honored, so the failure surfaces as the declared `502`.
252
- const feeTokens = await FeeAmm.feeTokenSet(c, chainId).catch((cause) => {
253
- if (feeEligible) throw cause
254
- return undefined
255
- })
256
-
257
- const balances = await getAddressBalances(c, {
258
- address,
259
- chainId,
260
- currency: query.currency,
261
- cursor: query.cursor,
262
- denomination: query['valuation.currency'] ?? 'USD',
263
- feeEligible,
264
- feeTokens,
265
- includeTotalCount: query.include.includes('totalCount'),
266
- limit: query.limit,
267
- oracle,
268
- page: query.page,
269
- verified,
270
- })
271
359
 
272
- return c.json(Response.validated(schema.getAddressBalances.Response, balances), 200)
273
- } catch (cause) {
274
- if (cause instanceof Valuation.UnsupportedDenominationError)
275
- return Response.error(c, { code: 'query_invalid', message: cause.message, status: 400 })
276
- return Response.upstream(c, cause)
277
- }
278
- },
279
- )
360
+ try {
361
+ const snapshot = await VerifiedTokens.snapshot(c, chainId)
362
+ const [balance, resolvedToken] = await Promise.all([
363
+ Timing.time(c, 'balance_rpc', async () => {
364
+ const result = await Actions.token.getBalance(c.get('getClient')(chainId), {
365
+ account: address,
366
+ token,
367
+ })
368
+ return result.amount
369
+ }),
370
+ Timing.time(c, 'token', () =>
371
+ Tokens.resolveToken(c, { address: token, chainId, include: [], snapshot }),
372
+ ),
373
+ ])
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
+ return c.json(
383
+ Response.validated(schema.getAddressBalance.Response, {
384
+ amount: balance.toString(),
385
+ currency: resolvedToken.currency,
386
+ decimals: resolvedToken.decimals,
387
+ ...(feeTokens ? { feeEligible: feeTokens.has(token) } : {}),
388
+ formatted: core_Value.format(balance, resolvedToken.decimals),
389
+ id: token,
390
+ token: resolvedToken,
391
+ valuation: Valuation.valuationFor({
392
+ amount: balance,
393
+ denomination,
394
+ rates,
395
+ token: snapshot.byAddress.get(token),
396
+ }),
397
+ }),
398
+ 200,
399
+ )
400
+ } catch (cause) {
401
+ if (Tokens.isTokenNotFound(cause))
402
+ return Response.error(c, {
403
+ code: 'token_not_found',
404
+ message: 'Token not found',
405
+ status: 404,
406
+ })
407
+ if (cause instanceof Valuation.UnsupportedDenominationError)
408
+ return Response.error(c, { code: 'query_invalid', message: cause.message, status: 400 })
409
+ return Response.upstream(c, cause)
410
+ }
411
+ },
412
+ )
280
413
  }
281
414
 
282
415
  export declare namespace addresses {
@@ -28,6 +28,7 @@ type OpenApiDocument = {
28
28
  string,
29
29
  {
30
30
  get?: {
31
+ description?: string
31
32
  parameters?: readonly {
32
33
  description?: string
33
34
  name?: string
@@ -109,12 +110,6 @@ describe('schema.getEarnVaultEarnings.Response', () => {
109
110
  period: 'active',
110
111
  status: 'complete',
111
112
  }).success,
112
- lifetime: Earn.schema.getEarnVaultEarnings.Response.safeParse({
113
- ...response,
114
- lifetimeEarnings: '0',
115
- period: 'lifetime',
116
- status: 'complete',
117
- }).success,
118
113
  completeWithoutEarnings: Earn.schema.getEarnVaultEarnings.Response.safeParse({
119
114
  ...response,
120
115
  period: 'lifetime',
@@ -125,12 +120,23 @@ describe('schema.getEarnVaultEarnings.Response', () => {
125
120
  period: 'active',
126
121
  status: 'incomplete_cost_basis',
127
122
  }).success,
123
+ incompleteWindow: Earn.schema.getEarnVaultEarnings.Response.safeParse({
124
+ ...response,
125
+ period: '30d',
126
+ status: 'incomplete_cost_basis',
127
+ }).success,
128
128
  incompleteWithEarnings: Earn.schema.getEarnVaultEarnings.Response.safeParse({
129
129
  ...response,
130
130
  lifetimeEarnings: '0',
131
131
  period: 'lifetime',
132
132
  status: 'incomplete_cost_basis',
133
133
  }).success,
134
+ lifetime: Earn.schema.getEarnVaultEarnings.Response.safeParse({
135
+ ...response,
136
+ lifetimeEarnings: '0',
137
+ period: 'lifetime',
138
+ status: 'complete',
139
+ }).success,
134
140
  pending: Earn.schema.getEarnVaultEarnings.Response.safeParse({
135
141
  ...response,
136
142
  period: 'lifetime',
@@ -141,34 +147,77 @@ describe('schema.getEarnVaultEarnings.Response', () => {
141
147
  period: 'active',
142
148
  status: 'pending_redemption',
143
149
  }).success,
150
+ pendingWindow: Earn.schema.getEarnVaultEarnings.Response.safeParse({
151
+ ...response,
152
+ period: '30d',
153
+ status: 'pending_redemption',
154
+ }).success,
144
155
  pendingWithEarnings: Earn.schema.getEarnVaultEarnings.Response.safeParse({
145
156
  ...response,
146
157
  lifetimeEarnings: '0',
147
158
  period: 'lifetime',
148
159
  status: 'pending_redemption',
149
160
  }).success,
161
+ window: Earn.schema.getEarnVaultEarnings.Response.safeParse({
162
+ ...response,
163
+ period: '30d',
164
+ status: 'complete',
165
+ windowEarnings: '0',
166
+ }).success,
167
+ windowWithActiveEarnings: Earn.schema.getEarnVaultEarnings.Response.safeParse({
168
+ ...response,
169
+ activeEarnings: '0',
170
+ period: '30d',
171
+ status: 'complete',
172
+ }).success,
173
+ windowWithoutEarnings: Earn.schema.getEarnVaultEarnings.Response.safeParse({
174
+ ...response,
175
+ period: '30d',
176
+ status: 'complete',
177
+ }).success,
150
178
  }).toMatchInlineSnapshot(`
151
179
  {
152
180
  "active": true,
153
181
  "activeWithLifetimeEarnings": false,
154
182
  "completeWithoutEarnings": false,
155
183
  "incomplete": true,
184
+ "incompleteWindow": true,
156
185
  "incompleteWithEarnings": false,
157
186
  "lifetime": true,
158
187
  "pending": true,
159
188
  "pendingActive": false,
189
+ "pendingWindow": true,
160
190
  "pendingWithEarnings": false,
191
+ "window": true,
192
+ "windowWithActiveEarnings": false,
193
+ "windowWithoutEarnings": false,
161
194
  }
162
195
  `)
163
196
  })
164
197
  })
165
198
 
166
199
  describe('schema.getEarnVaultEarnings.Query', () => {
167
- test('defaults to lifetime and accepts active earnings', () => {
200
+ test('defaults to lifetime and accepts active and trailing earnings', () => {
168
201
  expect([
169
202
  Earn.schema.getEarnVaultEarnings.Query.parse({}).period,
203
+ Earn.schema.getEarnVaultEarnings.Query.parse({ period: '30d' }).period,
170
204
  Earn.schema.getEarnVaultEarnings.Query.parse({ period: 'active' }).period,
171
- ]).toStrictEqual(['lifetime', 'active'])
205
+ ]).toStrictEqual(['lifetime', '30d', 'active'])
206
+ })
207
+ })
208
+
209
+ describe('calculateWindowEarnings', () => {
210
+ test('adjusts snapshot growth for deposits and realized assets', () => {
211
+ expect(
212
+ Earn.calculateWindowEarnings({
213
+ deposited: 50n,
214
+ endingValue: 130n,
215
+ finalized: 10n,
216
+ openingValue: 100n,
217
+ redeemed: 15n,
218
+ withdrewExact: 5n,
219
+ }),
220
+ ).toBe(10n)
172
221
  })
173
222
  })
174
223
 
@@ -1098,6 +1147,30 @@ describe('GET /v1/earn/vaults/:vaultId/earnings/:address', () => {
1098
1147
  60_000,
1099
1148
  )
1100
1149
 
1150
+ test.runIf(Runtime.get().mode === 'testnet')(
1151
+ 'returns cash-flow-adjusted trailing earnings',
1152
+ async () => {
1153
+ const app = TestApp.create({ auth: false, cache })
1154
+ const response = await app.request(
1155
+ `/v1/earn/vaults/${TestEarn.activeEarningsVaultAddress}/earnings/${TestEarn.activeEarningsAddress}?chainId=${TestEarn.chain.id}&period=30d`,
1156
+ )
1157
+
1158
+ expect(response.status).toBe(200)
1159
+ const earnings = await TestApp.json(response, Earn.schema.getEarnVaultEarnings.Response)
1160
+ if (earnings.status !== 'complete' || earnings.period !== '30d')
1161
+ throw new Error('Expected complete trailing earnings history.')
1162
+ expect(earnings).toMatchObject({
1163
+ account: TestEarn.activeEarningsAddress,
1164
+ period: '30d',
1165
+ status: 'complete',
1166
+ windowEarnings: expect.stringMatching(/^-?\d+$/),
1167
+ })
1168
+ expect(earnings).not.toHaveProperty('activeEarnings')
1169
+ expect(earnings).not.toHaveProperty('lifetimeEarnings')
1170
+ },
1171
+ 60_000,
1172
+ )
1173
+
1101
1174
  test.runIf(Runtime.get().mode === 'testnet')(
1102
1175
  'omits lifetime earnings when venue shares make the cost basis incomplete',
1103
1176
  async () => {
@@ -1377,14 +1450,21 @@ describe('OpenAPI', () => {
1377
1450
  const app = TestApp.create({ auth: false, cache })
1378
1451
  const spec = (await (await app.request('/openapi.json')).json()) as OpenApiDocument
1379
1452
  const operation = spec.paths['/v1/earn/vaults/{vaultId}/earnings/{address}']?.get
1453
+ const period = operation?.parameters?.find((parameter) => parameter.name === 'period')
1380
1454
  const response = JSON.stringify(operation?.responses)
1381
1455
 
1456
+ expect(operation?.description).toContain('the trailing 30 days')
1457
+ expect(period?.description).toContain('trailing 30 days')
1458
+ expect(period?.schema?.enum).toStrictEqual(['30d', 'active', 'lifetime'])
1459
+ expect(period?.schema?.examples).toStrictEqual(['lifetime'])
1382
1460
  expect(response).toContain('"earn_vault_not_found"')
1383
1461
  expect(response).toContain('Stable resource ID for these vault earnings')
1384
1462
  expect(response).toContain('Current share value plus completed redemptions')
1385
1463
  expect(response).toContain('weighted-average asset cost basis')
1386
- expect(response).toContain('cannot reconstruct the remaining asset cost basis')
1464
+ expect(response).toContain('Ending share value plus realized assets')
1465
+ expect(response).toContain('cannot reconstruct the asset cost basis for the requested period')
1387
1466
  expect(response).toContain('"active"')
1467
+ expect(response).toContain('"30d"')
1388
1468
  expect(response).toContain('"incomplete_cost_basis"')
1389
1469
  expect(response).toContain('"pending_redemption"')
1390
1470
  })