tapimo 0.4.3 → 0.5.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 (192) hide show
  1. package/dist/App.d.ts +7222 -6316
  2. package/dist/App.d.ts.map +1 -1
  3. package/dist/App.js +4 -0
  4. package/dist/App.js.map +1 -1
  5. package/dist/Scope.d.ts +18 -1
  6. package/dist/Scope.d.ts.map +1 -1
  7. package/dist/Scope.js +24 -3
  8. package/dist/Scope.js.map +1 -1
  9. package/dist/admin/apps/verified-tokens.d.ts +116 -116
  10. package/dist/apps/data/App.d.ts +2541 -2607
  11. package/dist/apps/data/App.d.ts.map +1 -1
  12. package/dist/apps/data/App.js +135 -44
  13. package/dist/apps/data/App.js.map +1 -1
  14. package/dist/apps/data/FundingQuoteProviders.d.ts +36 -0
  15. package/dist/apps/data/FundingQuoteProviders.d.ts.map +1 -0
  16. package/dist/apps/data/FundingQuoteProviders.js +190 -0
  17. package/dist/apps/data/FundingQuoteProviders.js.map +1 -0
  18. package/dist/apps/data/index.d.ts +1 -1
  19. package/dist/apps/data/index.js +1 -1
  20. package/dist/apps/data/routes/activities.d.ts +242 -242
  21. package/dist/apps/data/routes/addresses.d.ts +227 -227
  22. package/dist/apps/data/routes/balances.d.ts +12 -12
  23. package/dist/apps/data/routes/balances.d.ts.map +1 -1
  24. package/dist/apps/data/routes/balances.js +67 -149
  25. package/dist/apps/data/routes/balances.js.map +1 -1
  26. package/dist/apps/data/routes/blocks.d.ts +125 -126
  27. package/dist/apps/data/routes/blocks.d.ts.map +1 -1
  28. package/dist/apps/data/routes/blocks.js +3 -5
  29. package/dist/apps/data/routes/blocks.js.map +1 -1
  30. package/dist/apps/data/routes/coingecko.d.ts +55 -54
  31. package/dist/apps/data/routes/coingecko.d.ts.map +1 -1
  32. package/dist/apps/data/routes/coingecko.js +3 -8
  33. package/dist/apps/data/routes/coingecko.js.map +1 -1
  34. package/dist/apps/data/routes/exchanges.d.ts +455 -455
  35. package/dist/apps/data/routes/fee-amm.d.ts +92 -92
  36. package/dist/apps/data/routes/funding.d.ts +91 -864
  37. package/dist/apps/data/routes/funding.d.ts.map +1 -1
  38. package/dist/apps/data/routes/funding.js +138 -450
  39. package/dist/apps/data/routes/funding.js.map +1 -1
  40. package/dist/apps/data/routes/rpc.d.ts.map +1 -1
  41. package/dist/apps/data/routes/rpc.js +86 -16
  42. package/dist/apps/data/routes/rpc.js.map +1 -1
  43. package/dist/apps/data/routes/tokenlist.d.ts +36 -36
  44. package/dist/apps/data/routes/webhooks.d.ts +340 -0
  45. package/dist/apps/data/routes/webhooks.d.ts.map +1 -1
  46. package/dist/apps/data/routes/webhooks.js +13 -1
  47. package/dist/apps/data/routes/webhooks.js.map +1 -1
  48. package/dist/apps/index.d.ts +1 -1
  49. package/dist/apps/index.js +1 -1
  50. package/dist/apps/management/App.d.ts +1843 -861
  51. package/dist/apps/management/App.d.ts.map +1 -1
  52. package/dist/apps/management/App.js +4 -0
  53. package/dist/apps/management/App.js.map +1 -1
  54. package/dist/apps/management/routes/api-keys.d.ts +121 -121
  55. package/dist/apps/management/routes/api-keys.js +2 -2
  56. package/dist/apps/management/routes/api-keys.js.map +1 -1
  57. package/dist/apps/management/routes/billing.d.ts +2 -2
  58. package/dist/apps/management/routes/invite-links.d.ts +1100 -0
  59. package/dist/apps/management/routes/invite-links.d.ts.map +1 -0
  60. package/dist/apps/management/routes/invite-links.js +411 -0
  61. package/dist/apps/management/routes/invite-links.js.map +1 -0
  62. package/dist/apps/management/routes/me.d.ts +13 -13
  63. package/dist/apps/management/routes/members.d.ts +77 -77
  64. package/dist/apps/management/routes/orgs.d.ts +96 -96
  65. package/dist/apps/management/routes/projects.d.ts +62 -62
  66. package/dist/apps/management/routes/scopes.d.ts +11 -11
  67. package/dist/apps/management/routes/scopes.d.ts.map +1 -1
  68. package/dist/apps/management/routes/scopes.js +2 -2
  69. package/dist/apps/management/routes/scopes.js.map +1 -1
  70. package/dist/apps/management/routes/usage.d.ts +79 -79
  71. package/dist/apps/mcp.d.ts.map +1 -1
  72. package/dist/apps/mcp.js +3 -1
  73. package/dist/apps/mcp.js.map +1 -1
  74. package/dist/apps/relay/App.d.ts.map +1 -1
  75. package/dist/apps/relay/App.js +42 -4
  76. package/dist/apps/relay/App.js.map +1 -1
  77. package/dist/cloudflare.d.ts +2 -0
  78. package/dist/cloudflare.d.ts.map +1 -1
  79. package/dist/cloudflare.js +4 -0
  80. package/dist/cloudflare.js.map +1 -1
  81. package/dist/db/Db.d.ts +3 -0
  82. package/dist/db/Db.d.ts.map +1 -1
  83. package/dist/db/Db.js +57 -0
  84. package/dist/db/Db.js.map +1 -1
  85. package/dist/db/Schema.d.ts +31 -0
  86. package/dist/db/Schema.d.ts.map +1 -1
  87. package/dist/db/Schema.js +31 -0
  88. package/dist/db/Schema.js.map +1 -1
  89. package/dist/db/tables/inviteLinks.d.ts +42 -0
  90. package/dist/db/tables/inviteLinks.d.ts.map +1 -0
  91. package/dist/db/tables/inviteLinks.js +148 -0
  92. package/dist/db/tables/inviteLinks.js.map +1 -0
  93. package/dist/db/tables/organizations.d.ts.map +1 -1
  94. package/dist/db/tables/organizations.js +2 -0
  95. package/dist/db/tables/organizations.js.map +1 -1
  96. package/dist/handlers/relay.d.ts.map +1 -1
  97. package/dist/handlers/relay.js +13 -3
  98. package/dist/handlers/relay.js.map +1 -1
  99. package/dist/internal/Auth.d.ts +42 -23
  100. package/dist/internal/Auth.d.ts.map +1 -1
  101. package/dist/internal/Auth.js +169 -20
  102. package/dist/internal/Auth.js.map +1 -1
  103. package/dist/internal/EdgeCache.d.ts +3 -1
  104. package/dist/internal/EdgeCache.d.ts.map +1 -1
  105. package/dist/internal/EdgeCache.js +17 -2
  106. package/dist/internal/EdgeCache.js.map +1 -1
  107. package/dist/internal/Errors.d.ts +6 -0
  108. package/dist/internal/Errors.d.ts.map +1 -0
  109. package/dist/internal/Errors.js +16 -0
  110. package/dist/internal/Errors.js.map +1 -0
  111. package/dist/internal/FundingQuotes.d.ts +644 -0
  112. package/dist/internal/FundingQuotes.d.ts.map +1 -0
  113. package/dist/internal/{FundingRoutes.js → FundingQuotes.js} +93 -289
  114. package/dist/internal/FundingQuotes.js.map +1 -0
  115. package/dist/internal/Store.d.ts +61 -16
  116. package/dist/internal/Store.d.ts.map +1 -1
  117. package/dist/internal/Store.js +72 -17
  118. package/dist/internal/Store.js.map +1 -1
  119. package/dist/internal/Viem.d.ts +2 -0
  120. package/dist/internal/Viem.d.ts.map +1 -1
  121. package/dist/internal/Viem.js +11 -2
  122. package/dist/internal/Viem.js.map +1 -1
  123. package/dist/internal/index.d.ts +27 -0
  124. package/dist/internal/index.d.ts.map +1 -0
  125. package/dist/internal/index.js +27 -0
  126. package/dist/internal/index.js.map +1 -0
  127. package/package.json +8 -3
  128. package/src/ApiKeys.test.ts +23 -0
  129. package/src/App.test-d.ts +2 -0
  130. package/src/App.test.ts +60 -4
  131. package/src/App.ts +6 -0
  132. package/src/Client.test-d.ts +16 -54
  133. package/src/Scope.test.ts +1 -0
  134. package/src/Scope.ts +30 -4
  135. package/src/apps/data/App.test.ts +354 -0
  136. package/src/apps/data/App.ts +159 -52
  137. package/src/apps/data/FundingQuoteProviders.test.ts +151 -0
  138. package/src/apps/data/FundingQuoteProviders.ts +251 -0
  139. package/src/apps/data/index.ts +1 -1
  140. package/src/apps/data/routes/balances.test.ts +64 -18
  141. package/src/apps/data/routes/balances.ts +76 -177
  142. package/src/apps/data/routes/blocks.ts +3 -5
  143. package/src/apps/data/routes/coingecko.ts +3 -8
  144. package/src/apps/data/routes/funding.test.ts +149 -306
  145. package/src/apps/data/routes/funding.ts +154 -604
  146. package/src/apps/data/routes/indexer.test.ts +99 -3
  147. package/src/apps/data/routes/rpc.test.ts +219 -0
  148. package/src/apps/data/routes/rpc.ts +92 -17
  149. package/src/apps/data/routes/webhooks.test.ts +23 -2
  150. package/src/apps/data/routes/webhooks.ts +13 -1
  151. package/src/apps/index.ts +1 -1
  152. package/src/apps/management/App.ts +4 -0
  153. package/src/apps/management/routes/api-keys.test.ts +47 -3
  154. package/src/apps/management/routes/api-keys.ts +2 -2
  155. package/src/apps/management/routes/invite-links.test.ts +385 -0
  156. package/src/apps/management/routes/invite-links.ts +570 -0
  157. package/src/apps/management/routes/scopes.test.ts +3 -2
  158. package/src/apps/management/routes/scopes.ts +4 -2
  159. package/src/apps/mcp.test.ts +55 -14
  160. package/src/apps/mcp.ts +3 -1
  161. package/src/apps/relay/App.test.ts +324 -4
  162. package/src/apps/relay/App.ts +40 -4
  163. package/src/cloudflare.ts +5 -0
  164. package/src/db/Db.ts +65 -0
  165. package/src/db/Schema.ts +39 -0
  166. package/src/db/tables/inviteLinks.ts +187 -0
  167. package/src/db/tables/organizations.ts +2 -0
  168. package/src/handlers/relay.ts +16 -4
  169. package/src/internal/Auth.test.ts +528 -37
  170. package/src/internal/Auth.ts +246 -45
  171. package/src/internal/EdgeCache.test.ts +24 -3
  172. package/src/internal/EdgeCache.ts +18 -2
  173. package/src/internal/FundingQuotes.test.ts +174 -0
  174. package/src/internal/FundingQuotes.ts +735 -0
  175. package/src/internal/Log.test.ts +4 -4
  176. package/src/internal/Store.test-d.ts +15 -0
  177. package/src/internal/Store.test.ts +153 -2
  178. package/src/internal/Store.ts +157 -41
  179. package/src/internal/Viem.ts +14 -5
  180. package/src/internal/index.test.ts +34 -0
  181. package/src/internal/index.ts +26 -0
  182. package/dist/apps/data/FundingRouteProviders.d.ts +0 -143
  183. package/dist/apps/data/FundingRouteProviders.d.ts.map +0 -1
  184. package/dist/apps/data/FundingRouteProviders.js +0 -546
  185. package/dist/apps/data/FundingRouteProviders.js.map +0 -1
  186. package/dist/internal/FundingRoutes.d.ts +0 -1085
  187. package/dist/internal/FundingRoutes.d.ts.map +0 -1
  188. package/dist/internal/FundingRoutes.js.map +0 -1
  189. package/src/apps/data/FundingRouteProviders.test.ts +0 -485
  190. package/src/apps/data/FundingRouteProviders.ts +0 -744
  191. package/src/internal/FundingRoutes.test.ts +0 -239
  192. package/src/internal/FundingRoutes.ts +0 -1067
@@ -0,0 +1,251 @@
1
+ import * as FundingQuotes from '../../internal/FundingQuotes.js'
2
+
3
+ type Fetch = typeof globalThis.fetch
4
+
5
+ const defaultFetch: Fetch = (input, init) => globalThis.fetch(input, init)
6
+
7
+ const defaultRelayBaseUrl = 'https://api.relay.link'
8
+
9
+ /** Creates the live funding quote adapters configured for this deployment. */
10
+ export function createAdapters(options: createAdapters.Options = {}) {
11
+ return (
12
+ options.relay ? { relay: relay(options.relay) } : {}
13
+ ) satisfies FundingQuotes.ProviderAdapters
14
+ }
15
+
16
+ export declare namespace createAdapters {
17
+ /** Provider adapter factory options. Omit a provider to leave it unconfigured. */
18
+ type Options = {
19
+ /** Relay quote adapter options. */
20
+ relay?: relay.Options | false | undefined
21
+ }
22
+ }
23
+
24
+ /** Creates a Relay quote adapter backed by `/quote/v2`. */
25
+ export function relay(options: relay.Options): FundingQuotes.ProviderAdapter {
26
+ const baseUrl = normalizeBaseUrl(options.baseUrl ?? defaultRelayBaseUrl)
27
+ const fetcher = options.fetch ?? defaultFetch
28
+ const now = options.now ?? (() => new Date())
29
+
30
+ return {
31
+ async probeQuote({ candidate }, signal) {
32
+ const originChainId = providerChainId(candidate.sourceChain, 'relay')
33
+ const destinationChainId = eip155ChainId(candidate.destinationChain)
34
+ const headers = relayHeaders(options.apiKey)
35
+
36
+ try {
37
+ const quote = await requestJson(new URL(`${baseUrl}/quote/v2`), {
38
+ body: {
39
+ amount: candidate.sourceAmount.amount,
40
+ destinationChainId: Number(destinationChainId),
41
+ destinationCurrency: candidate.destinationToken.address,
42
+ originChainId: Number(originChainId),
43
+ originCurrency: candidate.sourceToken.address,
44
+ recipient: options.recipientAddress ?? options.userAddress,
45
+ tradeType: 'EXACT_INPUT',
46
+ user: options.userAddress,
47
+ },
48
+ fetcher,
49
+ headers,
50
+ method: 'POST',
51
+ signal,
52
+ })
53
+ return formatRelayResult(candidate, quote, now())
54
+ } catch (cause) {
55
+ const unavailable = providerUnavailable(cause, [
56
+ 'NO_QUOTES',
57
+ 'NO_ROUTE',
58
+ 'UNSUPPORTED_ROUTE',
59
+ ])
60
+ if (!unavailable) throw cause
61
+ return unavailableResult({
62
+ detail: 'relay:quote-v2',
63
+ message: unavailable,
64
+ now: now(),
65
+ source: 'providerQuote',
66
+ })
67
+ }
68
+ },
69
+ }
70
+ }
71
+
72
+ export declare namespace relay {
73
+ /** Relay quote adapter options. */
74
+ type Options = {
75
+ /** Probe wallet address required by Relay's quote API. */
76
+ userAddress: string
77
+ /** Optional recipient override; defaults to `userAddress`. */
78
+ recipientAddress?: string | undefined
79
+ /** Optional Relay API key for higher rate limits. */
80
+ apiKey?: string | undefined
81
+ /** Override for tests or Relay testnet environments. */
82
+ baseUrl?: string | undefined
83
+ /** Fetch implementation override for tests. */
84
+ fetch?: Fetch | undefined
85
+ /** Clock override for deterministic tests. */
86
+ now?: (() => Date) | undefined
87
+ }
88
+ }
89
+
90
+ function formatRelayResult(
91
+ candidate: FundingQuotes.QuoteCandidate,
92
+ quote: unknown,
93
+ now: Date,
94
+ ): FundingQuotes.QuoteProbeResult {
95
+ const details = record(record(quote)['details'])
96
+ const currencyOut = record(details['currencyOut'])
97
+ const outputAmount = decimalString(currencyOut['amount'])
98
+ const status = outputAmount
99
+ ? { status: 'available' as const, tier: 'liquid' as const }
100
+ : { status: 'unknown' as const, tier: 'unknown' as const }
101
+
102
+ return {
103
+ minimumOutputAmount: decimalString(currencyOut['minimumAmount']),
104
+ outputAmount,
105
+ outputAmountFormatted: outputAmount
106
+ ? decimalFromBaseUnits(outputAmount, candidate.destinationToken.decimals)
107
+ : undefined,
108
+ quality: {
109
+ estimatedSeconds: nonnegativeInteger(details['timeEstimate']),
110
+ liquiditySource: 'providerQuote',
111
+ sourceDetail: 'relay:quote-v2',
112
+ tier: status.tier,
113
+ },
114
+ sampledAt: now.toISOString(),
115
+ status: status.status,
116
+ }
117
+ }
118
+
119
+ function unavailableResult(options: {
120
+ detail: string
121
+ message: string
122
+ now: Date
123
+ source: string
124
+ }): FundingQuotes.QuoteProbeResult {
125
+ return {
126
+ quality: {
127
+ liquiditySource: options.source,
128
+ sourceDetail: `${options.detail}:${options.message}`,
129
+ tier: 'unavailable',
130
+ },
131
+ sampledAt: options.now.toISOString(),
132
+ status: 'unavailable',
133
+ }
134
+ }
135
+
136
+ async function requestJson(
137
+ url: URL,
138
+ options: {
139
+ body?: unknown
140
+ fetcher: Fetch
141
+ headers?: Record<string, string> | undefined
142
+ method?: string | undefined
143
+ signal: AbortSignal
144
+ },
145
+ ) {
146
+ const headers = { accept: 'application/json', ...options.headers }
147
+ const init: RequestInit = {
148
+ headers:
149
+ options.body === undefined ? headers : { ...headers, 'content-type': 'application/json' },
150
+ method: options.method ?? 'GET',
151
+ signal: options.signal,
152
+ }
153
+ if (options.body !== undefined) init.body = JSON.stringify(options.body)
154
+ const response = await options.fetcher(url, init)
155
+ const text = await response.text()
156
+ const body = parseJson(text)
157
+ if (!response.ok) throw new ProviderResponseError(response.status, body)
158
+ return body
159
+ }
160
+
161
+ function relayHeaders(apiKey: string | undefined): Record<string, string> | undefined {
162
+ return apiKey ? { 'x-api-key': apiKey } : undefined
163
+ }
164
+
165
+ function providerUnavailable(cause: unknown, codes: readonly string[]) {
166
+ if (!(cause instanceof ProviderResponseError) || cause.status < 400 || cause.status >= 500)
167
+ return undefined
168
+ const code = providerErrorCode(cause.body)
169
+ if (code && codes.includes(code)) return code
170
+ return undefined
171
+ }
172
+
173
+ function providerErrorCode(input: unknown) {
174
+ const body = record(input)
175
+ const code = stringValue(body['code']) ?? stringValue(body['errorCode'])
176
+ return code && /^[A-Za-z0-9_.:-]{1,64}$/.test(code) ? code : undefined
177
+ }
178
+
179
+ function providerChainId(
180
+ quoteChain: FundingQuotes.QuoteCandidate['sourceChain'],
181
+ provider: string,
182
+ ) {
183
+ const providerIds = quoteChain.providerIds as Record<string, string> | undefined
184
+ const id = providerIds?.[provider]
185
+ if (id && /^\d+$/.test(id)) return id
186
+ return eip155ChainId(quoteChain)
187
+ }
188
+
189
+ function eip155ChainId(chain: { id: string; kind: string }) {
190
+ if (chain.kind !== 'evm') throw new UnsupportedProviderChainError()
191
+ const match = /^eip155:(\d+)$/.exec(chain.id)
192
+ if (!match?.[1]) throw new UnsupportedProviderChainError()
193
+ return match[1]
194
+ }
195
+
196
+ function normalizeBaseUrl(input: string) {
197
+ return input.replace(/\/+$/, '')
198
+ }
199
+
200
+ function parseJson(input: string) {
201
+ if (!input) return null
202
+ try {
203
+ return JSON.parse(input) as unknown
204
+ } catch {
205
+ return input
206
+ }
207
+ }
208
+
209
+ function record(input: unknown): Record<string, unknown> {
210
+ return input && typeof input === 'object' && !Array.isArray(input)
211
+ ? (input as Record<string, unknown>)
212
+ : {}
213
+ }
214
+
215
+ function stringValue(input: unknown) {
216
+ return typeof input === 'string' && input.length > 0 ? input : undefined
217
+ }
218
+
219
+ function decimalString(input: unknown) {
220
+ if (typeof input === 'string' && /^\d+(\.\d+)?$/.test(input)) return input
221
+ if (typeof input === 'number' && Number.isFinite(input) && input >= 0) return String(input)
222
+ return undefined
223
+ }
224
+
225
+ function nonnegativeInteger(input: unknown) {
226
+ if (typeof input === 'number' && Number.isSafeInteger(input) && input >= 0) return input
227
+ if (typeof input === 'string' && /^\d+$/.test(input)) return Number(input)
228
+ return undefined
229
+ }
230
+
231
+ function decimalFromBaseUnits(baseUnits: string, decimals: number) {
232
+ const padded = baseUnits.padStart(decimals + 1, '0')
233
+ const whole = decimals === 0 ? padded : padded.slice(0, -decimals)
234
+ const fraction = decimals === 0 ? '' : padded.slice(-decimals).replace(/0+$/, '')
235
+ return fraction ? `${whole}.${fraction}` : whole
236
+ }
237
+
238
+ class ProviderResponseError extends Error {
239
+ override name = 'FundingQuoteProviders.ProviderResponseError'
240
+
241
+ constructor(
242
+ readonly status: number,
243
+ readonly body: unknown,
244
+ ) {
245
+ super(`Provider request failed with status ${status}`)
246
+ }
247
+ }
248
+
249
+ class UnsupportedProviderChainError extends Error {
250
+ override name = 'FundingQuoteProviders.UnsupportedProviderChainError'
251
+ }
@@ -1,3 +1,3 @@
1
1
  export { data } from './App.js'
2
- export * as FundingRouteProviders from './FundingRouteProviders.js'
2
+ export * as FundingQuoteProviders from './FundingQuoteProviders.js'
3
3
  export * as Sponsorships from '../relay/Sponsorships.js'
@@ -1,8 +1,10 @@
1
1
  import { Schema } from 'tapimo'
2
2
  import { Value as core_Value } from 'ox'
3
+ import { Actions } from 'viem/tempo'
3
4
 
4
5
  import * as TestApp from '../../../../test/App.js'
5
6
  import * as Runtime from '../../../../test/runtime.js'
7
+ import * as TestViem from '../../../../test/Viem.js'
6
8
  import * as Balances from './balances.js'
7
9
  import * as Tokens from './tokens.js'
8
10
 
@@ -45,7 +47,7 @@ describe('GET /addresses/:address/balances', () => {
45
47
  const body = await TestApp.json(response, Balances.schema.getAddressBalances.Response)
46
48
 
47
49
  expect(response.status).toBe(200)
48
- expect(response.headers.get('server-timing')?.includes('address_balances;dur=')).toBe(true)
50
+ expect(response.headers.get('server-timing')?.includes('balances_discovery;dur=')).toBe(true)
49
51
  expect(Array.isArray(body.data)).toBe(true)
50
52
  expect(body.data.length).toBeLessThanOrEqual(5)
51
53
  // Pagination fields stay at the response root; cursor pagination has no
@@ -72,16 +74,48 @@ describe('GET /addresses/:address/balances', () => {
72
74
  ),
73
75
  ).toBe(true)
74
76
  // Results are restricted to TIP-20 tokens (deterministic `0x20c0…` factory
75
- // addresses), so non-TIP-20 contracts in `token_balances_snapshot` are
76
- // excluded.
77
+ // addresses), so non-TIP-20 contracts in the delta history are excluded.
77
78
  expect(body.data.every((entry) => entry.token.address.startsWith('0x20c0'))).toBe(true)
78
79
  // Amounts are returned largest-first.
79
80
  const amounts = body.data.map((entry) => BigInt(entry.amount))
80
81
  expect(amounts.every((amount, index) => index === 0 || amounts[index - 1]! >= amount)).toBe(
81
82
  true,
82
83
  )
84
+
85
+ const rpc = TestViem.getClient()
86
+ const current = await Promise.all(
87
+ body.data.map((entry) =>
88
+ Actions.token.getBalance(rpc, { account: holder, token: entry.token.address }),
89
+ ),
90
+ )
91
+ expect(body.data.map((entry) => entry.amount)).toEqual(
92
+ current.map((balance) => balance.amount.toString()),
93
+ )
83
94
  })
84
95
 
96
+ test.skipIf(runtime.mode !== 'localnet')(
97
+ 'discovers freshly transferred assets from deltas',
98
+ async () => {
99
+ const fixture = runtime.fixtures?.crossTokenTransfer
100
+ if (!fixture) throw new Error('expected cross-token transfer fixture')
101
+ const client = app()
102
+
103
+ const response = await client.v1.addresses[':address'].balances.$get(
104
+ { param: { address: fixture.recipient }, query: { limit: '5' } },
105
+ TestApp.auth,
106
+ )
107
+ const body = await TestApp.json(response, Balances.schema.getAddressBalances.Response)
108
+
109
+ expect(response.status).toBe(200)
110
+ expect(body.data).toContainEqual(
111
+ expect.objectContaining({
112
+ amount: '1000000',
113
+ id: fixture.destinationToken,
114
+ }),
115
+ )
116
+ },
117
+ )
118
+
85
119
  test('embeds an exact balance total on demand via include=totalCount', async () => {
86
120
  const client = app()
87
121
  const holder = await holderWithBalance(client)
@@ -93,15 +127,14 @@ describe('GET /addresses/:address/balances', () => {
93
127
  const body = await TestApp.json(response, Balances.schema.getAddressBalances.Response)
94
128
 
95
129
  expect(response.status).toBe(200)
96
- expect(response.headers.get('server-timing')?.includes('address_balances_count;dur=')).toBe(
97
- true,
98
- )
130
+ expect(response.headers.get('server-timing')?.includes('balances_discovery;dur=')).toBe(true)
99
131
  expect(Number.isInteger(body.meta?.totalCount)).toBe(true)
100
132
  expect(body.meta!.totalCount).toBeGreaterThanOrEqual(body.data.length)
101
- // The count is sort-key-pruned on ClickHouse, so it is exact (never capped).
133
+ // The count comes from the corrected candidate set and is never capped.
102
134
  expect(body.meta!.totalCountCapped).toBe(false)
103
135
 
104
- // Without the include, `meta` is omitted and no count query runs.
136
+ // Without the include, `meta` is omitted. The corrected candidate set
137
+ // already supplies the count, so no second indexer query is needed.
105
138
  const bareResponse = await client.v1.addresses[':address'].balances.$get(
106
139
  { param: { address: holder }, query: { limit: '5' } },
107
140
  TestApp.auth,
@@ -109,8 +142,8 @@ describe('GET /addresses/:address/balances', () => {
109
142
  const bare = await TestApp.json(bareResponse, Balances.schema.getAddressBalances.Response)
110
143
  expect(bareResponse.status).toBe(200)
111
144
  expect(bare.meta).toBeUndefined()
112
- expect(bareResponse.headers.get('server-timing')?.includes('address_balances_count;dur=')).toBe(
113
- false,
145
+ expect(bareResponse.headers.get('server-timing')?.includes('balances_discovery;dur=')).toBe(
146
+ true,
114
147
  )
115
148
  })
116
149
 
@@ -138,6 +171,27 @@ describe('GET /addresses/:address/balances', () => {
138
171
  `)
139
172
  })
140
173
 
174
+ test('surfaces a 502 when RPC correction is unavailable', async () => {
175
+ const client = app({ rpc: { url: () => 'http://127.0.0.1:1' } })
176
+ const holder = await holderWithBalance(client)
177
+ const response = await client.v1.addresses[':address'].balances.$get(
178
+ { param: { address: holder }, query: {} },
179
+ TestApp.auth,
180
+ )
181
+ const body = await TestApp.json(response, Schema.ErrorResponse)
182
+ const { requestId, error } = body
183
+ const { message, ...stableError } = error
184
+
185
+ expect(response.status).toMatchInlineSnapshot(`502`)
186
+ expect(typeof requestId).toMatchInlineSnapshot(`"string"`)
187
+ expect(typeof message).toMatchInlineSnapshot(`"string"`)
188
+ expect(stableError).toMatchInlineSnapshot(`
189
+ {
190
+ "code": "upstream_error",
191
+ }
192
+ `)
193
+ })
194
+
141
195
  test('rejects invalid account addresses', async () => {
142
196
  // The typed client requires `address: `Hex.Hex`` so an invalid literal
143
197
  // cannot pass through it; exercise the validator via `app.request`.
@@ -250,14 +304,6 @@ describe('GET /addresses/:address/balances', () => {
250
304
  },
251
305
  TestApp.auth,
252
306
  )
253
- if (runtime.mode === 'testnet' && response.status === 502) {
254
- // Hosted TIDX can intermittently reject this live filtered balance query
255
- // with a 422. Localnet still exercises the behavior strictly; on testnet,
256
- // accept the documented upstream-error envelope instead of flaking CI.
257
- const body = await TestApp.json(response, Schema.ErrorResponse)
258
- expect(body.error.code).toBe('upstream_error')
259
- return
260
- }
261
307
  const body = await TestApp.json(response, Balances.schema.getAddressBalances.Response)
262
308
  expect(response.status).toBe(200)
263
309