tapimo 0.6.0 → 0.7.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 (250) hide show
  1. package/dist/App.d.ts +8641 -8263
  2. package/dist/App.d.ts.map +1 -1
  3. package/dist/App.js +9 -1
  4. package/dist/App.js.map +1 -1
  5. package/dist/Scope.d.ts +4 -0
  6. package/dist/Scope.d.ts.map +1 -1
  7. package/dist/Scope.js +5 -0
  8. package/dist/Scope.js.map +1 -1
  9. package/dist/admin/App.d.ts +112 -11
  10. package/dist/admin/App.d.ts.map +1 -1
  11. package/dist/admin/apps/api-keys.d.ts +2 -1
  12. package/dist/admin/apps/api-keys.d.ts.map +1 -1
  13. package/dist/admin/apps/early-access.d.ts +2 -2
  14. package/dist/admin/apps/early-access.d.ts.map +1 -1
  15. package/dist/admin/apps/early-access.js +5 -5
  16. package/dist/admin/apps/early-access.js.map +1 -1
  17. package/dist/admin/apps/organizations.d.ts +124 -1
  18. package/dist/admin/apps/organizations.d.ts.map +1 -1
  19. package/dist/admin/apps/organizations.js +97 -6
  20. package/dist/admin/apps/organizations.js.map +1 -1
  21. package/dist/admin/ui.generated.d.ts +1 -1
  22. package/dist/admin/ui.generated.d.ts.map +1 -1
  23. package/dist/admin/ui.generated.js +95 -87
  24. package/dist/admin/ui.generated.js.map +1 -1
  25. package/dist/analytics/Analytics.d.ts.map +1 -1
  26. package/dist/analytics/Analytics.js +20 -0
  27. package/dist/analytics/Analytics.js.map +1 -1
  28. package/dist/analytics/tables/requestEvents.d.ts +18 -0
  29. package/dist/analytics/tables/requestEvents.d.ts.map +1 -1
  30. package/dist/analytics/tables/requestEvents.js +36 -7
  31. package/dist/analytics/tables/requestEvents.js.map +1 -1
  32. package/dist/apps/data/App.d.ts +5016 -4985
  33. package/dist/apps/data/App.d.ts.map +1 -1
  34. package/dist/apps/data/FundingQuoteProviders.js +2 -15
  35. package/dist/apps/data/FundingQuoteProviders.js.map +1 -1
  36. package/dist/apps/data/FxOracle.d.ts +1 -1
  37. package/dist/apps/data/FxOracle.d.ts.map +1 -1
  38. package/dist/apps/data/FxOracle.js +1 -1
  39. package/dist/apps/data/FxOracle.js.map +1 -1
  40. package/dist/apps/data/routes/activities.d.ts +27 -27
  41. package/dist/apps/data/routes/activities.d.ts.map +1 -1
  42. package/dist/apps/data/routes/activities.js +21 -1
  43. package/dist/apps/data/routes/activities.js.map +1 -1
  44. package/dist/apps/data/routes/balances.d.ts +58 -58
  45. package/dist/apps/data/routes/blocks.d.ts +39 -39
  46. package/dist/apps/data/routes/coingecko.d.ts +176 -176
  47. package/dist/apps/data/routes/exchanges.d.ts +113 -113
  48. package/dist/apps/data/routes/fee-amm.d.ts +105 -91
  49. package/dist/apps/data/routes/fee-amm.d.ts.map +1 -1
  50. package/dist/apps/data/routes/fee-amm.js +76 -91
  51. package/dist/apps/data/routes/fee-amm.js.map +1 -1
  52. package/dist/apps/data/routes/funding.d.ts +72 -34
  53. package/dist/apps/data/routes/funding.d.ts.map +1 -1
  54. package/dist/apps/data/routes/funding.js +34 -26
  55. package/dist/apps/data/routes/funding.js.map +1 -1
  56. package/dist/apps/data/routes/indexer.d.ts +5 -5
  57. package/dist/apps/data/routes/receipts.d.ts +6 -6
  58. package/dist/apps/data/routes/rpc.d.ts +31 -31
  59. package/dist/apps/data/routes/tokens.d.ts +2 -2
  60. package/dist/apps/data/routes/tokens.js +1 -1
  61. package/dist/apps/data/routes/transactions.d.ts +4 -4
  62. package/dist/apps/data/routes/transfers.d.ts +99 -88
  63. package/dist/apps/data/routes/transfers.d.ts.map +1 -1
  64. package/dist/apps/data/routes/transfers.js +226 -141
  65. package/dist/apps/data/routes/transfers.js.map +1 -1
  66. package/dist/apps/data/routes/valuation.d.ts +34 -34
  67. package/dist/apps/data/routes/valuation.js +3 -3
  68. package/dist/apps/data/routes/valuation.js.map +1 -1
  69. package/dist/apps/data/routes/verified-tokens.d.ts +36 -36
  70. package/dist/apps/data/routes/webhooks.d.ts +366 -366
  71. package/dist/apps/index.d.ts +1 -0
  72. package/dist/apps/index.d.ts.map +1 -1
  73. package/dist/apps/index.js +1 -0
  74. package/dist/apps/index.js.map +1 -1
  75. package/dist/apps/management/App.d.ts +460 -523
  76. package/dist/apps/management/App.d.ts.map +1 -1
  77. package/dist/apps/management/routes/api-keys.d.ts +7 -23
  78. package/dist/apps/management/routes/api-keys.d.ts.map +1 -1
  79. package/dist/apps/management/routes/api-keys.js +20 -8
  80. package/dist/apps/management/routes/api-keys.js.map +1 -1
  81. package/dist/apps/management/routes/billing.d.ts +106 -151
  82. package/dist/apps/management/routes/billing.d.ts.map +1 -1
  83. package/dist/apps/management/routes/billing.js +28 -28
  84. package/dist/apps/management/routes/billing.js.map +1 -1
  85. package/dist/apps/management/routes/faucet.d.ts +40 -26
  86. package/dist/apps/management/routes/faucet.d.ts.map +1 -1
  87. package/dist/apps/management/routes/faucet.js +33 -18
  88. package/dist/apps/management/routes/faucet.js.map +1 -1
  89. package/dist/apps/management/routes/invitations.d.ts +131 -131
  90. package/dist/apps/management/routes/invitations.d.ts.map +1 -1
  91. package/dist/apps/management/routes/invitations.js +0 -3
  92. package/dist/apps/management/routes/invitations.js.map +1 -1
  93. package/dist/apps/management/routes/me.d.ts +6 -8
  94. package/dist/apps/management/routes/me.d.ts.map +1 -1
  95. package/dist/apps/management/routes/me.js +0 -4
  96. package/dist/apps/management/routes/me.js.map +1 -1
  97. package/dist/apps/management/routes/members.d.ts +45 -45
  98. package/dist/apps/management/routes/orgs.d.ts +94 -94
  99. package/dist/apps/management/routes/orgs.d.ts.map +1 -1
  100. package/dist/apps/management/routes/orgs.js +5 -0
  101. package/dist/apps/management/routes/orgs.js.map +1 -1
  102. package/dist/apps/management/routes/usage.d.ts +32 -32
  103. package/dist/apps/management/routes/verified-token-requests.d.ts +94 -94
  104. package/dist/apps/management/routes/webhooks.d.ts +1 -1
  105. package/dist/apps/mpp/App.d.ts +363 -0
  106. package/dist/apps/mpp/App.d.ts.map +1 -0
  107. package/dist/apps/mpp/App.js +72 -0
  108. package/dist/apps/mpp/App.js.map +1 -0
  109. package/dist/apps/mpp/Mpp.d.ts +178 -0
  110. package/dist/apps/mpp/Mpp.d.ts.map +1 -0
  111. package/dist/apps/mpp/Mpp.js +183 -0
  112. package/dist/apps/mpp/Mpp.js.map +1 -0
  113. package/dist/apps/mpp/index.d.ts +3 -0
  114. package/dist/apps/mpp/index.d.ts.map +1 -0
  115. package/dist/apps/mpp/index.js +3 -0
  116. package/dist/apps/mpp/index.js.map +1 -0
  117. package/dist/apps/relay/App.d.ts.map +1 -1
  118. package/dist/apps/relay/App.js +56 -9
  119. package/dist/apps/relay/App.js.map +1 -1
  120. package/dist/db/Db.d.ts +2 -0
  121. package/dist/db/Db.d.ts.map +1 -1
  122. package/dist/db/Db.js +27 -0
  123. package/dist/db/Db.js.map +1 -1
  124. package/dist/db/Schema.d.ts +12 -0
  125. package/dist/db/Schema.d.ts.map +1 -1
  126. package/dist/db/Schema.js +11 -0
  127. package/dist/db/Schema.js.map +1 -1
  128. package/dist/db/tables/enabledBillingSources.d.ts +73 -0
  129. package/dist/db/tables/enabledBillingSources.d.ts.map +1 -0
  130. package/dist/db/tables/enabledBillingSources.js +69 -0
  131. package/dist/db/tables/enabledBillingSources.js.map +1 -0
  132. package/dist/db/tables/invitations.d.ts +1 -12
  133. package/dist/db/tables/invitations.d.ts.map +1 -1
  134. package/dist/db/tables/invitations.js +3 -23
  135. package/dist/db/tables/invitations.js.map +1 -1
  136. package/dist/db/tables/organizations.d.ts +5 -2
  137. package/dist/db/tables/organizations.d.ts.map +1 -1
  138. package/dist/db/tables/organizations.js +13 -2
  139. package/dist/db/tables/organizations.js.map +1 -1
  140. package/dist/handlers/internal/utils.d.ts +1 -16
  141. package/dist/handlers/internal/utils.d.ts.map +1 -1
  142. package/dist/handlers/internal/utils.js +17 -6
  143. package/dist/handlers/internal/utils.js.map +1 -1
  144. package/dist/handlers/relay.d.ts.map +1 -1
  145. package/dist/handlers/relay.js +37 -5
  146. package/dist/handlers/relay.js.map +1 -1
  147. package/dist/internal/Auth.d.ts +0 -32
  148. package/dist/internal/Auth.d.ts.map +1 -1
  149. package/dist/internal/Auth.js +3 -51
  150. package/dist/internal/Auth.js.map +1 -1
  151. package/dist/internal/FundingQuotes.d.ts +78 -32
  152. package/dist/internal/FundingQuotes.d.ts.map +1 -1
  153. package/dist/internal/FundingQuotes.js +45 -8
  154. package/dist/internal/FundingQuotes.js.map +1 -1
  155. package/dist/internal/Log.d.ts +18 -4
  156. package/dist/internal/Log.d.ts.map +1 -1
  157. package/dist/internal/Log.js +50 -3
  158. package/dist/internal/Log.js.map +1 -1
  159. package/dist/internal/MetricSink.d.ts.map +1 -1
  160. package/dist/internal/MetricSink.js +15 -0
  161. package/dist/internal/MetricSink.js.map +1 -1
  162. package/dist/internal/Response.d.ts +2 -2
  163. package/dist/internal/Response.d.ts.map +1 -1
  164. package/dist/internal/Response.js +12 -4
  165. package/dist/internal/Response.js.map +1 -1
  166. package/dist/internal/Viem.d.ts +1 -1
  167. package/package.json +7 -2
  168. package/src/App.test.ts +9 -4
  169. package/src/App.ts +14 -2
  170. package/src/Client.test-d.ts +109 -28
  171. package/src/Scope.ts +5 -0
  172. package/src/admin/App.test.ts +4 -0
  173. package/src/admin/apps/early-access.ts +6 -5
  174. package/src/admin/apps/organizations.test.ts +86 -2
  175. package/src/admin/apps/organizations.ts +155 -31
  176. package/src/admin/apps/scopes.test.ts +5 -0
  177. package/src/admin/ui/src/lib/organizations.test.ts +81 -1
  178. package/src/admin/ui/src/lib/organizations.ts +92 -0
  179. package/src/admin/ui/src/routes/api-keys.tsx +1 -1
  180. package/src/admin/ui/src/routes/early-access.tsx +8 -3
  181. package/src/admin/ui/src/routes/organizations_.$orgId.tsx +103 -0
  182. package/src/admin/ui.generated.ts +95 -109
  183. package/src/analytics/Analytics.test.ts +83 -0
  184. package/src/analytics/Analytics.ts +20 -0
  185. package/src/analytics/tables/requestEvents.ts +60 -7
  186. package/src/apps/composition.test.ts +11 -2
  187. package/src/apps/data/App.test.ts +1 -1
  188. package/src/apps/data/FundingQuoteProviders.test.ts +0 -5
  189. package/src/apps/data/FundingQuoteProviders.ts +2 -29
  190. package/src/apps/data/FxOracle.test.ts +2 -2
  191. package/src/apps/data/FxOracle.ts +2 -2
  192. package/src/apps/data/routes/activities.test.ts +6 -2
  193. package/src/apps/data/routes/activities.ts +39 -1
  194. package/src/apps/data/routes/balances.test.ts +6 -2
  195. package/src/apps/data/routes/exchanges.test.ts +7 -3
  196. package/src/apps/data/routes/fee-amm.test.ts +47 -23
  197. package/src/apps/data/routes/fee-amm.ts +98 -119
  198. package/src/apps/data/routes/funding.test.ts +29 -8
  199. package/src/apps/data/routes/funding.ts +42 -48
  200. package/src/apps/data/routes/receipts.test.ts +6 -2
  201. package/src/apps/data/routes/tokens.ts +1 -1
  202. package/src/apps/data/routes/transfers.test.ts +203 -35
  203. package/src/apps/data/routes/transfers.ts +294 -172
  204. package/src/apps/data/routes/valuation.test.ts +18 -3
  205. package/src/apps/data/routes/valuation.ts +4 -4
  206. package/src/apps/index.ts +1 -0
  207. package/src/apps/management/routes/api-keys.test.ts +2 -75
  208. package/src/apps/management/routes/api-keys.ts +30 -7
  209. package/src/apps/management/routes/billing.test.ts +106 -85
  210. package/src/apps/management/routes/billing.ts +32 -21
  211. package/src/apps/management/routes/faucet.test.ts +11 -3
  212. package/src/apps/management/routes/faucet.ts +46 -30
  213. package/src/apps/management/routes/invitations.test.ts +0 -172
  214. package/src/apps/management/routes/invitations.ts +0 -3
  215. package/src/apps/management/routes/me.test.ts +3 -145
  216. package/src/apps/management/routes/me.ts +0 -6
  217. package/src/apps/management/routes/orgs.test.ts +45 -2
  218. package/src/apps/management/routes/orgs.ts +6 -0
  219. package/src/apps/management/routes/scopes.test.ts +1 -0
  220. package/src/apps/mpp/App.ts +86 -0
  221. package/src/apps/mpp/Mpp.test.ts +193 -0
  222. package/src/apps/mpp/Mpp.ts +353 -0
  223. package/src/apps/mpp/index.ts +2 -0
  224. package/src/apps/mpp/internal/Adapter.ts +11 -0
  225. package/src/apps/mpp/internal/Error.ts +9 -0
  226. package/src/apps/mpp/internal/Idempotency.ts +7 -0
  227. package/src/apps/mpp/internal/Screening.ts +10 -0
  228. package/src/apps/relay/App.test.ts +88 -6
  229. package/src/apps/relay/App.ts +61 -9
  230. package/src/db/Db.test.ts +2 -2
  231. package/src/db/Db.ts +30 -0
  232. package/src/db/Schema.ts +15 -0
  233. package/src/db/tables/enabledBillingSources.test.ts +303 -0
  234. package/src/db/tables/enabledBillingSources.ts +118 -0
  235. package/src/db/tables/invitations.ts +4 -26
  236. package/src/db/tables/organizations.test.ts +103 -0
  237. package/src/db/tables/organizations.ts +18 -2
  238. package/src/handlers/internal/utils.ts +22 -6
  239. package/src/handlers/relay.errors.test.ts +153 -0
  240. package/src/handlers/relay.ts +42 -7
  241. package/src/internal/Auth.test.ts +3 -3
  242. package/src/internal/Auth.ts +3 -63
  243. package/src/internal/FundingQuotes.test.ts +18 -5
  244. package/src/internal/FundingQuotes.ts +46 -11
  245. package/src/internal/Log.test.ts +91 -0
  246. package/src/internal/Log.ts +69 -5
  247. package/src/internal/MetricSink.test.ts +55 -0
  248. package/src/internal/MetricSink.ts +14 -0
  249. package/src/internal/Response.test.ts +28 -0
  250. package/src/internal/Response.ts +12 -4
@@ -36,6 +36,9 @@ const row = {
36
36
  rate_limit_scope: null,
37
37
  request_id: 'req-1',
38
38
  route: '/v1/transactions',
39
+ rpc_error_code: null,
40
+ rpc_error_count: 0,
41
+ rpc_error_data_code: null,
39
42
  service: 'api',
40
43
  status: 200,
41
44
  timestamp: '2026-01-01 00:00:00.000',
@@ -211,6 +214,9 @@ describe('createQueueSink', () => {
211
214
  rate_limit_scope: row.rate_limit_scope,
212
215
  request_id: row.request_id,
213
216
  route: row.route,
217
+ rpc_error_code: row.rpc_error_code,
218
+ rpc_error_count: row.rpc_error_count,
219
+ rpc_error_data_code: row.rpc_error_data_code,
214
220
  service: row.service,
215
221
  status: row.status,
216
222
  timestamp: '<timestamp>',
@@ -229,6 +235,9 @@ describe('createQueueSink', () => {
229
235
  "rate_limit_scope": null,
230
236
  "request_id": "req-1",
231
237
  "route": "/v1/transactions",
238
+ "rpc_error_code": null,
239
+ "rpc_error_count": 0,
240
+ "rpc_error_data_code": null,
232
241
  "service": "api",
233
242
  "status": 200,
234
243
  "timestamp": "<timestamp>",
@@ -272,6 +281,24 @@ describe('createQueueSink', () => {
272
281
  })
273
282
  })
274
283
 
284
+ test('records JSON-RPC error metadata', async () => {
285
+ const send = vi.fn<(message: Analytics.Event) => Promise<void>>().mockResolvedValue(undefined)
286
+ const sink = Analytics.createQueueSink({ send })(context)
287
+
288
+ await sink({
289
+ ...entry,
290
+ level: 'warn',
291
+ rpc: { code: -32602, dataCode: 'billing_required', errors: 2 },
292
+ })
293
+
294
+ expect(send.mock.calls[0]![0]).toMatchObject({
295
+ rpc_error_code: -32602,
296
+ rpc_error_count: 2,
297
+ rpc_error_data_code: 'billing_required',
298
+ status: 200,
299
+ })
300
+ })
301
+
275
302
  test('rethrows when the retry also fails', async () => {
276
303
  const send = vi
277
304
  .fn<(message: Analytics.Event) => Promise<void>>()
@@ -399,6 +426,7 @@ describe('readProjectUsage', () => {
399
426
  expect(query).toContain(`key_environment = 'sandbox'`)
400
427
  expect(query).not.toContain('key_id IN')
401
428
  expect(query).toContain('LIMIT 100')
429
+ expect(query).toContain('status >= 400 OR rpc_error_count > 0')
402
430
  expect(new URL(String(fetch.mock.calls[0]![0])).searchParams.get('query')).toBeNull()
403
431
  expect(usage).toMatchObject({
404
432
  from: '2026-01-01T00:00:00.000Z',
@@ -408,6 +436,29 @@ describe('readProjectUsage', () => {
408
436
  })
409
437
  })
410
438
 
439
+ test('includes JSON-RPC failures in the error breakdown', async () => {
440
+ const fetch = vi.fn<typeof globalThis.fetch>().mockResolvedValue(
441
+ new Response(JSON.stringify({ data: [{ code: 'billing_required', requests: '2' }] }), {
442
+ status: 200,
443
+ }),
444
+ )
445
+ vi.stubGlobal('fetch', fetch)
446
+
447
+ const breakdown = await RequestEvents.readErrorBreakdown(Analytics.clickhouse(clickHouse), {
448
+ attributions: [{ projectId: 'prj_1' }],
449
+ from: '2026-01-01T00:00:00.000Z',
450
+ interval: 'day',
451
+ orgId: 'org_1',
452
+ to: '2026-01-02T00:00:00.000Z',
453
+ })
454
+
455
+ expect(fetch.mock.calls[0]![1]?.body).toEqual(expect.stringContaining('rpc_error_data_code'))
456
+ expect(fetch.mock.calls[0]![1]?.body).toEqual(
457
+ expect.stringContaining('status >= 400 OR rpc_error_count > 0'),
458
+ )
459
+ expect(breakdown).toEqual([{ code: 'billing_required', requests: 2 }])
460
+ })
461
+
411
462
  test('keeps legacy rows scoped to project keys without global org filtering', async () => {
412
463
  const fetch = vi
413
464
  .fn<typeof globalThis.fetch>()
@@ -525,3 +576,35 @@ describe('readProjectUsage', () => {
525
576
  })
526
577
  })
527
578
  })
579
+
580
+ describe('readLastUsedAt', () => {
581
+ test('returns the latest request timestamp for each requested key', async () => {
582
+ const fetch = vi.fn<typeof globalThis.fetch>().mockImplementation(async (_url, init) => {
583
+ const sql = init?.body
584
+ if (typeof sql !== 'string') throw new Error('Expected a SQL request body.')
585
+ expect(sql).toContain("key_id IN ('key_1', 'key_2')")
586
+ expect(sql).toContain('max(timestamp)')
587
+ return new Response(
588
+ JSON.stringify({
589
+ data: [{ key_id: 'key_1', lastUsedAt: '2026-07-21T12:34:56.000Z' }],
590
+ }),
591
+ { status: 200 },
592
+ )
593
+ })
594
+ vi.stubGlobal('fetch', fetch)
595
+
596
+ await expect(
597
+ RequestEvents.readLastUsedAt(Analytics.clickhouse(clickHouse), ['key_1', 'key_2']),
598
+ ).resolves.toEqual([{ apiKeyId: 'key_1', lastUsedAt: '2026-07-21T12:34:56.000Z' }])
599
+ })
600
+
601
+ test('skips the analytics query when no keys are requested', async () => {
602
+ const fetch = vi.fn<typeof globalThis.fetch>()
603
+ vi.stubGlobal('fetch', fetch)
604
+
605
+ await expect(
606
+ RequestEvents.readLastUsedAt(Analytics.clickhouse(clickHouse), []),
607
+ ).resolves.toEqual([])
608
+ expect(fetch).not.toHaveBeenCalled()
609
+ })
610
+ })
@@ -131,6 +131,9 @@ export const migrations: readonly Migration[] = [
131
131
  project_id Nullable(String),
132
132
  rate_limit_scope Nullable(String),
133
133
  error_code Nullable(String),
134
+ rpc_error_code Nullable(Int32),
135
+ rpc_error_data_code Nullable(String),
136
+ rpc_error_count UInt16 DEFAULT 0,
134
137
  request_id Nullable(String),
135
138
  billing_active Nullable(UInt8)
136
139
  )
@@ -156,6 +159,23 @@ export const migrations: readonly Migration[] = [
156
159
  ADD COLUMN IF NOT EXISTS billing_active Nullable(UInt8)
157
160
  `,
158
161
  },
162
+ {
163
+ name: '0003_request_events_rpc_errors',
164
+ // Patches tables created before RPC columns entered the baseline. Fresh
165
+ // tables include them, allowing scoped preview users without ALTER to skip.
166
+ guard: `
167
+ SELECT toUInt8(count() = 3) FROM system.columns
168
+ WHERE database = currentDatabase()
169
+ AND table = 'request_events'
170
+ AND name IN ('rpc_error_code', 'rpc_error_data_code', 'rpc_error_count')
171
+ `,
172
+ sql: `
173
+ ALTER TABLE request_events
174
+ ADD COLUMN IF NOT EXISTS rpc_error_code Nullable(Int32),
175
+ ADD COLUMN IF NOT EXISTS rpc_error_data_code Nullable(String),
176
+ ADD COLUMN IF NOT EXISTS rpc_error_count UInt16 DEFAULT 0
177
+ `,
178
+ },
159
179
  ]
160
180
 
161
181
  /**
@@ -30,6 +30,12 @@ export type Table = {
30
30
  principal_type: 'api_key' | 'public' | 'session' | 'super_admin' | 'unknown'
31
31
  /** Rate-limit bucket scope, when a rate limit was applied. */
32
32
  rate_limit_scope: string | null
33
+ /** Numeric JSON-RPC error code when every failed response shares one code. */
34
+ rpc_error_code: number | null
35
+ /** Stable application error code from JSON-RPC `error.data.code`. */
36
+ rpc_error_data_code: string | null
37
+ /** Number of failed JSON-RPC responses in the request. */
38
+ rpc_error_count: number
33
39
  /** Stable per-request id (`entry.requestId`); dedups the billing count via `uniqExact`. */
34
40
  request_id: string | null
35
41
  /** Bounded matched route pattern. */
@@ -79,6 +85,14 @@ export type KeyBreakdown = {
79
85
  requests: number
80
86
  }
81
87
 
88
+ /** Latest authenticated request timestamp for one API key. */
89
+ export type LastUsedAt = {
90
+ /** API key id (`key_…`). */
91
+ apiKeyId: string
92
+ /** Latest request completion timestamp (ISO 8601). */
93
+ lastUsedAt: string
94
+ }
95
+
82
96
  /** Time bucket size for the usage series. */
83
97
  export type Interval = 'day' | 'hour'
84
98
 
@@ -227,6 +241,32 @@ export declare namespace insertMessages {
227
241
  }
228
242
  }
229
243
 
244
+ /**
245
+ * Reads the latest authenticated request timestamp for each requested API key.
246
+ * Keys with no request events are omitted.
247
+ */
248
+ export async function readLastUsedAt(
249
+ analytics: Analytics.Analytics,
250
+ apiKeyIds: readonly string[],
251
+ ): Promise<LastUsedAt[]> {
252
+ if (apiKeyIds.length === 0) return []
253
+ const rows = await analytics.query<LastUsedAtRow>(
254
+ `
255
+ SELECT
256
+ key_id,
257
+ formatDateTime(max(timestamp), '%Y-%m-%dT%H:%i:%S.000Z', 'UTC') AS lastUsedAt
258
+ FROM ${table}
259
+ WHERE principal_type = 'api_key'
260
+ AND key_id IN (${apiKeyIds.map(literal).join(', ')})
261
+ GROUP BY key_id
262
+ `,
263
+ )
264
+ return rows.map((row) => ({
265
+ apiKeyId: String(row.key_id),
266
+ lastUsedAt: String(row.lastUsedAt),
267
+ }))
268
+ }
269
+
230
270
  /**
231
271
  * Reads request usage for one project from the `request_events` table.
232
272
  *
@@ -247,7 +287,7 @@ export async function readProjectUsage(
247
287
  SELECT
248
288
  key_id,
249
289
  count() AS requests,
250
- countIf(status >= 400) AS errors,
290
+ countIf(status >= 400 OR rpc_error_count > 0) AS errors,
251
291
  ${average} AS averageDurationMs
252
292
  FROM ${table}
253
293
  WHERE ${where} AND key_id IS NOT NULL
@@ -261,7 +301,7 @@ export async function readProjectUsage(
261
301
  SELECT
262
302
  route,
263
303
  count() AS requests,
264
- countIf(status >= 400) AS errors,
304
+ countIf(status >= 400 OR rpc_error_count > 0) AS errors,
265
305
  ${average} AS averageDurationMs
266
306
  FROM ${table}
267
307
  WHERE ${where}
@@ -286,7 +326,7 @@ export async function readProjectUsage(
286
326
  SELECT
287
327
  formatDateTime(${bucket}, '%Y-%m-%dT%H:%i:%S.000Z', 'UTC') AS time,
288
328
  count() AS requests,
289
- countIf(status >= 400) AS errors
329
+ countIf(status >= 400 OR rpc_error_count > 0) AS errors
290
330
  FROM ${table}
291
331
  WHERE ${where}
292
332
  GROUP BY ${bucket}
@@ -297,7 +337,7 @@ export async function readProjectUsage(
297
337
  `
298
338
  SELECT
299
339
  count() AS requests,
300
- countIf(status >= 400) AS errors,
340
+ countIf(status >= 400 OR rpc_error_count > 0) AS errors,
301
341
  ${average} AS averageDurationMs
302
342
  FROM ${table}
303
343
  WHERE ${where}
@@ -347,11 +387,16 @@ export async function readErrorBreakdown(
347
387
  const rows = await analytics.query<ErrorRow>(
348
388
  `
349
389
  SELECT
350
- coalesce(error_code, 'unknown') AS code,
390
+ coalesce(
391
+ error_code,
392
+ rpc_error_data_code,
393
+ if(rpc_error_count > 0 AND rpc_error_code IS NOT NULL, concat('rpc_', toString(rpc_error_code)), NULL),
394
+ if(rpc_error_count > 0, 'rpc_multiple', 'unknown')
395
+ ) AS code,
351
396
  count() AS requests
352
397
  FROM ${table}
353
- WHERE ${usageWhere(options)} AND status >= 400
354
- GROUP BY error_code
398
+ WHERE ${usageWhere(options)} AND (status >= 400 OR rpc_error_count > 0)
399
+ GROUP BY code
355
400
  ORDER BY requests DESC, code ASC
356
401
  LIMIT 25
357
402
  `,
@@ -434,6 +479,11 @@ type KeyRow = {
434
479
  requests: number | string
435
480
  }
436
481
 
482
+ type LastUsedAtRow = {
483
+ key_id: string
484
+ lastUsedAt: string
485
+ }
486
+
437
487
  type ErrorRow = {
438
488
  code: string
439
489
  requests: number | string
@@ -480,6 +530,9 @@ function event(entry: Log.Entry, context: Metrics.cloudflare.Context): Table {
480
530
  rate_limit_scope: entry.rateLimit?.scope ?? null,
481
531
  request_id: entry.requestId ?? null,
482
532
  route: entry.route,
533
+ rpc_error_code: entry.rpc?.code ?? null,
534
+ rpc_error_count: entry.rpc?.errors ?? 0,
535
+ rpc_error_data_code: entry.rpc?.dataCode ?? null,
483
536
  service: context.service ?? 'unknown',
484
537
  status: entry.status,
485
538
  timestamp: timestamp(),
@@ -1,5 +1,5 @@
1
1
  import { App, Store } from 'tapimo'
2
- import { data, management, relay } from 'tapimo/apps'
2
+ import { data, management, mpp, relay } from 'tapimo/apps'
3
3
  import { Hono } from 'hono'
4
4
  import { describeRoute } from 'hono-openapi'
5
5
  import * as TestApp from '../../test/App.js'
@@ -190,7 +190,7 @@ describe('data()', () => {
190
190
  })
191
191
 
192
192
  describe('relay()', () => {
193
- test('mounts the relay after data() and stays out of the document', async () => {
193
+ test('mounts the relay after data() without documenting RPC routes', async () => {
194
194
  const composed = App.create({ db: TestApp.database() }).route('/', data()).route('/', relay())
195
195
 
196
196
  // Mounted but default-closed and hidden: unauthenticated requests are
@@ -209,6 +209,15 @@ describe('relay()', () => {
209
209
  })
210
210
  })
211
211
 
212
+ describe('mpp()', () => {
213
+ test('mounts documented MPP routes independently', async () => {
214
+ const app = App.create({ db: TestApp.database() }).route('/', mpp())
215
+
216
+ expect((await paths(app)).some((path) => path.startsWith('/v1/mpp'))).toBe(true)
217
+ expect((await app.request('/rpc/relay')).status).toBe(404)
218
+ })
219
+ })
220
+
212
221
  describe('management()', () => {
213
222
  test('mounts management routes and omits data routes', async () => {
214
223
  const app = App.create({
@@ -254,7 +254,7 @@ describe('zone chain gate', () => {
254
254
  for (const path of [
255
255
  `/v1/tokens?chainId=${zone}`,
256
256
  `/v1/addresses/0x0000000000000000000000000000000000000001/activities?chainId=${zone}`,
257
- `/v1/transfers?chainId=${zone}&include=token`,
257
+ `/v1/transfers?chainId=${zone}&include=token.verified`,
258
258
  ]) {
259
259
  const response = await app.request(path)
260
260
  const body = (await response.json()) as { error: { code: string; message: string } }
@@ -84,7 +84,6 @@ describe('FundingQuoteProviders', () => {
84
84
  expiresAt: '2026-01-01T00:01:00.000Z',
85
85
  minimumOutputAmount: '988000',
86
86
  outputAmount: '990000',
87
- outputAmountFormatted: '0.99',
88
87
  quality: {
89
88
  estimatedSeconds: 2,
90
89
  liquiditySource: 'providerQuote',
@@ -185,7 +184,6 @@ describe('FundingQuoteProviders', () => {
185
184
  expect(result).toMatchObject({
186
185
  minimumOutputAmount: '980000',
187
186
  outputAmount: '990000',
188
- outputAmountFormatted: '0.99',
189
187
  quality: {
190
188
  estimatedSeconds: 15,
191
189
  liquiditySource: 'providerQuote',
@@ -230,7 +228,6 @@ describe('FundingQuoteProviders', () => {
230
228
  expect(result).toEqual({
231
229
  minimumOutputAmount: '989622',
232
230
  outputAmount: '999620',
233
- outputAmountFormatted: '0.99962',
234
231
  quality: {
235
232
  estimatedSeconds: 10,
236
233
  liquiditySource: 'providerQuote',
@@ -308,7 +305,6 @@ describe('FundingQuoteProviders', () => {
308
305
  expect(result).toEqual({
309
306
  minimumOutputAmount: '979000',
310
307
  outputAmount: '989000',
311
- outputAmountFormatted: '0.989',
312
308
  quality: {
313
309
  estimatedSeconds: 18,
314
310
  liquiditySource: 'providerQuote',
@@ -480,7 +476,6 @@ describe('FundingQuoteProviders', () => {
480
476
  })
481
477
  expect(result).toEqual({
482
478
  outputAmount: '983000',
483
- outputAmountFormatted: '0.983',
484
479
  quality: {
485
480
  estimatedSeconds: 2,
486
481
  liquiditySource: 'providerQuote',
@@ -292,7 +292,7 @@ export function relay(options: relay.Options): FundingQuotes.ProviderAdapter {
292
292
  method: 'POST',
293
293
  signal,
294
294
  })
295
- return formatRelayResult(candidate, quote, now())
295
+ return formatRelayResult(quote, now())
296
296
  } catch (cause) {
297
297
  const unavailable = providerUnavailable(cause, [
298
298
  'NO_QUOTES',
@@ -600,10 +600,6 @@ function formatAcrossResult(
600
600
  expiresAt: expiresAt.toISOString(),
601
601
  minimumOutputAmount: response.minOutputAmount,
602
602
  outputAmount: response.expectedOutputAmount,
603
- outputAmountFormatted: decimalFromBaseUnits(
604
- response.expectedOutputAmount,
605
- candidate.destinationToken.decimals,
606
- ),
607
603
  quality: {
608
604
  estimatedSeconds: response.expectedFillTime,
609
605
  liquiditySource: 'providerQuote',
@@ -615,11 +611,7 @@ function formatAcrossResult(
615
611
  }
616
612
  }
617
613
 
618
- function formatRelayResult(
619
- candidate: FundingQuotes.QuoteCandidate,
620
- quote: unknown,
621
- now: Date,
622
- ): FundingQuotes.QuoteProbeResult {
614
+ function formatRelayResult(quote: unknown, now: Date): FundingQuotes.QuoteProbeResult {
623
615
  const details = record(record(quote)['details'])
624
616
  const currencyOut = record(details['currencyOut'])
625
617
  const outputAmount = decimalString(currencyOut['amount'])
@@ -630,9 +622,6 @@ function formatRelayResult(
630
622
  return {
631
623
  minimumOutputAmount: decimalString(currencyOut['minimumAmount']),
632
624
  outputAmount,
633
- outputAmountFormatted: outputAmount
634
- ? decimalFromBaseUnits(outputAmount, candidate.destinationToken.decimals)
635
- : undefined,
636
625
  quality: {
637
626
  estimatedSeconds: nonnegativeInteger(details['timeEstimate']),
638
627
  liquiditySource: 'providerQuote',
@@ -669,7 +658,6 @@ function formatRhinoResult(
669
658
 
670
659
  return {
671
660
  outputAmount,
672
- outputAmountFormatted: decimalFromBaseUnits(outputAmount, candidate.destinationToken.decimals),
673
661
  quality: {
674
662
  ...(response.estimatedDuration === undefined || response.estimatedDuration === null
675
663
  ? {}
@@ -704,10 +692,6 @@ function formatSquidResult(
704
692
  return {
705
693
  minimumOutputAmount: response.toAmountMin,
706
694
  outputAmount: response.toAmount,
707
- outputAmountFormatted: decimalFromBaseUnits(
708
- response.toAmount,
709
- candidate.destinationToken.decimals,
710
- ),
711
695
  quality: {
712
696
  estimatedSeconds: Math.ceil(response.estimatedRouteDuration),
713
697
  liquiditySource: 'providerQuote',
@@ -764,10 +748,6 @@ function formatSymbiosisResult(
764
748
  return {
765
749
  minimumOutputAmount: response.tokenAmountOutMin.amount,
766
750
  outputAmount: response.tokenAmountOut.amount,
767
- outputAmountFormatted: decimalFromBaseUnits(
768
- response.tokenAmountOut.amount,
769
- candidate.destinationToken.decimals,
770
- ),
771
751
  quality: {
772
752
  ...(response.estimatedTime === undefined
773
753
  ? {}
@@ -1005,13 +985,6 @@ function nonnegativeInteger(input: unknown) {
1005
985
  return undefined
1006
986
  }
1007
987
 
1008
- function decimalFromBaseUnits(baseUnits: string, decimals: number) {
1009
- const padded = baseUnits.padStart(decimals + 1, '0')
1010
- const whole = decimals === 0 ? padded : padded.slice(0, -decimals)
1011
- const fraction = decimals === 0 ? '' : padded.slice(-decimals).replace(/0+$/, '')
1012
- return fraction ? `${whole}.${fraction}` : whole
1013
- }
1014
-
1015
988
  function baseUnitsFromDecimal(value: string, decimals: number) {
1016
989
  if (!/^\d+(\.\d+)?$/.test(value)) return undefined
1017
990
  const [whole, fraction = ''] = value.split('.')
@@ -1,7 +1,7 @@
1
1
  import * as FxOracle from './FxOracle.js'
2
2
 
3
3
  const set = {
4
- asOf: '2026-01-01',
4
+ asOf: '2026-01-01T00:00:00.000Z',
5
5
  base: 'EUR',
6
6
  rates: { AUD: '1.6', USD: '1.25' },
7
7
  } satisfies FxOracle.RateSet
@@ -45,7 +45,7 @@ describe('ecb', () => {
45
45
  const rates = await FxOracle.ecb({ url: feedUrl('1.25') }).rates()
46
46
  expect(rates).toMatchInlineSnapshot(`
47
47
  {
48
- "asOf": "2026-01-01",
48
+ "asOf": "2026-01-01T00:00:00.000Z",
49
49
  "base": "EUR",
50
50
  "rates": {
51
51
  "AUD": "1.6",
@@ -14,7 +14,7 @@ export type Oracle = {
14
14
 
15
15
  /** One oracle-published set of exchange rates quoted against a base currency. */
16
16
  export type RateSet = {
17
- /** Publication date of the rate set (e.g. `2026-07-20`). */
17
+ /** Publication date of the rate set as an ISO 8601 timestamp. */
18
18
  asOf: string
19
19
  /** Currency the rates are quoted against (e.g. `EUR`). */
20
20
  base: string
@@ -88,7 +88,7 @@ export function ecb(options: ecb.Options = {}): Oracle {
88
88
  rates[code] = value
89
89
  }
90
90
  if (!asOf || rates['USD'] === undefined) throw new ParseError()
91
- return { asOf, base: 'EUR', rates }
91
+ return { asOf: `${asOf}T00:00:00.000Z`, base: 'EUR', rates }
92
92
  },
93
93
  })
94
94
  }
@@ -12,7 +12,11 @@ import * as Activities from './activities.js'
12
12
  // Deterministic EUR-based rate set so conversion math is exact in assertions.
13
13
  const fixed = FxOracle.from({
14
14
  name: 'fixed',
15
- rates: async () => ({ asOf: '2026-01-01', base: 'EUR', rates: { AUD: '1.6', USD: '1.0' } }),
15
+ rates: async () => ({
16
+ asOf: '2026-01-01T00:00:00.000Z',
17
+ base: 'EUR',
18
+ rates: { AUD: '1.6', USD: '1.0' },
19
+ }),
16
20
  })
17
21
 
18
22
  /**
@@ -312,7 +316,7 @@ describe('GET /addresses/:address/activities', () => {
312
316
  })
313
317
  expect(body.meta?.pricing).toMatchInlineSnapshot(`
314
318
  {
315
- "asOf": "2026-01-01",
319
+ "asOf": "2026-01-01T00:00:00.000Z",
316
320
  "basis": "nominal",
317
321
  "source": "fixed",
318
322
  }
@@ -1,6 +1,7 @@
1
1
  import { Hono, type Context } from 'hono'
2
2
  import { Address, Hex } from 'ox'
3
3
  import { type Log, parseEventLogs, zeroAddress } from 'viem'
4
+ import { token as viem_Token } from 'viem/actions'
4
5
  import { Abis, Addresses } from 'viem/tempo'
5
6
  import * as z from 'zod/mini'
6
7
 
@@ -28,6 +29,10 @@ const exampleHash = '0x4845ae2098724ab26a5d89370dce5124d044be6a5acc164afc348662d
28
29
 
29
30
  const tokenFields = ['token.logoUri', 'token.verified'] as const
30
31
 
32
+ // TIP-20 tokens live at deterministic `0x20c0…` addresses. Other contracts
33
+ // can emit the same `Transfer` signature but do not expose TIP-20 metadata.
34
+ const tip20AddressPrefix = '0x20c0'
35
+
31
36
  /** Zod schemas owned by the activity handlers. */
32
37
  export namespace schema {
33
38
  /** A signer: the literal `self`, or the access-key address that signed. */
@@ -3982,6 +3987,39 @@ type ResolvedToken = {
3982
3987
  verified?: boolean | undefined
3983
3988
  }
3984
3989
 
3990
+ /** Resolves TIP-20 metadata or the standard ERC-20 subset used by activity transfers. */
3991
+ function getActivityTokenMetadata(
3992
+ c: Context<App.Environment>,
3993
+ options: getActivityTokenMetadata.Options,
3994
+ ): Promise<Omit<ResolvedToken, 'verified'>> {
3995
+ const { address, chainId } = options
3996
+ if (address.startsWith(tip20AddressPrefix))
3997
+ return Tokens.getTokenMetadata(c, { address, chainId })
3998
+
3999
+ return Store.memoize(
4000
+ async () => {
4001
+ const metadata = await viem_Token.getMetadata(c.get('getClient')(chainId), { token: address })
4002
+ // ERC-20 has no currency field; its symbol is the amount's unit label.
4003
+ return {
4004
+ currency: metadata.symbol,
4005
+ decimals: metadata.decimals,
4006
+ name: metadata.name,
4007
+ symbol: metadata.symbol,
4008
+ }
4009
+ },
4010
+ { key: `token:erc20:v1:${chainId}:${address}:metadata`, store: c.get('store'), ttl: Ttl.minutes(1) }, // prettier-ignore
4011
+ )
4012
+ }
4013
+
4014
+ declare namespace getActivityTokenMetadata {
4015
+ type Options = {
4016
+ /** Token contract address. */
4017
+ address: Address.Address
4018
+ /** Source chain id. */
4019
+ chainId: z.output<typeof Schema.ChainId>
4020
+ }
4021
+ }
4022
+
3985
4023
  /** Resolves core metadata into immutable items. Optional logo and verification sources are best-effort. */
3986
4024
  async function enrichTokens(
3987
4025
  c: Context<App.Environment>,
@@ -4018,7 +4056,7 @@ async function enrichTokens(
4018
4056
  includeLogoUri
4019
4057
  ? Tokens.getTokenLogo(c, { address, chainId }).catch(() => undefined)
4020
4058
  : undefined,
4021
- Tokens.getTokenMetadata(c, { address, chainId }),
4059
+ getActivityTokenMetadata(c, { address, chainId }),
4022
4060
  ])
4023
4061
  return [
4024
4062
  address.toLowerCase(),
@@ -12,7 +12,11 @@ import * as Tokens from './tokens.js'
12
12
  // Deterministic EUR-based rate set so conversion math is exact in assertions.
13
13
  const fixed = FxOracle.from({
14
14
  name: 'fixed',
15
- rates: async () => ({ asOf: '2026-01-01', base: 'EUR', rates: { AUD: '1.6', USD: '1.0' } }),
15
+ rates: async () => ({
16
+ asOf: '2026-01-01T00:00:00.000Z',
17
+ base: 'EUR',
18
+ rates: { AUD: '1.6', USD: '1.0' },
19
+ }),
16
20
  })
17
21
 
18
22
  // Verified enrichment is store-backed now that the build-time bundle is gone, so
@@ -381,7 +385,7 @@ describe('GET /addresses/:address/balances', () => {
381
385
  // Consulted rates surface their provenance response-wide.
382
386
  expect(body.meta?.pricing).toMatchInlineSnapshot(`
383
387
  {
384
- "asOf": "2026-01-01",
388
+ "asOf": "2026-01-01T00:00:00.000Z",
385
389
  "basis": "nominal",
386
390
  "source": "fixed",
387
391
  }
@@ -33,7 +33,11 @@ type PairsResponse = z.output<typeof Exchanges.schema.getPairs.Response>
33
33
  // Deterministic EUR-based rate set so conversion math is exact in assertions.
34
34
  const fixed = FxOracle.from({
35
35
  name: 'fixed',
36
- rates: async () => ({ asOf: '2026-01-01', base: 'EUR', rates: { AUD: '1.6', USD: '1.0' } }),
36
+ rates: async () => ({
37
+ asOf: '2026-01-01T00:00:00.000Z',
38
+ base: 'EUR',
39
+ rates: { AUD: '1.6', USD: '1.0' },
40
+ }),
37
41
  })
38
42
 
39
43
  type Client = ReturnType<typeof TestApp.client>
@@ -652,7 +656,7 @@ describe('GET /exchange/swaps', () => {
652
656
  })
653
657
  expect(body.meta?.pricing).toMatchInlineSnapshot(`
654
658
  {
655
- "asOf": "2026-01-01",
659
+ "asOf": "2026-01-01T00:00:00.000Z",
656
660
  "basis": "nominal",
657
661
  "source": "fixed",
658
662
  }
@@ -1165,7 +1169,7 @@ describe('POST /exchange/quotes', () => {
1165
1169
  })
1166
1170
  expect(body.meta?.pricing).toMatchInlineSnapshot(`
1167
1171
  {
1168
- "asOf": "2026-01-01",
1172
+ "asOf": "2026-01-01T00:00:00.000Z",
1169
1173
  "basis": "nominal",
1170
1174
  "source": "fixed",
1171
1175
  }