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.
- package/dist/App.d.ts +2095 -2111
- package/dist/App.d.ts.map +1 -1
- package/dist/App.js +0 -1
- package/dist/App.js.map +1 -1
- package/dist/admin/apps/verified-tokens.d.ts +166 -166
- package/dist/apps/data/App.d.ts +4084 -3845
- package/dist/apps/data/App.d.ts.map +1 -1
- package/dist/apps/data/routes/activities.d.ts +270 -270
- package/dist/apps/data/routes/addresses.d.ts +248 -17
- package/dist/apps/data/routes/addresses.d.ts.map +1 -1
- package/dist/apps/data/routes/balances.d.ts +272 -2
- package/dist/apps/data/routes/balances.d.ts.map +1 -1
- package/dist/apps/data/routes/balances.js +114 -2
- package/dist/apps/data/routes/balances.js.map +1 -1
- package/dist/apps/data/routes/blocks.d.ts +123 -123
- package/dist/apps/data/routes/earn.d.ts +65 -3
- package/dist/apps/data/routes/earn.d.ts.map +1 -1
- package/dist/apps/data/routes/earn.js +259 -14
- package/dist/apps/data/routes/earn.js.map +1 -1
- package/dist/apps/data/routes/rpc.d.ts +31 -31
- package/dist/apps/data/routes/tokens.d.ts +454 -454
- package/dist/apps/data/routes/transfers.d.ts +81 -81
- package/dist/apps/funding/App.d.ts +40 -299
- package/dist/apps/funding/App.d.ts.map +1 -1
- package/dist/apps/funding/App.js +20 -5
- package/dist/apps/funding/App.js.map +1 -1
- package/dist/apps/funding/routes/deposit-addresses.d.ts +43 -434
- package/dist/apps/funding/routes/deposit-addresses.d.ts.map +1 -1
- package/dist/apps/funding/routes/deposit-addresses.js +21 -103
- package/dist/apps/funding/routes/deposit-addresses.js.map +1 -1
- package/dist/apps/funding/routes/quotes.d.ts +89 -89
- package/dist/apps/funding/routes/transfers.js +5 -5
- package/dist/apps/funding/routes/transfers.js.map +1 -1
- package/dist/apps/management/routes/invitations.d.ts +134 -134
- package/dist/apps/management/routes/orgs.d.ts +98 -98
- package/dist/apps/management/routes/verified-token-requests.d.ts +83 -83
- package/dist/apps/mpp/App.d.ts +44 -44
- package/dist/db/Db.d.ts.map +1 -1
- package/dist/db/Db.js +8 -0
- package/dist/db/Db.js.map +1 -1
- package/dist/db/tables/fundingDeposits.d.ts +18 -1
- package/dist/db/tables/fundingDeposits.d.ts.map +1 -1
- package/dist/db/tables/fundingDeposits.js +46 -2
- package/dist/db/tables/fundingDeposits.js.map +1 -1
- package/dist/internal/Auth.d.ts +4 -0
- package/dist/internal/Auth.d.ts.map +1 -1
- package/dist/internal/Auth.js +11 -2
- package/dist/internal/Auth.js.map +1 -1
- package/dist/internal/funding/Provider.d.ts +6 -6
- package/dist/internal/funding/Provider.d.ts.map +1 -1
- package/dist/internal/funding/Reconciliation.d.ts.map +1 -1
- package/dist/internal/funding/Reconciliation.js +101 -16
- package/dist/internal/funding/Reconciliation.js.map +1 -1
- package/dist/internal/funding/providers/relay.d.ts.map +1 -1
- package/dist/internal/funding/providers/relay.js +6 -2
- package/dist/internal/funding/providers/relay.js.map +1 -1
- package/package.json +1 -1
- package/src/App.test.ts +32 -0
- package/src/App.ts +4 -1
- package/src/Client.test-d.ts +56 -33
- package/src/apps/composition.test.ts +39 -10
- package/src/apps/data/routes/balances.test.ts +42 -0
- package/src/apps/data/routes/balances.ts +218 -85
- package/src/apps/data/routes/earn.test.ts +89 -9
- package/src/apps/data/routes/earn.ts +355 -14
- package/src/apps/funding/App.ts +20 -5
- package/src/apps/funding/routes/deposit-addresses.test.ts +43 -22
- package/src/apps/funding/routes/deposit-addresses.ts +34 -117
- package/src/apps/funding/routes/transfers.test.ts +6 -25
- package/src/apps/funding/routes/transfers.ts +5 -5
- package/src/db/Db.ts +9 -0
- package/src/db/tables/fundingDeposits.test.ts +101 -0
- package/src/db/tables/fundingDeposits.ts +71 -2
- package/src/internal/Auth.test.ts +28 -0
- package/src/internal/Auth.ts +15 -1
- package/src/internal/funding/Provider.ts +4 -4
- package/src/internal/funding/Reconciliation.test.ts +292 -54
- package/src/internal/funding/Reconciliation.ts +160 -53
- package/src/internal/funding/SourceObservation.test.ts +2 -2
- 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 {
|
|
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('
|
|
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/
|
|
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
|
-
|
|
360
|
-
|
|
361
|
-
|
|
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['
|
|
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
|
|
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>()
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
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
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
})
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
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
|
-
|
|
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
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
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('
|
|
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
|
})
|