tapimo 0.11.0 → 0.13.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 (443) hide show
  1. package/dist/App.d.ts +6396 -4575
  2. package/dist/App.d.ts.map +1 -1
  3. package/dist/App.js +39 -4
  4. package/dist/App.js.map +1 -1
  5. package/dist/Assets.d.ts +15 -0
  6. package/dist/Assets.d.ts.map +1 -1
  7. package/dist/Assets.js +7 -0
  8. package/dist/Assets.js.map +1 -1
  9. package/dist/Scope.d.ts +2 -0
  10. package/dist/Scope.d.ts.map +1 -1
  11. package/dist/Scope.js +17 -0
  12. package/dist/Scope.js.map +1 -1
  13. package/dist/admin/App.d.ts +176 -78
  14. package/dist/admin/App.d.ts.map +1 -1
  15. package/dist/admin/apps/api-keys.d.ts +84 -4
  16. package/dist/admin/apps/api-keys.d.ts.map +1 -1
  17. package/dist/admin/apps/api-keys.js +54 -0
  18. package/dist/admin/apps/api-keys.js.map +1 -1
  19. package/dist/admin/apps/earn-vaults.d.ts +123 -9
  20. package/dist/admin/apps/earn-vaults.d.ts.map +1 -1
  21. package/dist/admin/apps/earn-vaults.js +60 -7
  22. package/dist/admin/apps/earn-vaults.js.map +1 -1
  23. package/dist/admin/apps/organizations.d.ts.map +1 -1
  24. package/dist/admin/apps/organizations.js +4 -2
  25. package/dist/admin/apps/organizations.js.map +1 -1
  26. package/dist/admin/apps/verified-tokens.d.ts +90 -90
  27. package/dist/admin/ui.generated.d.ts +1 -1
  28. package/dist/admin/ui.generated.js +71 -71
  29. package/dist/admin/ui.generated.js.map +1 -1
  30. package/dist/apps/Zones.d.ts +16 -16
  31. package/dist/apps/Zones.js +4 -4
  32. package/dist/apps/data/App.d.ts +1699 -749
  33. package/dist/apps/data/App.d.ts.map +1 -1
  34. package/dist/apps/data/App.js +58 -9
  35. package/dist/apps/data/App.js.map +1 -1
  36. package/dist/apps/data/ZoneSelection.d.ts +62 -0
  37. package/dist/apps/data/ZoneSelection.d.ts.map +1 -0
  38. package/dist/apps/data/ZoneSelection.js +44 -0
  39. package/dist/apps/data/ZoneSelection.js.map +1 -0
  40. package/dist/apps/data/routes/activities.d.ts +2417 -50
  41. package/dist/apps/data/routes/activities.d.ts.map +1 -1
  42. package/dist/apps/data/routes/activities.js +1896 -1293
  43. package/dist/apps/data/routes/activities.js.map +1 -1
  44. package/dist/apps/data/routes/addresses.d.ts +471 -340
  45. package/dist/apps/data/routes/addresses.d.ts.map +1 -1
  46. package/dist/apps/data/routes/balances.d.ts +12 -12
  47. package/dist/apps/data/routes/balances.d.ts.map +1 -1
  48. package/dist/apps/data/routes/balances.js +44 -17
  49. package/dist/apps/data/routes/balances.js.map +1 -1
  50. package/dist/apps/data/routes/blocks.d.ts +42 -42
  51. package/dist/apps/data/routes/blocks.d.ts.map +1 -1
  52. package/dist/apps/data/routes/blocks.js +22 -12
  53. package/dist/apps/data/routes/blocks.js.map +1 -1
  54. package/dist/apps/data/routes/coingecko.d.ts +179 -179
  55. package/dist/apps/data/routes/earn.d.ts +144 -19
  56. package/dist/apps/data/routes/earn.d.ts.map +1 -1
  57. package/dist/apps/data/routes/earn.js +160 -28
  58. package/dist/apps/data/routes/earn.js.map +1 -1
  59. package/dist/apps/data/routes/exchanges.d.ts +719 -13
  60. package/dist/apps/data/routes/exchanges.d.ts.map +1 -1
  61. package/dist/apps/data/routes/exchanges.js +16 -13
  62. package/dist/apps/data/routes/exchanges.js.map +1 -1
  63. package/dist/apps/data/routes/fee-amm.d.ts +2 -2
  64. package/dist/apps/data/routes/fee-amm.d.ts.map +1 -1
  65. package/dist/apps/data/routes/fee-amm.js +9 -11
  66. package/dist/apps/data/routes/fee-amm.js.map +1 -1
  67. package/dist/apps/data/routes/indexer.d.ts +34 -34
  68. package/dist/apps/data/routes/receipts.d.ts +363 -263
  69. package/dist/apps/data/routes/receipts.d.ts.map +1 -1
  70. package/dist/apps/data/routes/receipts.js +29 -11
  71. package/dist/apps/data/routes/receipts.js.map +1 -1
  72. package/dist/apps/data/routes/rpc.js +24 -10
  73. package/dist/apps/data/routes/rpc.js.map +1 -1
  74. package/dist/apps/data/routes/tokenlist.d.ts +13 -13
  75. package/dist/apps/data/routes/tokenlist.d.ts.map +1 -1
  76. package/dist/apps/data/routes/tokenlist.js +16 -15
  77. package/dist/apps/data/routes/tokenlist.js.map +1 -1
  78. package/dist/apps/data/routes/tokens.d.ts +112 -98
  79. package/dist/apps/data/routes/tokens.d.ts.map +1 -1
  80. package/dist/apps/data/routes/tokens.js +85 -35
  81. package/dist/apps/data/routes/tokens.js.map +1 -1
  82. package/dist/apps/data/routes/transactions.d.ts +7 -2
  83. package/dist/apps/data/routes/transactions.d.ts.map +1 -1
  84. package/dist/apps/data/routes/transactions.js +38 -18
  85. package/dist/apps/data/routes/transactions.js.map +1 -1
  86. package/dist/apps/data/routes/transfers.d.ts +1 -1
  87. package/dist/apps/data/routes/transfers.d.ts.map +1 -1
  88. package/dist/apps/data/routes/transfers.js +25 -21
  89. package/dist/apps/data/routes/transfers.js.map +1 -1
  90. package/dist/apps/data/routes/valuation.d.ts +1 -1
  91. package/dist/apps/data/routes/valuation.d.ts.map +1 -1
  92. package/dist/apps/data/routes/valuation.js +12 -7
  93. package/dist/apps/data/routes/valuation.js.map +1 -1
  94. package/dist/apps/data/routes/verified-tokens.d.ts +3 -3
  95. package/dist/apps/data/routes/webhooks.d.ts +15 -15
  96. package/dist/apps/data/routes/webhooks.d.ts.map +1 -1
  97. package/dist/apps/data/routes/webhooks.js +2 -2
  98. package/dist/apps/data/routes/webhooks.js.map +1 -1
  99. package/dist/apps/data/routes/zones.d.ts +32 -32
  100. package/dist/apps/data/routes/zones.js +1 -1
  101. package/dist/apps/funding/App.d.ts +38 -27
  102. package/dist/apps/funding/App.d.ts.map +1 -1
  103. package/dist/apps/funding/routes/chains.d.ts.map +1 -1
  104. package/dist/apps/funding/routes/chains.js +5 -4
  105. package/dist/apps/funding/routes/chains.js.map +1 -1
  106. package/dist/apps/funding/routes/deposit-addresses.d.ts +16 -16
  107. package/dist/apps/funding/routes/deposit-addresses.d.ts.map +1 -1
  108. package/dist/apps/funding/routes/deposit-addresses.js +24 -19
  109. package/dist/apps/funding/routes/deposit-addresses.js.map +1 -1
  110. package/dist/apps/funding/routes/providers.d.ts +13 -13
  111. package/dist/apps/funding/routes/providers.d.ts.map +1 -1
  112. package/dist/apps/funding/routes/providers.js +3 -2
  113. package/dist/apps/funding/routes/providers.js.map +1 -1
  114. package/dist/apps/funding/routes/quotes.d.ts.map +1 -1
  115. package/dist/apps/funding/routes/quotes.js +2 -2
  116. package/dist/apps/funding/routes/quotes.js.map +1 -1
  117. package/dist/apps/funding/routes/transfers.d.ts +60 -48
  118. package/dist/apps/funding/routes/transfers.d.ts.map +1 -1
  119. package/dist/apps/funding/routes/transfers.js +27 -19
  120. package/dist/apps/funding/routes/transfers.js.map +1 -1
  121. package/dist/apps/management/App.d.ts +4558 -3700
  122. package/dist/apps/management/App.d.ts.map +1 -1
  123. package/dist/apps/management/Billing.d.ts.map +1 -1
  124. package/dist/apps/management/Billing.js +19 -6
  125. package/dist/apps/management/Billing.js.map +1 -1
  126. package/dist/apps/management/routes/api-keys.d.ts +786 -175
  127. package/dist/apps/management/routes/api-keys.d.ts.map +1 -1
  128. package/dist/apps/management/routes/api-keys.js +312 -34
  129. package/dist/apps/management/routes/api-keys.js.map +1 -1
  130. package/dist/apps/management/routes/billing.d.ts +103 -30
  131. package/dist/apps/management/routes/billing.d.ts.map +1 -1
  132. package/dist/apps/management/routes/billing.js +91 -32
  133. package/dist/apps/management/routes/billing.js.map +1 -1
  134. package/dist/apps/management/routes/faucet.d.ts +35 -35
  135. package/dist/apps/management/routes/faucet.d.ts.map +1 -1
  136. package/dist/apps/management/routes/faucet.js +23 -12
  137. package/dist/apps/management/routes/faucet.js.map +1 -1
  138. package/dist/apps/management/routes/invitations.d.ts +46 -13
  139. package/dist/apps/management/routes/invitations.d.ts.map +1 -1
  140. package/dist/apps/management/routes/invitations.js +79 -29
  141. package/dist/apps/management/routes/invitations.js.map +1 -1
  142. package/dist/apps/management/routes/invite-links.d.ts +120 -75
  143. package/dist/apps/management/routes/invite-links.d.ts.map +1 -1
  144. package/dist/apps/management/routes/invite-links.js +100 -41
  145. package/dist/apps/management/routes/invite-links.js.map +1 -1
  146. package/dist/apps/management/routes/me.d.ts +5 -5
  147. package/dist/apps/management/routes/me.d.ts.map +1 -1
  148. package/dist/apps/management/routes/me.js +14 -10
  149. package/dist/apps/management/routes/me.js.map +1 -1
  150. package/dist/apps/management/routes/members.d.ts +99 -78
  151. package/dist/apps/management/routes/members.d.ts.map +1 -1
  152. package/dist/apps/management/routes/members.js +37 -18
  153. package/dist/apps/management/routes/members.js.map +1 -1
  154. package/dist/apps/management/routes/orgs.d.ts +48 -8
  155. package/dist/apps/management/routes/orgs.d.ts.map +1 -1
  156. package/dist/apps/management/routes/orgs.js +48 -19
  157. package/dist/apps/management/routes/orgs.js.map +1 -1
  158. package/dist/apps/management/routes/projects.d.ts +108 -46
  159. package/dist/apps/management/routes/projects.d.ts.map +1 -1
  160. package/dist/apps/management/routes/projects.js +72 -21
  161. package/dist/apps/management/routes/projects.js.map +1 -1
  162. package/dist/apps/management/routes/scopes.d.ts.map +1 -1
  163. package/dist/apps/management/routes/scopes.js +8 -5
  164. package/dist/apps/management/routes/scopes.js.map +1 -1
  165. package/dist/apps/management/routes/usage.d.ts +82 -82
  166. package/dist/apps/management/routes/usage.d.ts.map +1 -1
  167. package/dist/apps/management/routes/usage.js +41 -31
  168. package/dist/apps/management/routes/usage.js.map +1 -1
  169. package/dist/apps/management/routes/verified-token-requests.d.ts +35 -7
  170. package/dist/apps/management/routes/verified-token-requests.d.ts.map +1 -1
  171. package/dist/apps/management/routes/verified-token-requests.js +36 -17
  172. package/dist/apps/management/routes/verified-token-requests.js.map +1 -1
  173. package/dist/apps/management/routes/webhooks.d.ts +200 -183
  174. package/dist/apps/management/routes/webhooks.d.ts.map +1 -1
  175. package/dist/apps/management/routes/webhooks.js +80 -35
  176. package/dist/apps/management/routes/webhooks.js.map +1 -1
  177. package/dist/apps/mpp/internal/Adapter.d.ts +1 -1
  178. package/dist/apps/mpp/internal/Adapter.d.ts.map +1 -1
  179. package/dist/apps/mpp/internal/Adapter.js +12 -0
  180. package/dist/apps/mpp/internal/Adapter.js.map +1 -1
  181. package/dist/db/Db.d.ts +2 -0
  182. package/dist/db/Db.d.ts.map +1 -1
  183. package/dist/db/Db.js +52 -1
  184. package/dist/db/Db.js.map +1 -1
  185. package/dist/db/Schema.d.ts +26 -3
  186. package/dist/db/Schema.d.ts.map +1 -1
  187. package/dist/db/Schema.js +24 -5
  188. package/dist/db/Schema.js.map +1 -1
  189. package/dist/db/tables/earnVaults.d.ts +5 -1
  190. package/dist/db/tables/earnVaults.d.ts.map +1 -1
  191. package/dist/db/tables/earnVaults.js +5 -0
  192. package/dist/db/tables/earnVaults.js.map +1 -1
  193. package/dist/db/tables/fundingCatalog.d.ts +4 -2
  194. package/dist/db/tables/fundingCatalog.d.ts.map +1 -1
  195. package/dist/db/tables/fundingCatalog.js +5 -1
  196. package/dist/db/tables/fundingCatalog.js.map +1 -1
  197. package/dist/db/tables/webhookDeliveries.d.ts.map +1 -1
  198. package/dist/db/tables/webhookDeliveries.js +10 -0
  199. package/dist/db/tables/webhookDeliveries.js.map +1 -1
  200. package/dist/db/tables/webhookQueueCompletions.d.ts +15 -0
  201. package/dist/db/tables/webhookQueueCompletions.d.ts.map +1 -0
  202. package/dist/db/tables/webhookQueueCompletions.js +32 -0
  203. package/dist/db/tables/webhookQueueCompletions.js.map +1 -0
  204. package/dist/db/tables/webhookQueueEvents.d.ts +14 -20
  205. package/dist/db/tables/webhookQueueEvents.d.ts.map +1 -1
  206. package/dist/db/tables/webhookQueueEvents.js +68 -47
  207. package/dist/db/tables/webhookQueueEvents.js.map +1 -1
  208. package/dist/db/tables/webhookSubscriptions.d.ts +16 -5
  209. package/dist/db/tables/webhookSubscriptions.d.ts.map +1 -1
  210. package/dist/db/tables/webhookSubscriptions.js +29 -5
  211. package/dist/db/tables/webhookSubscriptions.js.map +1 -1
  212. package/dist/handlers/internal/utils.d.ts.map +1 -1
  213. package/dist/handlers/internal/utils.js +9 -0
  214. package/dist/handlers/internal/utils.js.map +1 -1
  215. package/dist/internal/Auth.d.ts +11 -2
  216. package/dist/internal/Auth.d.ts.map +1 -1
  217. package/dist/internal/Auth.js +19 -6
  218. package/dist/internal/Auth.js.map +1 -1
  219. package/dist/internal/Cache.d.ts.map +1 -1
  220. package/dist/internal/Cache.js +11 -7
  221. package/dist/internal/Cache.js.map +1 -1
  222. package/dist/internal/EarnVaults.d.ts +43 -4
  223. package/dist/internal/EarnVaults.d.ts.map +1 -1
  224. package/dist/internal/EarnVaults.js +167 -0
  225. package/dist/internal/EarnVaults.js.map +1 -1
  226. package/dist/internal/Fetch.d.ts +23 -0
  227. package/dist/internal/Fetch.d.ts.map +1 -0
  228. package/dist/internal/Fetch.js +26 -0
  229. package/dist/internal/Fetch.js.map +1 -0
  230. package/dist/internal/Log.d.ts +3 -1
  231. package/dist/internal/Log.d.ts.map +1 -1
  232. package/dist/internal/Log.js +16 -3
  233. package/dist/internal/Log.js.map +1 -1
  234. package/dist/internal/OpenApi.d.ts +30 -24
  235. package/dist/internal/OpenApi.d.ts.map +1 -1
  236. package/dist/internal/OpenApi.js +140 -41
  237. package/dist/internal/OpenApi.js.map +1 -1
  238. package/dist/internal/Response.d.ts +14 -9
  239. package/dist/internal/Response.d.ts.map +1 -1
  240. package/dist/internal/Response.js +16 -5
  241. package/dist/internal/Response.js.map +1 -1
  242. package/dist/internal/Schema.d.ts +11 -10
  243. package/dist/internal/Schema.d.ts.map +1 -1
  244. package/dist/internal/Schema.js +14 -9
  245. package/dist/internal/Schema.js.map +1 -1
  246. package/dist/internal/Store.d.ts +28 -6
  247. package/dist/internal/Store.d.ts.map +1 -1
  248. package/dist/internal/Store.js +121 -25
  249. package/dist/internal/Store.js.map +1 -1
  250. package/dist/internal/Tidx.d.ts +2 -0
  251. package/dist/internal/Tidx.d.ts.map +1 -1
  252. package/dist/internal/Tidx.js +23 -6
  253. package/dist/internal/Tidx.js.map +1 -1
  254. package/dist/internal/Timeout.d.ts +36 -0
  255. package/dist/internal/Timeout.d.ts.map +1 -0
  256. package/dist/internal/Timeout.js +37 -0
  257. package/dist/internal/Timeout.js.map +1 -0
  258. package/dist/internal/Timing.d.ts +9 -0
  259. package/dist/internal/Timing.d.ts.map +1 -1
  260. package/dist/internal/Timing.js +7 -0
  261. package/dist/internal/Timing.js.map +1 -1
  262. package/dist/internal/WebhookDestination.d.ts.map +1 -1
  263. package/dist/internal/WebhookDestination.js +3 -0
  264. package/dist/internal/WebhookDestination.js.map +1 -1
  265. package/dist/internal/Webhooks.d.ts +29 -12
  266. package/dist/internal/Webhooks.d.ts.map +1 -1
  267. package/dist/internal/Webhooks.js +37 -20
  268. package/dist/internal/Webhooks.js.map +1 -1
  269. package/dist/internal/funding/Action.d.ts +9 -0
  270. package/dist/internal/funding/Action.d.ts.map +1 -1
  271. package/dist/internal/funding/Action.js +16 -8
  272. package/dist/internal/funding/Action.js.map +1 -1
  273. package/dist/internal/funding/Catalog.d.ts +9 -2
  274. package/dist/internal/funding/Catalog.d.ts.map +1 -1
  275. package/dist/internal/funding/Catalog.js +19 -8
  276. package/dist/internal/funding/Catalog.js.map +1 -1
  277. package/dist/internal/funding/Deposit.d.ts +2 -2
  278. package/dist/internal/funding/Deposit.d.ts.map +1 -1
  279. package/dist/internal/funding/Deposit.js +9 -8
  280. package/dist/internal/funding/Deposit.js.map +1 -1
  281. package/dist/internal/funding/DepositAddress.d.ts +4 -4
  282. package/dist/internal/funding/DepositAddress.d.ts.map +1 -1
  283. package/dist/internal/funding/DepositAddress.js +9 -8
  284. package/dist/internal/funding/DepositAddress.js.map +1 -1
  285. package/dist/internal/funding/Provider.d.ts +68 -13
  286. package/dist/internal/funding/Provider.d.ts.map +1 -1
  287. package/dist/internal/funding/Provider.js +42 -20
  288. package/dist/internal/funding/Provider.js.map +1 -1
  289. package/dist/internal/funding/Transfer.d.ts +13 -12
  290. package/dist/internal/funding/Transfer.d.ts.map +1 -1
  291. package/dist/internal/funding/Transfer.js +23 -21
  292. package/dist/internal/funding/Transfer.js.map +1 -1
  293. package/dist/internal/funding/providers/stargate.d.ts +38 -0
  294. package/dist/internal/funding/providers/stargate.d.ts.map +1 -0
  295. package/dist/internal/funding/providers/stargate.js +277 -0
  296. package/dist/internal/funding/providers/stargate.js.map +1 -0
  297. package/dist/internal/index.d.ts +1 -0
  298. package/dist/internal/index.d.ts.map +1 -1
  299. package/dist/internal/index.js +1 -0
  300. package/dist/internal/index.js.map +1 -1
  301. package/dist/providers/index.d.ts +7 -0
  302. package/dist/providers/index.d.ts.map +1 -1
  303. package/dist/providers/index.js +3 -0
  304. package/dist/providers/index.js.map +1 -1
  305. package/package.json +3 -3
  306. package/src/App.test.ts +148 -0
  307. package/src/App.ts +45 -4
  308. package/src/Assets.test.ts +18 -0
  309. package/src/Assets.ts +22 -0
  310. package/src/Client.test-d.ts +311 -16
  311. package/src/Scope.test.ts +20 -0
  312. package/src/Scope.ts +16 -0
  313. package/src/admin/apps/api-keys.test.ts +75 -0
  314. package/src/admin/apps/api-keys.ts +64 -0
  315. package/src/admin/apps/earn-vaults.test.ts +143 -0
  316. package/src/admin/apps/earn-vaults.ts +84 -7
  317. package/src/admin/apps/organizations.ts +6 -2
  318. package/src/admin/ui/src/lib/api-keys.ts +19 -0
  319. package/src/admin/ui/src/routes/api-keys.tsx +125 -2
  320. package/src/admin/ui.generated.ts +71 -71
  321. package/src/apps/Zones.test.ts +4 -4
  322. package/src/apps/Zones.ts +4 -4
  323. package/src/apps/composition.test.ts +1 -1
  324. package/src/apps/data/App.test.ts +72 -0
  325. package/src/apps/data/App.ts +58 -10
  326. package/src/apps/data/ZoneSelection.test.ts +196 -0
  327. package/src/apps/data/ZoneSelection.ts +108 -0
  328. package/src/apps/data/routes/activities.test.ts +1843 -2
  329. package/src/apps/data/routes/activities.ts +2211 -1388
  330. package/src/apps/data/routes/balances.ts +134 -98
  331. package/src/apps/data/routes/blocks.test.ts +533 -0
  332. package/src/apps/data/routes/blocks.ts +36 -17
  333. package/src/apps/data/routes/earn.test.ts +187 -25
  334. package/src/apps/data/routes/earn.ts +228 -32
  335. package/src/apps/data/routes/earn.zone-access.test.ts +46 -0
  336. package/src/apps/data/routes/exchanges.test.ts +31 -1
  337. package/src/apps/data/routes/exchanges.ts +19 -15
  338. package/src/apps/data/routes/fee-amm.ts +18 -11
  339. package/src/apps/data/routes/receipts.ts +48 -17
  340. package/src/apps/data/routes/rpc.test.ts +75 -0
  341. package/src/apps/data/routes/rpc.ts +23 -9
  342. package/src/apps/data/routes/tokenlist.ts +21 -18
  343. package/src/apps/data/routes/tokens.test.ts +607 -0
  344. package/src/apps/data/routes/tokens.ts +312 -231
  345. package/src/apps/data/routes/transactions.ts +69 -30
  346. package/src/apps/data/routes/transfers.test.ts +424 -3
  347. package/src/apps/data/routes/transfers.ts +57 -36
  348. package/src/apps/data/routes/valuation.ts +94 -78
  349. package/src/apps/data/routes/webhooks.ts +6 -2
  350. package/src/apps/data/routes/zones.test.ts +21 -15
  351. package/src/apps/data/routes/zones.ts +1 -1
  352. package/src/apps/funding/routes/chains.test.ts +211 -7
  353. package/src/apps/funding/routes/chains.ts +19 -12
  354. package/src/apps/funding/routes/deposit-addresses.test.ts +416 -0
  355. package/src/apps/funding/routes/deposit-addresses.ts +101 -84
  356. package/src/apps/funding/routes/providers.ts +9 -5
  357. package/src/apps/funding/routes/quotes.test.ts +42 -0
  358. package/src/apps/funding/routes/quotes.ts +8 -5
  359. package/src/apps/funding/routes/transfers.test.ts +430 -40
  360. package/src/apps/funding/routes/transfers.ts +76 -59
  361. package/src/apps/management/Billing.test.ts +26 -0
  362. package/src/apps/management/Billing.ts +24 -7
  363. package/src/apps/management/routes/api-keys.test.ts +398 -0
  364. package/src/apps/management/routes/api-keys.ts +481 -145
  365. package/src/apps/management/routes/billing.test.ts +209 -0
  366. package/src/apps/management/routes/billing.ts +254 -177
  367. package/src/apps/management/routes/faucet.test.ts +39 -0
  368. package/src/apps/management/routes/faucet.ts +75 -41
  369. package/src/apps/management/routes/invitations.test.ts +216 -0
  370. package/src/apps/management/routes/invitations.ts +186 -109
  371. package/src/apps/management/routes/invite-links.test.ts +263 -1
  372. package/src/apps/management/routes/invite-links.ts +295 -214
  373. package/src/apps/management/routes/me.test.ts +49 -0
  374. package/src/apps/management/routes/me.ts +42 -32
  375. package/src/apps/management/routes/members.test.ts +124 -1
  376. package/src/apps/management/routes/members.ts +81 -51
  377. package/src/apps/management/routes/orgs.test.ts +176 -0
  378. package/src/apps/management/routes/orgs.ts +101 -59
  379. package/src/apps/management/routes/projects.test.ts +183 -0
  380. package/src/apps/management/routes/projects.ts +124 -60
  381. package/src/apps/management/routes/scopes.test.ts +44 -0
  382. package/src/apps/management/routes/scopes.ts +37 -28
  383. package/src/apps/management/routes/usage.test.ts +92 -0
  384. package/src/apps/management/routes/usage.ts +186 -151
  385. package/src/apps/management/routes/verified-token-requests.test.ts +114 -0
  386. package/src/apps/management/routes/verified-token-requests.ts +166 -133
  387. package/src/apps/management/routes/webhooks.test.ts +234 -0
  388. package/src/apps/management/routes/webhooks.ts +191 -78
  389. package/src/apps/mpp/internal/Adapter.ts +13 -1
  390. package/src/db/Db.test.ts +41 -0
  391. package/src/db/Db.ts +75 -1
  392. package/src/db/Schema.ts +33 -5
  393. package/src/db/tables/earnVaults.test.ts +22 -0
  394. package/src/db/tables/earnVaults.ts +11 -1
  395. package/src/db/tables/fundingCatalog.test.ts +16 -2
  396. package/src/db/tables/fundingCatalog.ts +9 -3
  397. package/src/db/tables/webhookDeliveries.ts +13 -0
  398. package/src/db/tables/webhookQueueCompletions.ts +55 -0
  399. package/src/db/tables/webhookQueueEvents.ts +95 -61
  400. package/src/db/tables/webhookSubscriptions.ts +45 -6
  401. package/src/handlers/internal/utils.test.ts +53 -0
  402. package/src/handlers/internal/utils.ts +15 -0
  403. package/src/internal/Auth.ts +35 -9
  404. package/src/internal/Cache.ts +14 -7
  405. package/src/internal/EarnVaults.capabilities.test.ts +33 -0
  406. package/src/internal/EarnVaults.test.ts +79 -0
  407. package/src/internal/EarnVaults.ts +277 -0
  408. package/src/internal/Fetch.test.ts +46 -0
  409. package/src/internal/Fetch.ts +56 -0
  410. package/src/internal/Log.test.ts +21 -0
  411. package/src/internal/Log.ts +20 -5
  412. package/src/internal/OpenApi.ts +177 -51
  413. package/src/internal/Response.test.ts +95 -0
  414. package/src/internal/Response.ts +41 -3
  415. package/src/internal/Schema.test.ts +18 -0
  416. package/src/internal/Schema.ts +19 -9
  417. package/src/internal/Store.test.ts +364 -0
  418. package/src/internal/Store.ts +153 -27
  419. package/src/internal/Tidx.test.ts +21 -1
  420. package/src/internal/Tidx.ts +23 -6
  421. package/src/internal/Timeout.test.ts +35 -0
  422. package/src/internal/Timeout.ts +73 -0
  423. package/src/internal/Timing.ts +16 -0
  424. package/src/internal/WebhookDestination.ts +3 -0
  425. package/src/internal/Webhooks.test.ts +122 -8
  426. package/src/internal/Webhooks.ts +59 -29
  427. package/src/internal/funding/Action.test.ts +41 -1
  428. package/src/internal/funding/Action.ts +69 -41
  429. package/src/internal/funding/Catalog.test.ts +34 -0
  430. package/src/internal/funding/Catalog.ts +28 -11
  431. package/src/internal/funding/Deposit.ts +71 -64
  432. package/src/internal/funding/DepositAddress.ts +50 -43
  433. package/src/internal/funding/Provider.test.ts +73 -0
  434. package/src/internal/funding/Provider.ts +246 -170
  435. package/src/internal/funding/Transfer.ts +133 -110
  436. package/src/internal/funding/providers/index.test.ts +22 -0
  437. package/src/internal/funding/providers/relay.test.ts +2 -2
  438. package/src/internal/funding/providers/stargate.test.ts +163 -0
  439. package/src/internal/funding/providers/stargate.ts +409 -0
  440. package/src/internal/index.test.ts +1 -0
  441. package/src/internal/index.ts +1 -0
  442. package/src/providers/index.test.ts +1 -0
  443. package/src/providers/index.ts +9 -0
@@ -22,6 +22,7 @@ import * as Ttl from '../../../internal/Ttl.js'
22
22
  import * as Value from '../../../internal/Value.js'
23
23
  import * as VerifiedTokens from '../../../internal/VerifiedTokens.js'
24
24
  import * as FxOracle from '../FxOracle.js'
25
+ import * as ZoneSelection from '../ZoneSelection.js'
25
26
  import * as Tokens from './tokens.js'
26
27
  import * as Transfers from './transfers.js'
27
28
  import * as Valuation from './valuation.js'
@@ -29,7 +30,7 @@ import * as Valuation from './valuation.js'
29
30
  /** Example transaction hash surfaced in OpenAPI docs. */
30
31
  const exampleHash = '0x4845ae2098724ab26a5d89370dce5124d044be6a5acc164afc348662de67d474'
31
32
 
32
- const tokenFields = ['token.logoUri', 'token.verified'] as const
33
+ const includeFields = ['token.logoUri', 'token.verified', 'zones'] as const
33
34
 
34
35
  // TIP-20 tokens live at deterministic `0x20c0…` addresses. Other contracts
35
36
  // can emit the same `Transfer` signature but do not expose TIP-20 metadata.
@@ -45,47 +46,49 @@ export namespace schema {
45
46
  )
46
47
 
47
48
  /** A token with required RPC metadata and optional curated fields. */
48
- export const Token = Schema.describe(
49
- Tokens.schema.TokenReference,
50
- 'A token referenced by an activity item.',
49
+ export const Token = OpenApi.component(
50
+ Schema.describe(Tokens.schema.TokenReference, 'A token referenced by an activity item.'),
51
+ 'ActivityToken',
51
52
  )
52
53
 
53
- /** A token amount carrying its nominal value in the requested denomination. */
54
- const ValuedAmount = z.extend(Schema.TokenAmount, {
55
- valuation: z
56
- .optional(z.nullable(Valuation.schema.Value))
57
- .check(
58
- z.describe(
59
- 'The amount’s nominal value in the requested `valuation.currency`. `null` when the token is ' +
60
- 'unverified, its display currency has no rate, or rates were unavailable.',
54
+ const ValuedAmount = Schema.describe(
55
+ z.extend(Schema.TokenAmount, {
56
+ valuation: z
57
+ .optional(z.nullable(Valuation.schema.Value))
58
+ .check(
59
+ z.describe(
60
+ 'The amount’s nominal value in the requested `valuation.currency`. `null` when the token is ' +
61
+ 'unverified, its display currency has no rate, or rates were unavailable.',
62
+ ),
61
63
  ),
62
- ),
63
- })
64
-
65
- /** Self-contained amount for a destination token. */
66
- const DestinationAmount = ValuedAmount.check(
67
- z.describe('Amount associated with `destinationToken`.'),
64
+ }),
65
+ 'A token amount carrying its nominal value in the requested denomination.',
68
66
  )
69
67
 
70
- /** Self-contained amount for a refunded token. */
71
- const RefundAmount = ValuedAmount.check(z.describe('Amount associated with `refund`.'))
68
+ const valuedAmount = OpenApi.component(ValuedAmount, 'ActivityValuedAmount')
69
+
70
+ const DestinationAmount = valuedAmount
71
+
72
+ const RefundAmount = valuedAmount
72
73
 
73
- /** Self-contained amount for a source token. */
74
- const SourceAmount = ValuedAmount.check(z.describe('Amount associated with `sourceToken`.'))
74
+ const SourceAmount = valuedAmount
75
75
 
76
76
  /** Page-level resources: valuation rate provenance. */
77
- export const Meta = z
78
- .object({
79
- valuation: z
80
- .optional(Valuation.schema.Pricing)
81
- .check(
82
- z.describe(
83
- 'Rate provenance for amount valuations. Present when conversion rates were ' +
84
- 'consulted; absent when every value was identity-valued or rates were unavailable.',
77
+ export const Meta = OpenApi.component(
78
+ z
79
+ .object({
80
+ valuation: z
81
+ .optional(Valuation.schema.Pricing)
82
+ .check(
83
+ z.describe(
84
+ 'Rate provenance for amount valuations. Present when conversion rates were ' +
85
+ 'consulted; absent when every value was identity-valued or rates were unavailable.',
86
+ ),
85
87
  ),
86
- ),
87
- })
88
- .check(z.describe('Page-level resources attached to this response.'))
88
+ })
89
+ .check(z.describe('Page-level resources attached to this response.')),
90
+ 'ActivityMeta',
91
+ )
89
92
 
90
93
  /** A token transfer with subject-relative direction and signer fields. */
91
94
  export const Transfer = z
@@ -159,6 +162,8 @@ export namespace schema {
159
162
  z.describe('One token transfer involving the requested address.'),
160
163
  )
161
164
 
165
+ const activityTransfer = OpenApi.component(Transfer, 'ActivityTransfer')
166
+
162
167
  /**
163
168
  * One transaction log, decoded into a known Tempo event when possible. The
164
169
  * raw fields (`address`/`data`/`logIndex`/`topics`) are always present so no
@@ -195,6 +200,8 @@ export namespace schema {
195
200
  ),
196
201
  )
197
202
 
203
+ const activityEvent = OpenApi.component(event, 'ActivityEvent')
204
+
198
205
  /**
199
206
  * Fields shared by every activity item. `transactionHash`/`timestamp` identify and sort
200
207
  * the entry; `data` carries the type-specific payload — shaped like the
@@ -202,6 +209,12 @@ export namespace schema {
202
209
  * transfer's `direction`/`memo`/`attribution`/`signer`) the resource lacks.
203
210
  */
204
211
  const base = {
212
+ chainId: z
213
+ .optional(Schema.ChainId)
214
+ .check(
215
+ z.describe('Chain ID containing this activity. Returned when `include=zones`.'),
216
+ z.meta({ examples: [421700001] }),
217
+ ),
205
218
  perspective: z
206
219
  .enum(['incoming', 'outgoing'])
207
220
  .check(
@@ -210,7 +223,7 @@ export namespace schema {
210
223
  ),
211
224
  ),
212
225
  events: z
213
- .optional(z.array(event))
226
+ .optional(z.array(activityEvent))
214
227
  .check(
215
228
  z.describe(
216
229
  'Every log in the transaction, decoded when possible, so nothing is hidden. Returned only when the request sets `logs=true`, except on `unknown` items where it is always present.',
@@ -246,8 +259,7 @@ export namespace schema {
246
259
  transactionHash: Schema.Hash.check(z.describe('Transaction hash for this activity.')),
247
260
  }
248
261
 
249
- /** The classified activity item variants (discriminated by `type`). */
250
- export const items = [
262
+ const itemSchemas = [
251
263
  z
252
264
  .object({
253
265
  ...base,
@@ -391,7 +403,7 @@ export namespace schema {
391
403
  z
392
404
  .object({
393
405
  ...base,
394
- data: Transfer.check(
406
+ data: activityTransfer.check(
395
407
  z.describe('Transfer details with feed-relative direction and signer fields.'),
396
408
  ),
397
409
  type: z
@@ -989,9 +1001,7 @@ export namespace schema {
989
1001
  logoUri: z.string().check(
990
1002
  z.describe('New logo URL for the token.'),
991
1003
  z.meta({
992
- examples: [
993
- 'https://api.tempo.xyz/data/4217/icons/0x20c0000000000000000000008f5425160ebe5525.svg',
994
- ],
1004
+ examples: [Tokens.tokenExample.logoUri],
995
1005
  }),
996
1006
  ),
997
1007
  signer: Signer,
@@ -1474,14 +1484,31 @@ export namespace schema {
1474
1484
  .check(z.meta({ title: 'Unknown' })),
1475
1485
  ] as const
1476
1486
 
1487
+ /** The classified activity item variants (discriminated by `type`). */
1488
+ export const items = itemSchemas.map((item) => {
1489
+ const [type] = item.shape.type.def.values
1490
+ if (typeof type !== 'string') throw new Error('Activity item schemas require a string type.')
1491
+ const name = `ActivityItem${type
1492
+ .replace(/[^A-Za-z0-9]+(.)/g, (_, character: string) => character.toUpperCase())
1493
+ .replace(/^(.)/, (character) => character.toUpperCase())}`
1494
+ return OpenApi.component(item, name)
1495
+ }) as unknown as typeof itemSchemas
1496
+
1477
1497
  /** A single classified activity item. */
1478
- export const Item = z
1479
- .union(items)
1480
- .check(z.describe('One classified activity item in the address feed.'))
1498
+ export const Item = OpenApi.component(
1499
+ z.union(items).check(z.describe('One classified activity item in the address feed.')),
1500
+ 'ActivityItem',
1501
+ )
1481
1502
 
1482
1503
  /** A folded access-key group: consecutive same-signer items on one UTC day. */
1483
1504
  export const Group = z
1484
1505
  .object({
1506
+ chainId: z
1507
+ .optional(Schema.ChainId)
1508
+ .check(
1509
+ z.describe('Chain ID containing this activity group. Returned when `include=zones`.'),
1510
+ z.meta({ examples: [421700001] }),
1511
+ ),
1485
1512
  data: z
1486
1513
  .object({
1487
1514
  items: z.array(Item).check(z.describe('Activity items included in this group.')),
@@ -1518,20 +1545,27 @@ export namespace schema {
1518
1545
  z.describe('A group of consecutive activity items signed by the same access key.'),
1519
1546
  )
1520
1547
 
1548
+ const activityGroup = OpenApi.component(Group, 'ActivityGroup')
1549
+
1521
1550
  /** A single activity item, or a folded access-key group. */
1522
- export const Entry = z
1523
- .union([...items, Group])
1524
- .check(z.describe('Either one activity item, or a grouped set of access-key activity items.'))
1551
+ export const Entry = OpenApi.component(
1552
+ z
1553
+ .union([Item, activityGroup])
1554
+ .check(
1555
+ z.describe('Either one activity item, or a grouped set of access-key activity items.'),
1556
+ ),
1557
+ 'ActivityEntry',
1558
+ )
1525
1559
 
1526
- /** Optional token fields that require sources beyond RPC metadata. */
1560
+ /** Optional activity resources and selection modes. */
1527
1561
  export const Include = z
1528
- .enum(tokenFields)
1529
- .check(z.describe('Additional token fields to include in activity responses.'))
1562
+ .enum(includeFields)
1563
+ .check(z.describe('Additional activity resources and selection modes to include.'))
1530
1564
 
1531
- /** Parses comma-separated optional token fields. */
1565
+ /** Parses comma-separated optional activity resources and selection modes. */
1532
1566
  export const includeQuery = Schema.includeQuery(
1533
1567
  Include,
1534
- 'Comma-separated token fields to include, such as `token.logoUri,token.verified`.',
1568
+ 'Comma-separated activity options. `zones` combines the selected parent chain with every readable Zone.',
1535
1569
  )
1536
1570
 
1537
1571
  /** Schemas for the getAddressActivities operation. */
@@ -1585,15 +1619,18 @@ export namespace schema {
1585
1619
  * `group=true`) plus the root `nextCursor` pagination field shared with
1586
1620
  * other list endpoints.
1587
1621
  */
1588
- export const Response = z
1589
- .object({
1590
- data: z.array(Entry).check(z.describe('Activity feed entries for the address.')),
1591
- meta: z
1592
- .optional(schema.Meta)
1593
- .check(z.describe('Page-level resources, such as valuation rate provenance.')),
1594
- nextCursor: Schema.NextCursor,
1595
- })
1596
- .check(z.describe('A page of human-readable activity for the address.'))
1622
+ export const Response = OpenApi.component(
1623
+ z
1624
+ .object({
1625
+ data: z.array(Entry).check(z.describe('Activity feed entries for the address.')),
1626
+ meta: z
1627
+ .optional(schema.Meta)
1628
+ .check(z.describe('Page-level resources, such as valuation rate provenance.')),
1629
+ nextCursor: Schema.NextCursor,
1630
+ })
1631
+ .check(z.describe('A page of human-readable activity for the address.')),
1632
+ 'AddressActivityList',
1633
+ )
1597
1634
  }
1598
1635
 
1599
1636
  /** Schemas for the getTransactionActivities operation. */
@@ -1629,19 +1666,26 @@ export namespace schema {
1629
1666
  .check(z.describe('Query parameters for the transaction activity request.'))
1630
1667
 
1631
1668
  /**
1632
- * The classified activity for one transaction: a list of `data` items
1633
- * (usually one headline item, occasionally more) describing what the
1634
- * transaction did onchain. Unpaginated a single transaction is finite
1635
- * so there is no `nextCursor`.
1669
+ * The classified activity for one transaction: a list of `data` items,
1670
+ * one per independent activity, describing what the transaction did
1671
+ * onchain. Unpaginated (a single transaction is finite), so there is no
1672
+ * `nextCursor`.
1636
1673
  */
1637
- export const Response = z
1638
- .object({
1639
- data: z.array(Item).check(z.describe('Activity items classified from the transaction.')),
1640
- meta: z
1641
- .optional(schema.Meta)
1642
- .check(z.describe('Page-level resources, such as valuation rate provenance.')),
1643
- })
1644
- .check(z.describe('The human-readable activity that happened on a transaction.'))
1674
+ export const Response = OpenApi.component(
1675
+ z
1676
+ .object({
1677
+ chainId: Schema.ChainId.check(
1678
+ z.describe('Chain ID containing the transaction.'),
1679
+ z.meta({ examples: [4217] }),
1680
+ ),
1681
+ data: z.array(Item).check(z.describe('Activity items classified from the transaction.')),
1682
+ meta: z
1683
+ .optional(schema.Meta)
1684
+ .check(z.describe('Page-level resources, such as valuation rate provenance.')),
1685
+ })
1686
+ .check(z.describe('The human-readable activity that happened on a transaction.')),
1687
+ 'TransactionActivityList',
1688
+ )
1645
1689
  }
1646
1690
  }
1647
1691
 
@@ -1773,6 +1817,7 @@ const titleByEventName: Record<string, string> = {
1773
1817
  BlacklistUpdated: 'Blacklist updated',
1774
1818
  Burn: 'Token burned',
1775
1819
  BurnBlocked: 'Burn blocked',
1820
+ Deposit: 'Assets deposited',
1776
1821
  Deposited: 'Assets deposited',
1777
1822
  EarnDeposit: 'Private assets deposited',
1778
1823
  EarnRedeem: 'Private shares redeemed',
@@ -1781,6 +1826,7 @@ const titleByEventName: Record<string, string> = {
1781
1826
  RedeemRequested: 'Share redemption requested',
1782
1827
  Redeemed: 'Shares redeemed',
1783
1828
  VenueSharesDeposited: 'Shares deposited',
1829
+ Withdraw: 'Assets withdrawn',
1784
1830
  WithdrewExact: 'Assets withdrawn',
1785
1831
  ChannelClosed: 'Channel closed',
1786
1832
  ChannelOpened: 'Channel opened',
@@ -1848,6 +1894,33 @@ const accessKeySpendSelector = '0xe0815e3aaadddf4dd75bde97fc060f0c38afe18e87a169
1848
1894
  /** TIP-20 / ERC-20 `Transfer` event signature for the decoded CTE. */
1849
1895
  const transferSignature = 'event Transfer(address indexed from, address indexed to, uint256 value)'
1850
1896
 
1897
+ // Standard ERC-4626 vault events, absent from the merged `viem/tempo` ABI.
1898
+ // Decoding them lets external vault deposits/withdrawals classify instead of
1899
+ // falling through to the share-token mint/burn legs.
1900
+ const erc4626Events = [
1901
+ {
1902
+ inputs: [
1903
+ { indexed: true, name: 'sender', type: 'address' },
1904
+ { indexed: true, name: 'owner', type: 'address' },
1905
+ { indexed: false, name: 'assets', type: 'uint256' },
1906
+ { indexed: false, name: 'shares', type: 'uint256' },
1907
+ ],
1908
+ name: 'Deposit',
1909
+ type: 'event',
1910
+ },
1911
+ {
1912
+ inputs: [
1913
+ { indexed: true, name: 'sender', type: 'address' },
1914
+ { indexed: true, name: 'receiver', type: 'address' },
1915
+ { indexed: true, name: 'owner', type: 'address' },
1916
+ { indexed: false, name: 'assets', type: 'uint256' },
1917
+ { indexed: false, name: 'shares', type: 'uint256' },
1918
+ ],
1919
+ name: 'Withdraw',
1920
+ type: 'event',
1921
+ },
1922
+ ] as const
1923
+
1851
1924
  // TIP-20 payment-channel reserve precompile. Not exported by `viem/tempo`
1852
1925
  // `Addresses`, so the known address is pinned here: channel events
1853
1926
  // (`ChannelOpened`/`Settled`/`TopUp`/`ChannelClosed`) are emitted here, and
@@ -1855,6 +1928,14 @@ const transferSignature = 'event Transfer(address indexed from, address indexed
1855
1928
  // is how `channel-funded`/`channel-settled`/`channel-closed` recover the token.
1856
1929
  const tip20ChannelReserve = '0x4d50500000000000000000000000000000000000' as Address.Address
1857
1930
 
1931
+ // Tempo system addresses other than the DEX are never swap-route
1932
+ // counterparties; a newly shipped precompile joins the exclusion automatically.
1933
+ const nonRouteSystemAddresses = new Set(
1934
+ [...Object.values(Addresses), tip20ChannelReserve]
1935
+ .filter((value) => value !== Addresses.stablecoinDex)
1936
+ .map((value) => value.toLowerCase()),
1937
+ )
1938
+
1858
1939
  /** Number of grouped entries to target before stopping the page-fetch loop. */
1859
1940
  const minGroupEntries = 5
1860
1941
 
@@ -1981,7 +2062,12 @@ export function addresses(options: addresses.Options = {}) {
1981
2062
  'Get a human-readable feed of what an address has been doing onchain, including payments sent and received, swaps, approvals, and more.',
1982
2063
  operationId: 'getAddressActivities',
1983
2064
  responses: OpenApi.responses({
1984
- errors: { 502: 'Tempo could not read activity or token data from an upstream service.' },
2065
+ errors: {
2066
+ 400: {
2067
+ codes: ['address_invalid', 'chain_id_invalid', 'chain_id_unsupported', 'query_invalid'],
2068
+ },
2069
+ 502: 'Tempo could not read activity or token data from an upstream service.',
2070
+ },
1985
2071
  success: {
1986
2072
  description: 'A page of address activity entries.',
1987
2073
  schema: schema.getAddressActivities.Response,
@@ -1991,7 +2077,7 @@ export function addresses(options: addresses.Options = {}) {
1991
2077
  tags: ['Activities'],
1992
2078
  }),
1993
2079
  Cache.response<App.Environment>({
1994
- cacheControl: Cache.policies.feed,
2080
+ cacheControl: Cache.policies.noStore,
1995
2081
  name: 'tempo-api:addresses:v1',
1996
2082
  key: (c) => Cache.urlKey(c, schema.getAddressActivities.Query),
1997
2083
  }),
@@ -2011,19 +2097,19 @@ export function addresses(options: addresses.Options = {}) {
2011
2097
  const { address } = c.req.valid('param')
2012
2098
  const query = c.req.valid('query')
2013
2099
 
2014
- // Cursor pages can carry current valuations and curated verification data.
2015
- if (query.cursor !== undefined) Cache.setPolicy(c, Cache.policies.metadata)
2100
+ if (!query.include.includes('zones'))
2101
+ Cache.setPolicy(
2102
+ c,
2103
+ query.cursor === undefined ? Cache.policies.feed : Cache.policies.metadata,
2104
+ )
2016
2105
 
2017
2106
  try {
2018
2107
  const chainId = query.chainId ?? c.get('chainId')
2019
- const provider = tidx({ chainId, tidx: c.get('getTidx')(chainId) })
2020
- const { items: unenrichedItems, nextCursor } = await Timing.time(
2021
- c,
2022
- 'address_activities',
2023
- () =>
2024
- Store.memoize(
2025
- () =>
2026
- list(provider, {
2108
+ const listed = await Timing.time(c, 'address_activities', async () => {
2109
+ if (!query.include.includes('zones')) {
2110
+ const result = await Store.memoize(
2111
+ (signal) =>
2112
+ list(tidx({ chainId, signal, tidx: c.get('getTidx')(chainId) }), {
2027
2113
  address: address as Hex.Hex,
2028
2114
  cursor: query.cursor,
2029
2115
  group: query.group,
@@ -2036,18 +2122,56 @@ export function addresses(options: addresses.Options = {}) {
2036
2122
  store: c.get('store'),
2037
2123
  ttl: Ttl.seconds(15),
2038
2124
  },
2039
- ),
2040
- )
2125
+ )
2126
+ return { chainId, kind: 'chain' as const, ...result }
2127
+ }
2041
2128
 
2042
- // Build immutable token and structured amount copies from RPC metadata.
2043
- const { items, rates } = await enrichTokens(c, {
2044
- chainId,
2045
- denomination: query['valuation.currency'],
2046
- include: query.include,
2047
- items: unenrichedItems,
2048
- oracle,
2129
+ const zoneChainIds = c.get('zoneChainIds')
2130
+ if (!zoneChainIds) throw new Error('Inferred Zone selection was not resolved.')
2131
+ const chainIds = [chainId, ...zoneChainIds]
2132
+ const result = await Store.memoize(
2133
+ (signal) =>
2134
+ listChains({
2135
+ address: address as Hex.Hex,
2136
+ cursor: query.cursor,
2137
+ group: query.group,
2138
+ limit: query.limit,
2139
+ logs: query.logs,
2140
+ providers: chainIds.map((chainId) => ({
2141
+ chainId,
2142
+ provider: tidx({
2143
+ chainId,
2144
+ signal,
2145
+ tidx: c.get('getTidx')(chainId),
2146
+ }),
2147
+ })),
2148
+ store: c.get('store'),
2149
+ }),
2150
+ {
2151
+ key: `activities:zones:v2:${chainIds.join(',')}:${address.toLowerCase()}:${query.group ?? false}:${query.logs ?? false}:${query.cursor ?? 'head'}:${query.limit}`,
2152
+ store: c.get('store'),
2153
+ ttl: Ttl.seconds(15),
2154
+ },
2155
+ )
2156
+ return { kind: 'zones' as const, ...result }
2049
2157
  })
2050
2158
 
2159
+ const { items, rates } =
2160
+ listed.kind === 'zones'
2161
+ ? await enrichChainTokens(c, {
2162
+ denomination: query['valuation.currency'],
2163
+ include: query.include,
2164
+ items: listed.items,
2165
+ oracle,
2166
+ })
2167
+ : await enrichTokens(c, {
2168
+ chainId: listed.chainId,
2169
+ denomination: query['valuation.currency'],
2170
+ include: query.include,
2171
+ items: listed.items,
2172
+ oracle,
2173
+ })
2174
+
2051
2175
  // Fold consecutive access-key-signed items into `group` entries only
2052
2176
  // when requested; otherwise every item is returned at the root.
2053
2177
  const data = query.group ? groupByAccessKey(items) : items
@@ -2056,7 +2180,7 @@ export function addresses(options: addresses.Options = {}) {
2056
2180
  Response.validated(schema.getAddressActivities.Response, {
2057
2181
  data,
2058
2182
  ...(rates ? { meta: { valuation: Valuation.pricing(rates, oracle) } } : {}),
2059
- nextCursor,
2183
+ nextCursor: listed.nextCursor,
2060
2184
  }),
2061
2185
  200,
2062
2186
  )
@@ -2084,7 +2208,7 @@ export declare namespace addresses {
2084
2208
  * Runs the same `groupByTx` → `classifyGroup` pipeline the address feed uses
2085
2209
  * over a single transaction's logs, anchored on the transaction sender as the
2086
2210
  * viewer (a transaction-scoped feed has no subject account). Returns the
2087
- * classified item(s) usually one headline activity with the full per-tx
2211
+ * classified items (one per independent activity) with the full per-tx
2088
2212
  * event set available via `logs=true`.
2089
2213
  */
2090
2214
  export function transactions(options: transactions.Options = {}) {
@@ -2106,10 +2230,22 @@ export function transactions(options: transactions.Options = {}) {
2106
2230
  operationId: 'getTransactionActivities',
2107
2231
  responses: OpenApi.responses({
2108
2232
  errors: {
2233
+ 400: {
2234
+ codes: [
2235
+ 'chain_id_invalid',
2236
+ 'chain_id_unsupported',
2237
+ 'query_invalid',
2238
+ 'transaction_invalid',
2239
+ ],
2240
+ },
2109
2241
  404: {
2110
2242
  description: 'No mined transaction was found for that hash.',
2111
2243
  codes: ['transaction_not_found'],
2112
2244
  },
2245
+ 409: {
2246
+ description: 'More than one readable chain contains that transaction hash.',
2247
+ codes: ['transaction_ambiguous'],
2248
+ },
2113
2249
  502: 'Tempo could not read activity or token data from an upstream service.',
2114
2250
  },
2115
2251
  success: {
@@ -2142,48 +2278,126 @@ export function transactions(options: transactions.Options = {}) {
2142
2278
 
2143
2279
  const { transactionHash } = c.req.valid('param')
2144
2280
  const query = c.req.valid('query')
2145
- const chainId = query.chainId ?? c.get('chainId')
2146
2281
 
2147
2282
  try {
2148
- const unenrichedItems = await Timing.time(c, 'transaction_activities', () =>
2149
- Store.memoize(
2150
- () =>
2151
- getTransactionActivities({
2152
- chainId,
2153
- logs: query.logs,
2283
+ const result = await Timing.time(c, 'transaction_activities', async () => {
2284
+ const selected = await (async () => {
2285
+ if (!query.include.includes('zones'))
2286
+ return {
2287
+ chainId: query.chainId ?? c.get('chainId'),
2288
+ kind: 'selected' as const,
2289
+ receipt: null,
2290
+ }
2291
+
2292
+ const zoneChainIds = c.get('zoneChainIds')
2293
+ if (!zoneChainIds) throw new Error('Inferred Zone selection was not resolved.')
2294
+ const chainIds = [query.chainId ?? c.get('chainId'), ...zoneChainIds]
2295
+ // Cache only positive resolution. Missing transactions must remain
2296
+ // visible as soon as any selected indexer catches up.
2297
+ const match = await Store.memoize(
2298
+ async () => {
2299
+ const probes: readonly ZoneSelection.uniqueMatch.Probe<TransactionReceipt>[] =
2300
+ await Promise.all(
2301
+ chainIds.map(async (chainId) => {
2302
+ try {
2303
+ return {
2304
+ chainId,
2305
+ value: await getTransactionReceipt({
2306
+ chainId,
2307
+ tidx: c.get('getTidx')(chainId),
2308
+ transactionHash,
2309
+ }),
2310
+ }
2311
+ } catch (cause) {
2312
+ return { cause, chainId }
2313
+ }
2314
+ }),
2315
+ )
2316
+ return ZoneSelection.uniqueMatch<TransactionReceipt>(probes)
2317
+ },
2318
+ {
2319
+ key: `activities:zones:v2:${chainIds.join(',')}:tx:${transactionHash.toLowerCase()}`,
2320
+ shouldCache: (match) => match.kind === 'selected',
2154
2321
  store: c.get('store'),
2155
- tidx: c.get('getTidx')(chainId),
2156
- transactionHash,
2157
- }),
2322
+ ttl: Ttl.seconds(15),
2323
+ },
2324
+ )
2325
+ if (match.kind === 'failed') {
2326
+ c.set(
2327
+ 'providerFailures',
2328
+ match.failures.map(({ cause, chainId }) => ({
2329
+ ...Tidx.providerFailure(cause),
2330
+ chainId,
2331
+ })),
2332
+ )
2333
+ throw match.failures[0]!.cause
2334
+ }
2335
+ if (match.kind !== 'selected') return match
2336
+ return { chainId: match.chainId, kind: 'selected' as const, receipt: match.value }
2337
+ })()
2338
+
2339
+ if (selected.kind !== 'selected') return selected
2340
+ const { chainId } = selected
2341
+ const unenrichedItems = await Store.memoize(
2342
+ () =>
2343
+ selected.receipt === null
2344
+ ? getTransactionActivities_inner({
2345
+ chainId,
2346
+ logs: query.logs,
2347
+ store: c.get('store'),
2348
+ tidx: c.get('getTidx')(chainId),
2349
+ time: (name, fn) => Timing.time(c, name, fn),
2350
+ transactionHash,
2351
+ })
2352
+ : classifyTransactionActivities({
2353
+ chainId,
2354
+ logs: query.logs,
2355
+ receipt: selected.receipt,
2356
+ store: c.get('store'),
2357
+ tidx: c.get('getTidx')(chainId),
2358
+ time: (name, fn) => Timing.time(c, name, fn),
2359
+ transactionHash,
2360
+ }),
2158
2361
  {
2159
2362
  key: `activities:v1:${chainId}:tx:${transactionHash.toLowerCase()}:${query.logs ?? false}`,
2160
2363
  store: c.get('store'),
2161
2364
  ttl: Ttl.seconds(15),
2162
2365
  },
2163
- ),
2164
- )
2366
+ )
2367
+ return unenrichedItems === null
2368
+ ? ({ kind: 'not_found' } as const)
2369
+ : ({ chainId, items: unenrichedItems, kind: 'found' } as const)
2370
+ })
2165
2371
 
2166
- if (unenrichedItems === null)
2372
+ if (result.kind === 'not_found')
2167
2373
  return Response.error(c, {
2168
2374
  code: 'transaction_not_found',
2169
2375
  message: 'Transaction not found',
2170
2376
  status: 404,
2171
2377
  })
2378
+ if (result.kind === 'ambiguous')
2379
+ return Response.error(c, {
2380
+ code: 'transaction_ambiguous',
2381
+ message: 'Transaction hash matched more than one readable chain',
2382
+ status: 409,
2383
+ })
2172
2384
 
2173
2385
  // Mined activity can carry current valuations and verification data.
2174
- Cache.setPolicy(c, Cache.policies.metadata)
2386
+ if (!query.include.includes('zones')) Cache.setPolicy(c, Cache.policies.metadata)
2387
+ c.set('chainId', result.chainId)
2175
2388
 
2176
2389
  // Build immutable token and structured amount copies from RPC metadata.
2177
2390
  const { items, rates } = await enrichTokens(c, {
2178
- chainId,
2391
+ chainId: result.chainId,
2179
2392
  denomination: query['valuation.currency'],
2180
2393
  include: query.include,
2181
- items: unenrichedItems,
2394
+ items: result.items,
2182
2395
  oracle,
2183
2396
  })
2184
2397
 
2185
2398
  return c.json(
2186
2399
  Response.validated(schema.getTransactionActivities.Response, {
2400
+ chainId: result.chainId,
2187
2401
  data: items,
2188
2402
  ...(rates ? { meta: { valuation: Valuation.pricing(rates, oracle) } } : {}),
2189
2403
  }),
@@ -2219,7 +2433,26 @@ export declare namespace transactions {
2219
2433
  export async function getTransactionActivities(
2220
2434
  options: getTransactionActivities.Options,
2221
2435
  ): Promise<UnenrichedItem[] | null> {
2222
- const { chainId, logs = false, tidx: tidxClient } = options
2436
+ return getTransactionActivities_inner({
2437
+ ...options,
2438
+ time: async (_name, fn) => fn(),
2439
+ })
2440
+ }
2441
+
2442
+ async function getTransactionActivities_inner(
2443
+ options: TransactionActivityOptions,
2444
+ ): Promise<UnenrichedItem[] | null> {
2445
+ const receipt = await options.time('transaction_activity_receipt', () =>
2446
+ getTransactionReceipt(options),
2447
+ )
2448
+ if (!receipt) return null
2449
+ return classifyTransactionActivities({ ...options, receipt })
2450
+ }
2451
+
2452
+ async function getTransactionReceipt(
2453
+ options: getTransactionReceipt.Options,
2454
+ ): Promise<TransactionReceipt | null> {
2455
+ const { chainId, tidx: tidxClient } = options
2223
2456
  const hash = options.transactionHash.toLowerCase()
2224
2457
 
2225
2458
  // The `receipts` table is indexed by `tx_hash`, so this point lookup needs no
@@ -2234,17 +2467,45 @@ export async function getTransactionActivities(
2234
2467
  const receipt = receiptResult.rows[0]
2235
2468
  if (!receipt) return null
2236
2469
 
2237
- const blockNum = Number(Value.toNumber(receipt['block_num']))
2238
- const feePayer = lowerOrNull(receipt['fee_payer'])
2239
- const txSender = lowerOrNull(receipt['from'])
2470
+ return {
2471
+ blockNum: Number(Value.toNumber(receipt['block_num'])),
2472
+ feePayer: lowerOrNull(receipt['fee_payer']),
2473
+ txSender: lowerOrNull(receipt['from']),
2474
+ }
2475
+ }
2476
+
2477
+ declare namespace getTransactionReceipt {
2478
+ type Options = Pick<getTransactionActivities.Options, 'chainId' | 'tidx' | 'transactionHash'>
2479
+ }
2480
+
2481
+ type TransactionReceipt = {
2482
+ /** Block number containing the transaction. */
2483
+ blockNum: number
2484
+ /** Account that paid the transaction fee, when indexed. */
2485
+ feePayer: Address.Address | null
2486
+ /** Account that sent the transaction, when indexed. */
2487
+ txSender: Address.Address | null
2488
+ }
2489
+
2490
+ async function classifyTransactionActivities(
2491
+ options: classifyTransactionActivities.Options,
2492
+ ): Promise<UnenrichedItem[]> {
2493
+ const { chainId, logs = false, receipt, tidx: tidxClient } = options
2494
+ const hash = options.transactionHash.toLowerCase()
2495
+ const { blockNum, feePayer, txSender } = receipt
2496
+ const fingerprintMap_promise = options
2497
+ .time('transaction_activity_mpp', () => Mpp.fingerprintMap({ store: options.store }))
2498
+ .catch(() => ({}))
2240
2499
 
2241
2500
  // `logs` is sorted by block position, so a bare `tx_hash =` scans to genesis;
2242
2501
  // pair it with the receipt's block number (see `embedReceipts` in receipts.ts).
2243
- const logsResult = await tidxClient.fetch({
2244
- chainId,
2245
- query:
2246
- `SELECT tx_hash, block_num, block_timestamp, tx_idx, log_idx, address, selector, topic1, topic2, topic3, data FROM logs WHERE block_num = ${blockNum} AND tx_hash = '${hash}' ORDER BY log_idx ASC` as string,
2247
- })
2502
+ const logsResult = await options.time('transaction_activity_logs', () =>
2503
+ tidxClient.fetch({
2504
+ chainId,
2505
+ query:
2506
+ `SELECT tx_hash, block_num, block_timestamp, tx_idx, log_idx, address, selector, topic1, topic2, topic3, data FROM logs WHERE block_num = ${blockNum} AND tx_hash = '${hash}' ORDER BY log_idx ASC` as string,
2507
+ }),
2508
+ )
2248
2509
 
2249
2510
  const rows: Row[] = logsResult.rows.map((r) => ({
2250
2511
  blockNum: Number(Value.toNumber(r['block_num'])),
@@ -2269,7 +2530,7 @@ export async function getTransactionActivities(
2269
2530
 
2270
2531
  // Best-effort MPP service directory: a fetch failure yields an empty map, so
2271
2532
  // enrichment never blocks classification.
2272
- const fingerprintMap = await Mpp.fingerprintMap({ store: options.store }).catch(() => ({}))
2533
+ const fingerprintMap = await fingerprintMap_promise
2273
2534
 
2274
2535
  // Anchor the viewer on the transaction sender: the sender initiated every
2275
2536
  // activity in the transaction, so `perspective` is `outgoing` and `signer`
@@ -2279,6 +2540,13 @@ export async function getTransactionActivities(
2279
2540
  return classifyGroup(group, viewer, fingerprintMap, logs).map(withTitle)
2280
2541
  }
2281
2542
 
2543
+ declare namespace classifyTransactionActivities {
2544
+ type Options = TransactionActivityOptions & {
2545
+ /** Indexed receipt fields used to bound the log lookup. */
2546
+ receipt: TransactionReceipt
2547
+ }
2548
+ }
2549
+
2282
2550
  export declare namespace getTransactionActivities {
2283
2551
  /** Options for {@link getTransactionActivities}. */
2284
2552
  type Options = {
@@ -2295,11 +2563,18 @@ export declare namespace getTransactionActivities {
2295
2563
  }
2296
2564
  }
2297
2565
 
2566
+ type TransactionActivityOptions = getTransactionActivities.Options & {
2567
+ time: <value>(
2568
+ name: 'transaction_activity_logs' | 'transaction_activity_mpp' | 'transaction_activity_receipt',
2569
+ fn: () => Promise<value> | value,
2570
+ ) => Promise<value>
2571
+ }
2572
+
2298
2573
  /**
2299
2574
  * Lists classified activity for an address by running the shared pipeline
2300
2575
  * (`groupByTx` → `classifyGroup` → `enrichItem`) over rows from a
2301
- * {@link Provider}. Returns enriched, chain-tagged items plus the next cursor;
2302
- * callers run {@link groupByAccessKey} over the result.
2576
+ * {@link Provider}. Returns classified items plus the next cursor; callers run
2577
+ * {@link groupByAccessKey} over the result.
2303
2578
  */
2304
2579
  export async function list(provider: Provider, options: list.Options): Promise<list.ReturnType> {
2305
2580
  const { address, cursor, group = false, limit = 20, logs = false } = options
@@ -2307,7 +2582,7 @@ export async function list(provider: Provider, options: list.Options): Promise<l
2307
2582
 
2308
2583
  // Best-effort MPP service directory: a fetch failure yields an empty map, so
2309
2584
  // enrichment never blocks the feed.
2310
- const fingerprintMap = await Mpp.fingerprintMap({ store: options.store }).catch(() => ({}))
2585
+ const fingerprintMap_promise = Mpp.fingerprintMap({ store: options.store }).catch(() => ({}))
2311
2586
 
2312
2587
  let currentCursor: string | undefined = cursor
2313
2588
  const allItems: UnenrichedItem[] = []
@@ -2326,6 +2601,7 @@ export async function list(provider: Provider, options: list.Options): Promise<l
2326
2601
  hasMore = txGroups.length > limit
2327
2602
  const page = txGroups.slice(0, limit)
2328
2603
 
2604
+ const fingerprintMap = await fingerprintMap_promise
2329
2605
  if (page.length === 0) break
2330
2606
 
2331
2607
  allItems.push(
@@ -2374,6 +2650,175 @@ export declare namespace list {
2374
2650
  }
2375
2651
  }
2376
2652
 
2653
+ const chainCursorVersion = 2
2654
+
2655
+ type ChainPageState = {
2656
+ /** Chain ID backing this page state. */
2657
+ chainId: number
2658
+ /** Whether the current buffer reaches the end of this chain feed. */
2659
+ exhausted: boolean
2660
+ /** Unconsumed transaction groups already fetched from this chain. */
2661
+ groups: TxGroup[]
2662
+ /** Activity data source for this chain. */
2663
+ provider: Provider
2664
+ }
2665
+
2666
+ /**
2667
+ * Lists address activity across multiple chains in one stable timestamp-ordered
2668
+ * feed. Its opaque cursor retains each provider's native continuation.
2669
+ */
2670
+ export async function listChains(options: listChains.Options): Promise<listChains.ReturnType> {
2671
+ const { address, cursor, limit = 20, logs = false, providers, store } = options
2672
+ const group = options.group ?? false
2673
+ const orderedProviders = [...providers].sort((a, b) => a.chainId - b.chainId)
2674
+ const chainIds = orderedProviders.map(({ chainId }) => chainId)
2675
+ const cursors = decodeChainCursor(cursor, chainIds)
2676
+ const viewer = address.toLowerCase() as Address.Address
2677
+ const fingerprintMap_promise = Mpp.fingerprintMap({ store }).catch(() => ({}))
2678
+ const states: ChainPageState[] = orderedProviders.map(({ chainId, provider }) => ({
2679
+ chainId,
2680
+ exhausted: false,
2681
+ groups: [],
2682
+ provider,
2683
+ }))
2684
+ const allItems: listChains.Item[] = []
2685
+ let hasMore = false
2686
+
2687
+ for (let attempt = 0; attempt < 3; attempt++) {
2688
+ await Promise.all(
2689
+ states.map(async (state) => {
2690
+ if (state.exhausted || state.groups.length > 0) return
2691
+ const { rows } = await state.provider.fetchRows({
2692
+ address: viewer,
2693
+ cursor: cursors.get(state.chainId),
2694
+ limit,
2695
+ })
2696
+ const groups = groupByTx(rows).slice(0, limit + 1)
2697
+ state.exhausted = groups.length <= limit
2698
+ state.groups.push(...groups)
2699
+ }),
2700
+ )
2701
+ const candidates = states
2702
+ .flatMap((state) => state.groups.map((txGroup) => ({ state, txGroup })))
2703
+ .sort((a, b) => {
2704
+ if (a.txGroup.blockTimestamp !== b.txGroup.blockTimestamp)
2705
+ return b.txGroup.blockTimestamp - a.txGroup.blockTimestamp
2706
+ if (a.state.chainId !== b.state.chainId) return a.state.chainId - b.state.chainId
2707
+ if (a.txGroup.blockNum !== b.txGroup.blockNum)
2708
+ return b.txGroup.blockNum - a.txGroup.blockNum
2709
+ return b.txGroup.txIdx - a.txGroup.txIdx
2710
+ })
2711
+ const page = candidates.slice(0, limit)
2712
+ const fingerprintMap = await fingerprintMap_promise
2713
+ if (page.length === 0) break
2714
+
2715
+ for (const { state, txGroup } of page) {
2716
+ state.groups.splice(state.groups.indexOf(txGroup), 1)
2717
+ cursors.set(state.chainId, state.provider.cursorForGroup(txGroup))
2718
+ allItems.push(
2719
+ ...classifyGroup(txGroup, viewer, fingerprintMap, logs).map((item) => ({
2720
+ ...withTitle(item),
2721
+ chainId: state.chainId,
2722
+ })),
2723
+ )
2724
+ }
2725
+
2726
+ hasMore = states.some((state) => !state.exhausted || state.groups.length > 0)
2727
+ const enough = group
2728
+ ? groupByAccessKeyItems(allItems).length >= minGroupEntries
2729
+ : allItems.length >= limit
2730
+ if (enough || !hasMore) break
2731
+ }
2732
+
2733
+ // A consumed sentinel proves only that the provider may have more rows.
2734
+ // Probe drained buffers so `nextCursor` remains the end-of-list signal.
2735
+ await Promise.all(
2736
+ states.map(async (state) => {
2737
+ if (state.exhausted || state.groups.length > 0) return
2738
+ const { rows } = await state.provider.fetchRows({
2739
+ address: viewer,
2740
+ cursor: cursors.get(state.chainId),
2741
+ limit: 1,
2742
+ })
2743
+ const group = groupByTx(rows)[0]
2744
+ if (group) state.groups.push(group)
2745
+ else state.exhausted = true
2746
+ }),
2747
+ )
2748
+ hasMore = states.some((state) => !state.exhausted || state.groups.length > 0)
2749
+
2750
+ return {
2751
+ items: allItems,
2752
+ nextCursor: hasMore ? encodeChainCursor(cursors, chainIds) : null,
2753
+ }
2754
+ }
2755
+
2756
+ export declare namespace listChains {
2757
+ /** One chain activity provider. */
2758
+ type ChainProvider = {
2759
+ /** Chain ID. */
2760
+ chainId: number
2761
+ /** Activity data source for the chain. */
2762
+ provider: Provider
2763
+ }
2764
+
2765
+ /** A classified item tagged with its chain ID. */
2766
+ type Item = UnenrichedItem & {
2767
+ /** Chain ID containing the activity. */
2768
+ chainId: number
2769
+ }
2770
+
2771
+ /** Options for {@link listChains}. */
2772
+ type Options = {
2773
+ /** Subject account address. */
2774
+ address: Address.Address
2775
+ /** Opaque multi-chain cursor from a previous page. */
2776
+ cursor?: string | undefined
2777
+ /** Whether the caller will fold access-key items. */
2778
+ group?: boolean | undefined
2779
+ /** Target number of transaction groups per page. */
2780
+ limit?: number | undefined
2781
+ /** Attach each item's full transaction event capture. */
2782
+ logs?: boolean | undefined
2783
+ /** Chain providers participating in the merged feed. */
2784
+ providers: readonly ChainProvider[]
2785
+ /** Cache store for the MPP fingerprint-directory lookup. */
2786
+ store: Store.Store
2787
+ }
2788
+
2789
+ /** Multi-chain classified page before token enrichment. */
2790
+ type ReturnType = {
2791
+ /** Timestamp-ordered, chain-tagged activity items. */
2792
+ items: Item[]
2793
+ /** Opaque continuation for every chain, or null at the end. */
2794
+ nextCursor: string | null
2795
+ }
2796
+ }
2797
+
2798
+ function decodeChainCursor(cursor: string | undefined, chainIds: readonly number[]) {
2799
+ const fields: Cursor.Field[] = ['int']
2800
+ for (const _chainId of chainIds) fields.push('int', 'id')
2801
+ const decoded = cursor ? Cursor.decode(cursor, fields) : undefined
2802
+ const cursors = new Map<number, string>()
2803
+ if (!decoded || decoded[0] !== chainCursorVersion) return cursors
2804
+
2805
+ for (let index = 0; index < chainIds.length; index++) {
2806
+ const chainId = chainIds[index]!
2807
+ const offset = index * 2 + 1
2808
+ if (decoded[offset] !== chainId) return new Map()
2809
+ const value = decoded[offset + 1] as string
2810
+ if (value !== 'head') cursors.set(chainId, value)
2811
+ }
2812
+ return cursors
2813
+ }
2814
+
2815
+ function encodeChainCursor(cursors: ReadonlyMap<number, string>, chainIds: readonly number[]) {
2816
+ return Cursor.encode([
2817
+ chainCursorVersion,
2818
+ ...chainIds.flatMap((chainId) => [chainId, cursors.get(chainId) ?? 'head']),
2819
+ ])
2820
+ }
2821
+
2377
2822
  /**
2378
2823
  * Creates a TIDX-backed {@link Provider} that runs three sub-queries in
2379
2824
  * parallel and merges them:
@@ -2384,7 +2829,18 @@ export declare namespace list {
2384
2829
  * (catches access-key-signed sends where `tx.from` differs from the address).
2385
2830
  */
2386
2831
  export function tidx(deps: tidx.Deps): Provider {
2387
- const { chainId, tidx: tidxClient } = deps
2832
+ const { chainId, signal, tidx: tidxClient } = deps
2833
+
2834
+ const fetch: Tidx.Client['fetch'] = (options) => {
2835
+ const requestSignal =
2836
+ signal && options.signal
2837
+ ? AbortSignal.any([signal, options.signal])
2838
+ : (signal ?? options.signal)
2839
+ return tidxClient.fetch({
2840
+ ...options,
2841
+ ...(requestSignal ? { signal: requestSignal } : {}),
2842
+ })
2843
+ }
2388
2844
 
2389
2845
  function parseCursor(cursor: string): { blockNum: number; txIdx: number } | undefined {
2390
2846
  // Opaque `[blockNum, txIdx]` keyset cursor, the same encoding every other
@@ -2410,7 +2866,7 @@ export function tidx(deps: tidx.Deps): Provider {
2410
2866
  : ''
2411
2867
  const queryLimit = limit + 1
2412
2868
  const fetchReceipts = (side: 'fee_payer' | 'from') =>
2413
- tidxClient.fetch({
2869
+ fetch({
2414
2870
  chainId,
2415
2871
  // Raw-table query (no signature-decoded CTE), so the inline SQL is cast
2416
2872
  // to `string`.
@@ -2454,7 +2910,7 @@ export function tidx(deps: tidx.Deps): Provider {
2454
2910
 
2455
2911
  // `logs` is not keyed by transaction hash, so pair the bounded hash list
2456
2912
  // with the selected receipts' block range to avoid a full archive scan.
2457
- const logs = await tidxClient.fetch({
2913
+ const logs = await fetch({
2458
2914
  chainId,
2459
2915
  // Raw-table query (no signature-decoded CTE), so the inline SQL is cast
2460
2916
  // to `string`.
@@ -2514,7 +2970,7 @@ export function tidx(deps: tidx.Deps): Provider {
2514
2970
  `
2515
2971
  const result = await (async () => {
2516
2972
  try {
2517
- return await tidxClient.fetch({
2973
+ return await fetch({
2518
2974
  chainId,
2519
2975
  query: query('block_num'),
2520
2976
  signatures: [transferSignature],
@@ -2523,14 +2979,14 @@ export function tidx(deps: tidx.Deps): Provider {
2523
2979
  if (!Tidx.isDeterministicError(error)) throw error
2524
2980
  try {
2525
2981
  // The expression blocks a mis-selected block-ordered top-N walk.
2526
- return await tidxClient.fetch({
2982
+ return await fetch({
2527
2983
  chainId,
2528
2984
  query: query('block_num + 0'),
2529
2985
  signatures: [transferSignature],
2530
2986
  })
2531
2987
  } catch (fallbackError) {
2532
2988
  if (!Tidx.isDeterministicError(fallbackError)) throw fallbackError
2533
- const result = await tidxClient.fetch({
2989
+ const result = await fetch({
2534
2990
  chainId,
2535
2991
  query: query(),
2536
2992
  signatures: [transferSignature],
@@ -2585,6 +3041,8 @@ export declare namespace tidx {
2585
3041
  type Deps = {
2586
3042
  /** Source chain id. */
2587
3043
  chainId: number
3044
+ /** Optional deadline signal shared by this provider's TIDX reads. */
3045
+ signal?: AbortSignal | undefined
2588
3046
  /** TIDX query client. */
2589
3047
  tidx: Tidx.Client
2590
3048
  }
@@ -2599,8 +3057,10 @@ export function mergeAndSort(q1: readonly Row[], q2: readonly Row[], q3: readonl
2599
3057
  const q1Hashes = new Set(q1.map((r) => r.txHash))
2600
3058
  const filteredQ2 = q2.filter((r) => !q1Hashes.has(r.txHash))
2601
3059
  const filteredQ3 = q3.filter((r) => !q1Hashes.has(r.txHash))
2602
- const q2Hashes = new Set(filteredQ2.map((r) => r.txHash))
2603
- const dedupedQ3 = filteredQ3.filter((r) => !q2Hashes.has(r.txHash))
3060
+ // Dedup by row, not transaction: a tx that both credits and debits the
3061
+ // viewer keeps its rows from both sides, one item each.
3062
+ const q2Keys = new Set(filteredQ2.map((r) => `${r.txHash}:${r.logIdx}`))
3063
+ const dedupedQ3 = filteredQ3.filter((r) => !q2Keys.has(`${r.txHash}:${r.logIdx}`))
2604
3064
  return [...q1, ...filteredQ2, ...dedupedQ3].sort((a, b) => {
2605
3065
  if (a.blockNum !== b.blockNum) return b.blockNum - a.blockNum
2606
3066
  return b.txIdx - a.txIdx
@@ -2677,34 +3137,38 @@ function classifyGroup(
2677
3137
  viewer,
2678
3138
  })
2679
3139
 
2680
- // Q2-only — a transfer row without a joined receipt. Classify by comparing
2681
- // `from`/`to` against the viewer (zero-address sides are mints/burns).
2682
- if (q2Rows.length > 0) {
2683
- const r = q2Rows[0]!
2684
- const base = itemBase(r.logIdx)
2685
- const from = r.transferFrom!
2686
- const to = r.transferTo!
2687
- const sourceToken = token(r.transferAmount, r.transferToken!)
2688
- if (Address.isEqual(from, zeroAddress as Address.Address))
2689
- return [{ ...base, data: { recipient: viewer, sourceToken }, type: 'mint' }]
2690
- if (Address.isEqual(to, zeroAddress as Address.Address))
2691
- return [{ ...base, data: { sender: from, signer, sourceToken }, type: 'burn' }]
2692
- // Q2-only rows carry no memo, so MPP service attribution is unavailable
2693
- // here; `attribution` is resolved only on the log-bearing (Q1) path.
2694
- if (Address.isEqual(from, viewer))
2695
- return [
2696
- transfer(base, blockNum, {
2697
- direction: 'out',
2698
- recipient: to,
2699
- sender: viewer,
2700
- signer,
3140
+ // Q2-only: transfer rows without a joined receipt, one item per row so a
3141
+ // batch paying the viewer several times surfaces every payment. Classify by
3142
+ // comparing `from`/`to` against the viewer (zero-address sides mint/burn).
3143
+ if (q2Rows.length > 0)
3144
+ return [...q2Rows]
3145
+ .sort((a, b) => a.logIdx - b.logIdx)
3146
+ .map((r) => {
3147
+ const base = itemBase(r.logIdx)
3148
+ const from = r.transferFrom!
3149
+ const to = r.transferTo!
3150
+ const sourceToken = token(r.transferAmount, r.transferToken!)
3151
+ if (Address.isEqual(from, zeroAddress as Address.Address))
3152
+ return { ...base, data: { recipient: viewer, sourceToken }, type: 'mint' }
3153
+ if (Address.isEqual(to, zeroAddress as Address.Address))
3154
+ return { ...base, data: { sender: from, signer, sourceToken }, type: 'burn' }
3155
+ // Q2-only rows carry no memo, so MPP service attribution is unavailable
3156
+ // here; `attribution` is resolved only on the log-bearing (Q1) path.
3157
+ if (Address.isEqual(from, viewer))
3158
+ return transfer(base, blockNum, {
3159
+ direction: 'out',
3160
+ recipient: to,
3161
+ sender: viewer,
3162
+ signer,
3163
+ sourceToken,
3164
+ })
3165
+ return transfer(base, blockNum, {
3166
+ direction: 'in',
3167
+ recipient: viewer,
3168
+ sender: from,
2701
3169
  sourceToken,
2702
- }),
2703
- ]
2704
- return [
2705
- transfer(base, blockNum, { direction: 'in', recipient: viewer, sender: from, sourceToken }),
2706
- ]
2707
- }
3170
+ })
3171
+ })
2708
3172
 
2709
3173
  // Fallback — should not happen (a group with neither q1 nor q2 rows).
2710
3174
  return [
@@ -2743,7 +3207,7 @@ function classifyFromLogs(
2743
3207
  })()
2744
3208
  // When the caller requested raw logs, attach the full per-transaction log set
2745
3209
  // (decoded when possible) to every classified item so no co-located log is
2746
- // hidden behind the headline. `unknown` items already carry it.
3210
+ // hidden. `unknown` items already carry it.
2747
3211
  if (!ctx.includeLogs) return items
2748
3212
  const events = buildTxEvents(rows, decoded)
2749
3213
  return items.map((item) => (item.type === 'unknown' ? item : { ...item, events }))
@@ -2767,7 +3231,11 @@ function signerForRows(
2767
3231
  : options.fallback
2768
3232
  }
2769
3233
 
2770
- /** Classifies a Q1 (log-bearing) group via `parseEventLogs`, picking one headline item. */
3234
+ /**
3235
+ * Classifies every activity in a Q1 (log-bearing) transaction. Recognizers run
3236
+ * in priority order over a pool of decoded events, each emitting one item per
3237
+ * matched anchor and claiming the exact logs backing it.
3238
+ */
2771
3239
  function classifyFromLogsCore(
2772
3240
  rows: Row[],
2773
3241
  ctx: {
@@ -2785,97 +3253,196 @@ function classifyFromLogsCore(
2785
3253
 
2786
3254
  const minLogIndex = Math.min(...rows.map((r) => r.logIdx))
2787
3255
 
2788
- // Payment-channel settlement: a CloseRequested topic plus an optional refund
2789
- // transfer back to the viewer.
2790
- const hasCloseRequested = rows.some((r) => r.selector === closeRequestedSelector)
2791
- if (hasCloseRequested) {
2792
- const closeRequestedLogIndex =
2793
- rows.find((r) => r.selector === closeRequestedSelector)?.logIdx ?? minLogIndex
2794
- const refundTransfer = events.find(
2795
- (e) =>
2796
- (e.eventName === 'Transfer' || e.eventName === 'TransferWithMemo') &&
2797
- 'to' in e.args &&
2798
- Address.isEqual((e.args as { to: Address.Address }).to, viewer),
2799
- )
2800
- const refund = refundTransfer
2801
- ? token(
2802
- (refundTransfer.args as { amount: bigint }).amount,
2803
- refundTransfer.address as Address.Address,
2804
- )
2805
- : undefined
2806
- return [
2807
- {
2808
- ...itemBase(closeRequestedLogIndex),
2809
- data: { ...(refund ? { refund } : {}), signer },
2810
- type: 'session-closed',
2811
- },
2812
- ]
2813
- }
2814
-
2815
- // Dedup preference: TransferWithMemo beats Transfer; Mint/Burn beat
2816
- // zero-address Transfer.
2817
- const preferenceMap = new Map<string, string>()
3256
+ // Pair folds are counted: each TransferWithMemo folds one matching plain
3257
+ // Transfer and each Mint/Burn event folds one zero-address transfer, so an
3258
+ // extra identical payment survives as its own item. Keys require the full
3259
+ // event shape; a malformed event must not fold away a classifiable leg.
3260
+ const foldCounts = new Map<string, number>()
3261
+ const addFold = (key: string) => foldCounts.set(key, (foldCounts.get(key) ?? 0) + 1)
2818
3262
  for (const event of events) {
2819
3263
  if (event.eventName === 'TransferWithMemo' && 'from' in event.args && 'to' in event.args) {
2820
3264
  const args = event.args as { amount: bigint; from: Address.Address; to: Address.Address }
2821
- preferenceMap.set(`${args.from}:${args.to}:${args.amount}`, 'TransferWithMemo')
3265
+ addFold(`${event.address}:${args.from}:${args.to}:${args.amount}`)
2822
3266
  }
2823
- if (event.eventName === 'Mint' && 'amount' in event.args)
2824
- preferenceMap.set(
2825
- `mint:${event.address}:${(event.args as { amount: bigint }).amount}`,
2826
- 'Mint',
2827
- )
2828
- if (event.eventName === 'Burn' && 'amount' in event.args)
2829
- preferenceMap.set(
2830
- `burn:${event.address}:${(event.args as { amount: bigint }).amount}`,
2831
- 'Burn',
2832
- )
3267
+ if (event.eventName === 'Mint' && 'amount' in event.args && 'to' in event.args)
3268
+ addFold(`mint:${event.address}:${(event.args as { amount: bigint }).amount}`)
3269
+ if (event.eventName === 'Burn' && 'amount' in event.args && 'from' in event.args)
3270
+ addFold(`burn:${event.address}:${(event.args as { amount: bigint }).amount}`)
3271
+ }
3272
+ const takeFold = (key: string) => {
3273
+ const count = foldCounts.get(key) ?? 0
3274
+ if (count === 0) return false
3275
+ foldCounts.set(key, count - 1)
3276
+ return true
2833
3277
  }
2834
3278
 
2835
- const dedupedEvents = events.filter((event) => {
2836
- if (event.eventName !== 'Transfer') return true
3279
+ const finalEvents = events.filter((event) => {
3280
+ if (event.eventName !== 'Transfer' && event.eventName !== 'TransferWithMemo') return true
3281
+ if (!('from' in event.args) || !('to' in event.args)) return true
2837
3282
  const args = event.args as { amount: bigint; from: Address.Address; to: Address.Address }
2838
- if (preferenceMap.has(`${args.from}:${args.to}:${args.amount}`)) return false
2839
- if (
2840
- Address.isEqual(args.from, zeroAddress as Address.Address) &&
2841
- preferenceMap.has(`mint:${event.address}:${args.amount}`)
2842
- )
2843
- return false
2844
3283
  if (
2845
- Address.isEqual(args.to, zeroAddress as Address.Address) &&
2846
- preferenceMap.has(`burn:${event.address}:${args.amount}`)
3284
+ event.eventName === 'Transfer' &&
3285
+ takeFold(`${event.address}:${args.from}:${args.to}:${args.amount}`)
2847
3286
  )
2848
3287
  return false
2849
- return true
2850
- })
2851
-
2852
- const finalEvents = dedupedEvents.filter((event) => {
2853
- if (event.eventName !== 'TransferWithMemo') return true
2854
- const args = event.args as { amount: bigint; from: Address.Address; to: Address.Address }
2855
3288
  if (
2856
3289
  Address.isEqual(args.from, zeroAddress as Address.Address) &&
2857
- preferenceMap.has(`mint:${event.address}:${args.amount}`)
3290
+ takeFold(`mint:${event.address}:${args.amount}`)
2858
3291
  )
2859
3292
  return false
2860
3293
  if (
2861
3294
  Address.isEqual(args.to, zeroAddress as Address.Address) &&
2862
- preferenceMap.has(`burn:${event.address}:${args.amount}`)
3295
+ takeFold(`burn:${event.address}:${args.amount}`)
2863
3296
  )
2864
3297
  return false
2865
3298
  return true
2866
3299
  })
2867
3300
 
2868
- const transfers = finalEvents.filter(
2869
- (e) =>
2870
- (e.eventName === 'Transfer' || e.eventName === 'TransferWithMemo') &&
2871
- 'to' in e.args &&
2872
- 'from' in e.args &&
2873
- 'amount' in e.args,
3301
+ type Decoded = (typeof finalEvents)[number]
3302
+
3303
+ const pool = [...finalEvents].sort((a, b) => a.logIndex - b.logIndex)
3304
+ const claimed = new Set<number>()
3305
+ const items: RawItem[] = []
3306
+ // Claims made while building an item are journaled, so a build that throws
3307
+ // on a malformed event releases its legs and consumes only the anchor.
3308
+ let journal: number[] | undefined
3309
+ const claim = (event: Decoded) => {
3310
+ claimed.add(event.logIndex)
3311
+ journal?.push(event.logIndex)
3312
+ }
3313
+ // A predicate throwing on a malformed decode must not discard the whole
3314
+ // transaction; treat that event as a non-match instead.
3315
+ const unclaimed = (predicate?: (event: Decoded) => boolean) =>
3316
+ pool.filter((event) => {
3317
+ if (claimed.has(event.logIndex)) return false
3318
+ if (!predicate) return true
3319
+ try {
3320
+ return predicate(event)
3321
+ } catch {
3322
+ return false
3323
+ }
3324
+ })
3325
+ const lc = (value: Address.Address) => value.toLowerCase() as Address.Address
3326
+ const hx = (value: Hex.Hex) => value.toLowerCase() as Hex.Hex
3327
+
3328
+ // Non-strict decoding can surface Transfer-shaped events with malformed
3329
+ // args; validating (once per event) keeps predicate comparisons throw-free.
3330
+ const transferIndexes = new Set(
3331
+ pool
3332
+ .filter((event) => {
3333
+ if (event.eventName !== 'Transfer' && event.eventName !== 'TransferWithMemo') return false
3334
+ const args = event.args as { amount?: unknown; from?: unknown; to?: unknown }
3335
+ return (
3336
+ typeof args.amount === 'bigint' &&
3337
+ typeof args.from === 'string' &&
3338
+ Address.validate(args.from) &&
3339
+ typeof args.to === 'string' &&
3340
+ Address.validate(args.to)
3341
+ )
3342
+ })
3343
+ .map((event) => event.logIndex),
2874
3344
  )
3345
+ const isTransfer = (event: Decoded) => transferIndexes.has(event.logIndex)
3346
+ const transferArgs = (event: Decoded) =>
3347
+ event.args as {
3348
+ amount: bigint
3349
+ from: Address.Address
3350
+ memo?: Hex.Hex | undefined
3351
+ to: Address.Address
3352
+ }
3353
+ // Finds and claims the first unclaimed event matching `predicate`, so a
3354
+ // later identical anchor pairs with the next matching leg instead of
3355
+ // reusing this one.
3356
+ const takeEvent = (predicate: (event: Decoded) => boolean) => {
3357
+ for (const event of pool) {
3358
+ if (claimed.has(event.logIndex)) continue
3359
+ const match = (() => {
3360
+ try {
3361
+ return predicate(event)
3362
+ } catch {
3363
+ return false
3364
+ }
3365
+ })()
3366
+ if (!match) continue
3367
+ claim(event)
3368
+ return event
3369
+ }
3370
+ return undefined
3371
+ }
3372
+ const takeTransfer = (predicate: (event: Decoded) => boolean) =>
3373
+ takeEvent((event) => isTransfer(event) && predicate(event))
3374
+
3375
+ // Runs `build` once per unclaimed matching event, in log order; `build`
3376
+ // claims its legs and returns the item(s) to emit, or nothing for
3377
+ // fold-only recognitions. A throwing build consumes only its anchor.
3378
+ const recognize = (
3379
+ predicate: (event: Decoded) => boolean,
3380
+ build: (event: Decoded) => RawItem | readonly RawItem[] | undefined,
3381
+ ) => {
3382
+ for (const anchor of unclaimed(predicate)) {
3383
+ // A build may claim a later anchor of the same pass (or a log decoded
3384
+ // into duplicate ABI copies); never process a claimed anchor again.
3385
+ if (claimed.has(anchor.logIndex)) continue
3386
+ claim(anchor)
3387
+ journal = []
3388
+ try {
3389
+ const built = build(anchor)
3390
+ if (built) items.push(...(Array.isArray(built) ? built : [built as RawItem]))
3391
+ } catch {
3392
+ // Indexed logs can match a known signature without satisfying that
3393
+ // event's expected shape; release the legs and skip the anchor.
3394
+ for (const index of journal ?? []) claimed.delete(index)
3395
+ } finally {
3396
+ journal = undefined
3397
+ }
3398
+ }
3399
+ }
3400
+ const named = (name: string) => (event: Decoded) => event.eventName === name
3401
+
3402
+ // Claims and returns a vault operation's token legs, matched by role and
3403
+ // amount: asset legs in or out of the vault, share mint/burn legs on it.
3404
+ const claimVaultLegs = (vault: Address.Address, amounts: readonly bigint[]) => {
3405
+ const legs: Decoded[] = []
3406
+ for (const event of unclaimed()) {
3407
+ const isVaultMintBurn =
3408
+ (event.eventName === 'Mint' || event.eventName === 'Burn') &&
3409
+ Address.isEqual(event.address, vault)
3410
+ const isVaultLeg = (() => {
3411
+ if (!isTransfer(event)) return false
3412
+ const args = transferArgs(event)
3413
+ if (Address.isEqual(args.from, vault) || Address.isEqual(args.to, vault)) return true
3414
+ return (
3415
+ Address.isEqual(event.address, vault) &&
3416
+ (Address.isEqual(args.from, zeroAddress as Address.Address) ||
3417
+ Address.isEqual(args.to, zeroAddress as Address.Address))
3418
+ )
3419
+ })()
3420
+ if (!isVaultMintBurn && !isVaultLeg) continue
3421
+ const amount = (event.args as { amount?: bigint }).amount
3422
+ if (amount === undefined || !amounts.includes(amount)) continue
3423
+ claim(event)
3424
+ legs.push(event)
3425
+ }
3426
+ // The allowance granted for this operation (approve+deposit multicall) is
3427
+ // internal: exact amount, spender is the vault, on a token a leg moved.
3428
+ const legTokens = new Set(legs.map((leg) => leg.address.toLowerCase()))
3429
+ for (const event of unclaimed()) {
3430
+ if (event.eventName !== 'Approval' || !('spender' in event.args)) continue
3431
+ if (!legTokens.has(event.address.toLowerCase())) continue
3432
+ if (!Address.isEqual((event.args as { spender: Address.Address }).spender, vault)) continue
3433
+ const amount = (event.args as { amount?: bigint }).amount
3434
+ if (amount === undefined || !amounts.includes(amount)) continue
3435
+ claim(event)
3436
+ legs.push(event)
3437
+ }
3438
+ return legs
3439
+ }
2875
3440
 
2876
- const privateDeposit = finalEvents.find((event) => event.eventName === 'EarnDeposit')
2877
- if (privateDeposit) {
2878
- const args = privateDeposit.args as {
3441
+ // Private Earn router operations. Each wraps a public vault operation on the
3442
+ // vault it names; fold that vault's own event and token legs, leaving vault
3443
+ // activity elsewhere in the batch unclaimed.
3444
+ recognize(named('EarnDeposit'), (event) => {
3445
+ const args = event.args as {
2879
3446
  actionId: Hex.Hex
2880
3447
  earnShares: bigint
2881
3448
  earnVault: Address.Address
@@ -2884,28 +3451,38 @@ function classifyFromLogsCore(
2884
3451
  vaultAssets: bigint
2885
3452
  zoneDepositHash: Hex.Hex
2886
3453
  }
2887
- return [
2888
- {
2889
- ...itemBase(logIndexOfEvent(privateDeposit)),
2890
- data: {
2891
- actionId: args.actionId,
2892
- assets: args.vaultAssets.toString(),
2893
- inputAmount: args.inputAmount.toString(),
2894
- inputToken: args.inputToken.toLowerCase() as Address.Address,
2895
- shares: args.earnShares.toString(),
2896
- signer,
2897
- status: 'completed',
2898
- vault: args.earnVault.toLowerCase() as Address.Address,
2899
- zoneDepositHash: args.zoneDepositHash,
2900
- },
2901
- type: 'private-assets-deposited',
2902
- },
2903
- ]
2904
- }
3454
+ const vault = lc(args.earnVault)
3455
+ // Parse before claiming so a malformed event consumes nothing but itself.
3456
+ const data = {
3457
+ actionId: args.actionId,
3458
+ assets: args.vaultAssets.toString(),
3459
+ inputAmount: args.inputAmount.toString(),
3460
+ inputToken: lc(args.inputToken),
3461
+ shares: args.earnShares.toString(),
3462
+ signer,
3463
+ status: 'completed' as const,
3464
+ vault,
3465
+ zoneDepositHash: args.zoneDepositHash,
3466
+ }
3467
+ // Fold the vault event this router call produced: the nearest preceding
3468
+ // amount match, so a separate same-vault deposit keeps its own.
3469
+ const nested = unclaimed(
3470
+ (e) =>
3471
+ e.eventName === 'Deposited' &&
3472
+ e.logIndex < event.logIndex &&
3473
+ Address.isEqual(e.address, vault) &&
3474
+ (e.args as { assets?: bigint }).assets === args.vaultAssets,
3475
+ ).at(-1)
3476
+ if (nested) claim(nested)
3477
+ else takeEvent((e) => e.eventName === 'Deposited' && Address.isEqual(e.address, vault))
3478
+ claimVaultLegs(vault, [args.inputAmount, args.vaultAssets, args.earnShares])
3479
+ // The funding leg pays the router (the emitting contract), not the vault.
3480
+ claimVaultLegs(event.address as Address.Address, [args.inputAmount])
3481
+ return { ...itemBase(event.logIndex), data, type: 'private-assets-deposited' }
3482
+ })
2905
3483
 
2906
- const privateRedeem = finalEvents.find((event) => event.eventName === 'EarnRedeem')
2907
- if (privateRedeem) {
2908
- const args = privateRedeem.args as {
3484
+ recognize(named('EarnRedeem'), (event) => {
3485
+ const args = event.args as {
2909
3486
  actionId: Hex.Hex
2910
3487
  earnShares: bigint
2911
3488
  earnVault: Address.Address
@@ -2914,782 +3491,1025 @@ function classifyFromLogsCore(
2914
3491
  vaultAssets: bigint
2915
3492
  zoneDepositHash: Hex.Hex
2916
3493
  }
2917
- return [
2918
- {
2919
- ...itemBase(logIndexOfEvent(privateRedeem)),
2920
- data: {
2921
- actionId: args.actionId,
2922
- assets: args.vaultAssets.toString(),
2923
- outputAmount: args.outputAmount.toString(),
2924
- outputToken: args.outputToken.toLowerCase() as Address.Address,
2925
- shares: args.earnShares.toString(),
2926
- signer,
2927
- status: 'completed',
2928
- vault: args.earnVault.toLowerCase() as Address.Address,
2929
- zoneDepositHash: args.zoneDepositHash,
2930
- },
2931
- type: 'private-shares-redeemed',
2932
- },
2933
- ]
2934
- }
3494
+ const vault = lc(args.earnVault)
3495
+ // Parse before claiming so a malformed event consumes nothing but itself.
3496
+ const data = {
3497
+ actionId: args.actionId,
3498
+ assets: args.vaultAssets.toString(),
3499
+ outputAmount: args.outputAmount.toString(),
3500
+ outputToken: lc(args.outputToken),
3501
+ shares: args.earnShares.toString(),
3502
+ signer,
3503
+ status: 'completed' as const,
3504
+ vault,
3505
+ zoneDepositHash: args.zoneDepositHash,
3506
+ }
3507
+ // Fold the vault event this router call produced: the nearest preceding
3508
+ // amount match, so a separate same-vault redemption keeps its own.
3509
+ const nested = unclaimed(
3510
+ (e) =>
3511
+ (e.eventName === 'Redeemed' || e.eventName === 'WithdrewExact') &&
3512
+ e.logIndex < event.logIndex &&
3513
+ Address.isEqual(e.address, vault) &&
3514
+ (e.args as { assets?: bigint }).assets === args.vaultAssets,
3515
+ ).at(-1)
3516
+ if (nested) claim(nested)
3517
+ else
3518
+ takeEvent(
3519
+ (e) =>
3520
+ (e.eventName === 'Redeemed' || e.eventName === 'WithdrewExact') &&
3521
+ Address.isEqual(e.address, vault),
3522
+ )
3523
+ claimVaultLegs(vault, [args.outputAmount, args.vaultAssets, args.earnShares])
3524
+ // The payout leg leaves through the router (the emitting contract).
3525
+ claimVaultLegs(event.address as Address.Address, [args.outputAmount])
3526
+ return { ...itemBase(event.logIndex), data, type: 'private-shares-redeemed' }
3527
+ })
2935
3528
 
2936
- const deposited = finalEvents.find((event) => event.eventName === 'Deposited')
2937
- if (deposited) {
2938
- const args = deposited.args as {
3529
+ // Earn vault operations, each claiming its own token legs.
3530
+ recognize(named('Deposited'), (event) => {
3531
+ const args = event.args as {
2939
3532
  assets: bigint
2940
3533
  caller: Address.Address
2941
3534
  earnShares: bigint
2942
3535
  receiver: Address.Address
2943
3536
  }
2944
- return [
2945
- {
2946
- ...itemBase(logIndexOfEvent(deposited)),
2947
- data: {
2948
- assets: args.assets.toString(),
2949
- caller: args.caller.toLowerCase() as Address.Address,
2950
- receiver: args.receiver.toLowerCase() as Address.Address,
2951
- shares: args.earnShares.toString(),
2952
- signer,
2953
- status: 'completed',
2954
- vault: deposited.address.toLowerCase() as Address.Address,
2955
- },
2956
- type: 'assets-deposited',
3537
+ claimVaultLegs(event.address as Address.Address, [args.assets, args.earnShares])
3538
+ return {
3539
+ ...itemBase(event.logIndex),
3540
+ data: {
3541
+ assets: args.assets.toString(),
3542
+ caller: lc(args.caller),
3543
+ receiver: lc(args.receiver),
3544
+ shares: args.earnShares.toString(),
3545
+ signer,
3546
+ status: 'completed',
3547
+ vault: lc(event.address),
2957
3548
  },
2958
- ]
2959
- }
3549
+ type: 'assets-deposited',
3550
+ }
3551
+ })
2960
3552
 
2961
- const venueDeposit = finalEvents.find((event) => event.eventName === 'VenueSharesDeposited')
2962
- if (venueDeposit) {
2963
- const args = venueDeposit.args as {
3553
+ recognize(named('VenueSharesDeposited'), (event) => {
3554
+ const args = event.args as {
2964
3555
  caller: Address.Address
2965
3556
  earnShares: bigint
2966
3557
  receivedEngineShares: bigint
2967
3558
  receiver: Address.Address
2968
3559
  requestedVenueShares: bigint
2969
3560
  }
2970
- return [
2971
- {
2972
- ...itemBase(logIndexOfEvent(venueDeposit)),
2973
- data: {
2974
- caller: args.caller.toLowerCase() as Address.Address,
2975
- receivedEngineShares: args.receivedEngineShares.toString(),
2976
- receiver: args.receiver.toLowerCase() as Address.Address,
2977
- requestedVenueShares: args.requestedVenueShares.toString(),
2978
- shares: args.earnShares.toString(),
2979
- signer,
2980
- status: 'completed',
2981
- vault: venueDeposit.address.toLowerCase() as Address.Address,
2982
- },
2983
- type: 'shares-deposited',
3561
+ claimVaultLegs(event.address as Address.Address, [
3562
+ args.earnShares,
3563
+ args.receivedEngineShares,
3564
+ args.requestedVenueShares,
3565
+ ])
3566
+ return {
3567
+ ...itemBase(event.logIndex),
3568
+ data: {
3569
+ caller: lc(args.caller),
3570
+ receivedEngineShares: args.receivedEngineShares.toString(),
3571
+ receiver: lc(args.receiver),
3572
+ requestedVenueShares: args.requestedVenueShares.toString(),
3573
+ shares: args.earnShares.toString(),
3574
+ signer,
3575
+ status: 'completed',
3576
+ vault: lc(event.address),
2984
3577
  },
2985
- ]
2986
- }
3578
+ type: 'shares-deposited',
3579
+ }
3580
+ })
2987
3581
 
2988
- const redeemed = finalEvents.find((event) => event.eventName === 'Redeemed')
2989
- if (redeemed) {
2990
- const args = redeemed.args as {
3582
+ recognize(named('Redeemed'), (event) => {
3583
+ const args = event.args as {
2991
3584
  assets: bigint
2992
3585
  caller: Address.Address
2993
3586
  earnShares: bigint
2994
3587
  receiver: Address.Address
2995
3588
  }
2996
- return [
2997
- {
2998
- ...itemBase(logIndexOfEvent(redeemed)),
2999
- data: {
3000
- assets: args.assets.toString(),
3001
- caller: args.caller.toLowerCase() as Address.Address,
3002
- receiver: args.receiver.toLowerCase() as Address.Address,
3003
- shares: args.earnShares.toString(),
3004
- signer,
3005
- status: 'completed',
3006
- vault: redeemed.address.toLowerCase() as Address.Address,
3007
- },
3008
- type: 'shares-redeemed',
3589
+ claimVaultLegs(event.address as Address.Address, [args.assets, args.earnShares])
3590
+ return {
3591
+ ...itemBase(event.logIndex),
3592
+ data: {
3593
+ assets: args.assets.toString(),
3594
+ caller: lc(args.caller),
3595
+ receiver: lc(args.receiver),
3596
+ shares: args.earnShares.toString(),
3597
+ signer,
3598
+ status: 'completed',
3599
+ vault: lc(event.address),
3009
3600
  },
3010
- ]
3011
- }
3601
+ type: 'shares-redeemed',
3602
+ }
3603
+ })
3012
3604
 
3013
- const withdrew = finalEvents.find((event) => event.eventName === 'WithdrewExact')
3014
- if (withdrew) {
3015
- const args = withdrew.args as {
3605
+ recognize(named('WithdrewExact'), (event) => {
3606
+ const args = event.args as {
3016
3607
  assets: bigint
3017
3608
  caller: Address.Address
3018
3609
  earnSharesBurned: bigint
3019
3610
  receiver: Address.Address
3020
3611
  }
3021
- return [
3022
- {
3023
- ...itemBase(logIndexOfEvent(withdrew)),
3024
- data: {
3025
- assets: args.assets.toString(),
3026
- caller: args.caller.toLowerCase() as Address.Address,
3027
- sharesBurned: args.earnSharesBurned.toString(),
3028
- receiver: args.receiver.toLowerCase() as Address.Address,
3029
- signer,
3030
- status: 'completed',
3031
- vault: withdrew.address.toLowerCase() as Address.Address,
3032
- },
3033
- type: 'assets-withdrawn',
3612
+ claimVaultLegs(event.address as Address.Address, [args.assets, args.earnSharesBurned])
3613
+ return {
3614
+ ...itemBase(event.logIndex),
3615
+ data: {
3616
+ assets: args.assets.toString(),
3617
+ caller: lc(args.caller),
3618
+ receiver: lc(args.receiver),
3619
+ sharesBurned: args.earnSharesBurned.toString(),
3620
+ signer,
3621
+ status: 'completed',
3622
+ vault: lc(event.address),
3034
3623
  },
3035
- ]
3036
- }
3624
+ type: 'assets-withdrawn',
3625
+ }
3626
+ })
3037
3627
 
3038
- const requested = finalEvents.find((event) => event.eventName === 'RedeemRequested')
3039
- if (requested) {
3040
- const args = requested.args as {
3628
+ recognize(named('RedeemRequested'), (event) => {
3629
+ const args = event.args as {
3041
3630
  earnShares: bigint
3042
3631
  receiver: Address.Address
3043
3632
  requestId: Hex.Hex
3044
3633
  requester: Address.Address
3045
3634
  }
3046
- return [
3047
- {
3048
- ...itemBase(logIndexOfEvent(requested)),
3049
- data: {
3050
- receiver: args.receiver.toLowerCase() as Address.Address,
3051
- requestId: args.requestId,
3052
- requester: args.requester.toLowerCase() as Address.Address,
3053
- shares: args.earnShares.toString(),
3054
- signer,
3055
- status: 'pending',
3056
- vault: requested.address.toLowerCase() as Address.Address,
3057
- },
3058
- type: 'shares-redemption-requested',
3635
+ claimVaultLegs(event.address as Address.Address, [args.earnShares])
3636
+ return {
3637
+ ...itemBase(event.logIndex),
3638
+ data: {
3639
+ receiver: lc(args.receiver),
3640
+ requestId: args.requestId,
3641
+ requester: lc(args.requester),
3642
+ shares: args.earnShares.toString(),
3643
+ signer,
3644
+ status: 'pending',
3645
+ vault: lc(event.address),
3059
3646
  },
3060
- ]
3061
- }
3647
+ type: 'shares-redemption-requested',
3648
+ }
3649
+ })
3062
3650
 
3063
- const finalized = finalEvents.find((event) => event.eventName === 'RedeemFinalized')
3064
- if (finalized) {
3065
- const args = finalized.args as {
3651
+ recognize(named('RedeemFinalized'), (event) => {
3652
+ const args = event.args as {
3066
3653
  asset: Address.Address
3067
3654
  assets: bigint
3068
3655
  earnShares: bigint
3069
3656
  receiver: Address.Address
3070
3657
  requestId: Hex.Hex
3071
3658
  }
3072
- return [
3073
- {
3074
- ...itemBase(logIndexOfEvent(finalized)),
3075
- data: {
3076
- asset: args.asset.toLowerCase() as Address.Address,
3077
- assets: args.assets.toString(),
3078
- receiver: args.receiver.toLowerCase() as Address.Address,
3079
- requestId: args.requestId,
3080
- shares: args.earnShares.toString(),
3081
- signer,
3082
- status: 'completed',
3083
- vault: finalized.address.toLowerCase() as Address.Address,
3084
- },
3085
- type: 'shares-redemption-finalized',
3659
+ claimVaultLegs(event.address as Address.Address, [args.assets, args.earnShares])
3660
+ return {
3661
+ ...itemBase(event.logIndex),
3662
+ data: {
3663
+ asset: lc(args.asset),
3664
+ assets: args.assets.toString(),
3665
+ receiver: lc(args.receiver),
3666
+ requestId: args.requestId,
3667
+ shares: args.earnShares.toString(),
3668
+ signer,
3669
+ status: 'completed',
3670
+ vault: lc(event.address),
3086
3671
  },
3087
- ]
3088
- }
3672
+ type: 'shares-redemption-finalized',
3673
+ }
3674
+ })
3089
3675
 
3090
- const cancelled = finalEvents.find((event) => event.eventName === 'RedeemCancelled')
3091
- if (cancelled) {
3092
- const args = cancelled.args as {
3676
+ recognize(named('RedeemCancelled'), (event) => {
3677
+ const args = event.args as {
3093
3678
  earnShares: bigint
3094
3679
  receiver: Address.Address
3095
3680
  requestId: Hex.Hex
3096
3681
  }
3097
- return [
3098
- {
3099
- ...itemBase(logIndexOfEvent(cancelled)),
3100
- data: {
3101
- receiver: args.receiver.toLowerCase() as Address.Address,
3102
- requestId: args.requestId,
3103
- shares: args.earnShares.toString(),
3104
- signer,
3105
- status: 'cancelled',
3106
- vault: cancelled.address.toLowerCase() as Address.Address,
3107
- },
3108
- type: 'shares-redemption-cancelled',
3682
+ claimVaultLegs(event.address as Address.Address, [args.earnShares])
3683
+ return {
3684
+ ...itemBase(event.logIndex),
3685
+ data: {
3686
+ receiver: lc(args.receiver),
3687
+ requestId: args.requestId,
3688
+ shares: args.earnShares.toString(),
3689
+ signer,
3690
+ status: 'cancelled',
3691
+ vault: lc(event.address),
3109
3692
  },
3110
- ]
3111
- }
3693
+ type: 'shares-redemption-cancelled',
3694
+ }
3695
+ })
3112
3696
 
3113
- // Swap: a transfer into the DEX paired with one out of the DEX.
3114
- const toDex = transfers.find((e) =>
3115
- Address.isEqual((e.args as { to: Address.Address }).to, Addresses.stablecoinDex),
3116
- )
3117
- const fromDex = transfers.find((e) =>
3118
- Address.isEqual((e.args as { from: Address.Address }).from, Addresses.stablecoinDex),
3119
- )
3120
- if (toDex && fromDex) {
3121
- const swapper = (fromDex.args as { to: Address.Address }).to
3122
- const destTokenAddress = fromDex.address as Address.Address
3123
- const sourceToken = token(
3124
- (toDex.args as { amount: bigint }).amount,
3125
- toDex.address as Address.Address,
3697
+ // External ERC-4626 vaults. Claim the share and asset legs unconditionally
3698
+ // so nested vault plumbing never resurfaces as burns or transfers, but emit
3699
+ // only operations the viewer is a party to. Fold-only recognitions are
3700
+ // buffered and surface only when nothing else classified.
3701
+ const suppressedVaultItems: RawItem[] = []
3702
+ const viewerInvolved = (parties: readonly Address.Address[], legs: readonly Decoded[]) =>
3703
+ parties.some((party) => Address.isEqual(party, viewer)) ||
3704
+ legs.some(
3705
+ (leg) =>
3706
+ isTransfer(leg) &&
3707
+ (Address.isEqual(transferArgs(leg).from, viewer) ||
3708
+ Address.isEqual(transferArgs(leg).to, viewer)),
3126
3709
  )
3710
+ recognize(named('Withdraw'), (event) => {
3711
+ const args = event.args as {
3712
+ assets: bigint
3713
+ owner: Address.Address
3714
+ receiver: Address.Address
3715
+ sender: Address.Address
3716
+ shares: bigint
3717
+ }
3718
+ const vault = event.address as Address.Address
3719
+ // Parse before claiming so a malformed event consumes nothing but itself.
3720
+ const data = {
3721
+ assets: args.assets.toString(),
3722
+ caller: lc(args.sender),
3723
+ receiver: lc(args.receiver),
3724
+ sharesBurned: args.shares.toString(),
3725
+ signer,
3726
+ status: 'completed' as const,
3727
+ vault: lc(vault),
3728
+ }
3729
+ const legs = claimVaultLegs(vault, [args.assets, args.shares])
3730
+ const item = { ...itemBase(event.logIndex), data, type: 'assets-withdrawn' as const }
3731
+ if (viewerInvolved([args.sender, args.receiver, args.owner], legs)) return item
3732
+ suppressedVaultItems.push(item)
3733
+ return undefined
3734
+ })
3127
3735
 
3128
- // Cross-token transfer: the swapped-out token is forwarded from the swapper
3129
- // to a different recipient in the same transaction (the account server's
3130
- // approve+swap+transfer multicall). Fold the swap and its delivery leg into
3131
- // one `transfer` carrying both the source token spent into the DEX and the
3132
- // destination token delivered to the recipient. A swap whose output stays
3133
- // with the swapper (no onward leg) stays a plain `swap` below.
3134
- const delivery = transfers.find(
3135
- (e) =>
3136
- Address.isEqual(e.address, destTokenAddress) &&
3137
- Address.isEqual((e.args as { from: Address.Address }).from, swapper) &&
3138
- !Address.isEqual((e.args as { to: Address.Address }).to, Addresses.stablecoinDex) &&
3139
- !Address.isEqual((e.args as { to: Address.Address }).to, swapper),
3140
- )
3141
- if (delivery) {
3142
- const args = delivery.args as {
3143
- amount: bigint
3144
- from: Address.Address
3145
- memo?: Hex.Hex | undefined
3146
- to: Address.Address
3147
- }
3148
- const rawMemo = delivery.eventName === 'TransferWithMemo' ? args.memo : undefined
3149
- const memo = rawMemo ? Transfers.memoToString(rawMemo) : undefined
3150
- const attribution = Mpp.resolve({ fingerprintMap, memo: rawMemo })
3151
- return [
3152
- transfer(itemBase(logIndexOfEvent(delivery)), blockNum, {
3153
- attribution,
3154
- destinationToken: token(args.amount, delivery.address as Address.Address),
3155
- direction: Address.isEqual(args.to, viewer) ? 'in' : 'out',
3156
- memo,
3157
- recipient: args.to.toLowerCase() as Address.Address,
3158
- sender: swapper.toLowerCase() as Address.Address,
3159
- signer,
3160
- sourceToken,
3161
- }),
3162
- ]
3736
+ recognize(named('Deposit'), (event) => {
3737
+ const args = event.args as {
3738
+ assets: bigint
3739
+ owner: Address.Address
3740
+ sender: Address.Address
3741
+ shares: bigint
3742
+ }
3743
+ const vault = event.address as Address.Address
3744
+ // Parse before claiming so a malformed event consumes nothing but itself.
3745
+ const data = {
3746
+ assets: args.assets.toString(),
3747
+ caller: lc(args.sender),
3748
+ receiver: lc(args.owner),
3749
+ shares: args.shares.toString(),
3750
+ signer,
3751
+ status: 'completed' as const,
3752
+ vault: lc(vault),
3163
3753
  }
3754
+ const legs = claimVaultLegs(vault, [args.assets, args.shares])
3755
+ const item = { ...itemBase(event.logIndex), data, type: 'assets-deposited' as const }
3756
+ if (viewerInvolved([args.sender, args.owner], legs)) return item
3757
+ suppressedVaultItems.push(item)
3758
+ return undefined
3759
+ })
3164
3760
 
3165
- return [
3166
- {
3167
- ...itemBase(Math.min(logIndexOfEvent(toDex), logIndexOfEvent(fromDex))),
3168
- data: {
3169
- destinationToken: token((fromDex.args as { amount: bigint }).amount, destTokenAddress),
3170
- signer,
3171
- sourceToken,
3172
- },
3173
- type: 'swap',
3761
+ // DEX order placed: `OrderPlaced` carries the escrow token/amount and side;
3762
+ // claim the escrow transfer that funded it.
3763
+ const orderPlacedItem = (event: Decoded): RawItem => {
3764
+ const args = event.args as {
3765
+ amount: bigint
3766
+ isBid: boolean
3767
+ orderId: bigint
3768
+ tick: number
3769
+ token: Address.Address
3770
+ }
3771
+ const escrow = (leg: Decoded) =>
3772
+ isTransfer(leg) &&
3773
+ Address.isEqual(leg.address, args.token) &&
3774
+ Address.isEqual(transferArgs(leg).to, event.address as Address.Address)
3775
+ // The escrow funds the placement, so it is the nearest preceding match; a
3776
+ // same-token earlier swap input must not be taken instead.
3777
+ const escrowLeg =
3778
+ unclaimed(
3779
+ (leg) =>
3780
+ escrow(leg) && leg.logIndex < event.logIndex && transferArgs(leg).amount === args.amount,
3781
+ ).at(-1) ?? unclaimed((leg) => escrow(leg) && leg.logIndex < event.logIndex).at(-1)
3782
+ if (escrowLeg) claim(escrowLeg)
3783
+ return {
3784
+ ...itemBase(event.logIndex),
3785
+ data: {
3786
+ orderId: args.orderId.toString(),
3787
+ side: args.isBid ? 'bid' : 'ask',
3788
+ signer,
3789
+ sourceToken: token(args.amount, args.token),
3790
+ tick: Number(args.tick),
3174
3791
  },
3175
- ]
3792
+ type: 'order-placed',
3793
+ }
3176
3794
  }
3795
+ const isFlipOrder = (event: Decoded) =>
3796
+ 'isFlipOrder' in event.args && (event.args as { isFlipOrder: boolean }).isFlipOrder === true
3797
+ // Non-flip order escrow legs claim before swap pairing so they cannot
3798
+ // mispair with an unrelated swap's output leg.
3799
+ recognize((event) => event.eventName === 'OrderPlaced' && !isFlipOrder(event), orderPlacedItem)
3800
+
3801
+ // Swap: a transfer into a route paired with a different token coming back
3802
+ // out. The DEX matches any swapper; other routes must be viewer-bounded
3803
+ // with route-emitted logs between the legs.
3804
+ {
3805
+ // Outgoing transfers indexed by lowercase sender, for O(candidates)
3806
+ // destination lookups. Claimed entries are skipped at scan time.
3807
+ const transfersByFrom = new Map<string, Decoded[]>()
3808
+ for (const event of pool) {
3809
+ if (!isTransfer(event)) continue
3810
+ const key = transferArgs(event).from.toLowerCase()
3811
+ const list = transfersByFrom.get(key)
3812
+ if (list) list.push(event)
3813
+ else transfersByFrom.set(key, [event])
3814
+ }
3815
+ // One forward pass: claims only shrink the pool, so a source that finds
3816
+ // no destination now never finds one later, and scanning each source once
3817
+ // keeps the cost linear in sources instead of restarting per pair.
3818
+ for (const source of unclaimed(isTransfer)) {
3819
+ if (claimed.has(source.logIndex)) continue
3820
+ const sourceArgs = transferArgs(source)
3821
+ const route = sourceArgs.to
3822
+ const routeKey = route.toLowerCase()
3823
+ // Tempo system addresses (the reserve, fee manager, outbox, and every
3824
+ // other non-DEX precompile) are never swap routes; new precompiles join
3825
+ // the exclusion automatically.
3826
+ if (nonRouteSystemAddresses.has(routeKey)) continue
3827
+ if (Address.isEqual(route, zeroAddress as Address.Address)) continue
3828
+ if (Address.isEqual(route, viewer)) continue
3829
+ const isDex = Address.isEqual(route, Addresses.stablecoinDex)
3830
+ if (!isDex && !Address.isEqual(sourceArgs.from, viewer)) continue
3831
+ const candidates = (transfersByFrom.get(routeKey) ?? []).filter((event) => {
3832
+ if (claimed.has(event.logIndex) || event.logIndex <= source.logIndex) return false
3833
+ if (Address.isEqual(event.address, source.address)) return false
3834
+ return !Address.isEqual(transferArgs(event).to, Addresses.feeManager)
3835
+ })
3836
+ // Prefer the output paying the swapper back (so interleaved swaps by
3837
+ // different accounts keep their own outputs); otherwise a DEX pairs
3838
+ // first-in-first-out, and a route's last leg is a direct delivery
3839
+ // (earlier different-token legs are intermediate hops).
3840
+ const destination =
3841
+ candidates.find((event) => Address.isEqual(transferArgs(event).to, sourceArgs.from)) ??
3842
+ (isDex ? candidates[0] : candidates.at(-1))
3843
+ if (!destination) continue
3844
+ // Route evidence for non-precompile routes: the route itself emitted a
3845
+ // log between the legs (raw rows, so undecoded router events count). A
3846
+ // counterparty that merely received and later paid back is not a route.
3847
+ if (!isDex) {
3848
+ const evidence = rows.some(
3849
+ (r) =>
3850
+ r.logIdx > source.logIndex &&
3851
+ r.logIdx < destination.logIndex &&
3852
+ r.logAddress.toLowerCase() === routeKey,
3853
+ )
3854
+ if (!evidence) continue
3855
+ }
3856
+ journal = []
3857
+ try {
3858
+ claim(source)
3859
+ claim(destination)
3860
+ const destinationArgs = transferArgs(destination)
3861
+ const swapper = isDex ? destinationArgs.to : sourceArgs.from
3862
+ const destTokenAddress = destination.address as Address.Address
3863
+ const sourceToken = token(sourceArgs.amount, source.address as Address.Address)
3864
+
3865
+ // Router plumbing between the legs: route-touching or zero-address
3866
+ // token moves and router-side approvals, never viewer-party transfers.
3867
+ // DEX-precompile pairs claim nothing here, so an interleaved swap by
3868
+ // another account keeps its own legs.
3869
+ if (!isDex)
3870
+ for (const event of unclaimed()) {
3871
+ if (event.logIndex <= source.logIndex || event.logIndex >= destination.logIndex)
3872
+ continue
3873
+ if (isTransfer(event)) {
3874
+ const args = transferArgs(event)
3875
+ if (Address.isEqual(args.from, viewer) || Address.isEqual(args.to, viewer)) continue
3876
+ if (
3877
+ Address.isEqual(args.from, route) ||
3878
+ Address.isEqual(args.to, route) ||
3879
+ Address.isEqual(args.from, zeroAddress as Address.Address) ||
3880
+ Address.isEqual(args.to, zeroAddress as Address.Address)
3881
+ )
3882
+ claim(event)
3883
+ } else if (
3884
+ event.eventName === 'Approval' &&
3885
+ 'owner' in event.args &&
3886
+ !Address.isEqual((event.args as { owner: Address.Address }).owner, viewer)
3887
+ )
3888
+ claim(event)
3889
+ }
3177
3890
 
3178
- // Mint: explicit `Mint` event (not the fee manager) or a transfer from zero.
3179
- const mintEvent = finalEvents.find(
3180
- (e) =>
3181
- e.eventName === 'Mint' &&
3182
- 'amount' in e.args &&
3183
- !Address.isEqual(e.address, Addresses.feeManager),
3184
- )
3185
- if (mintEvent) {
3186
- const args = mintEvent.args as { amount: bigint; to: Address.Address }
3187
- return [
3188
- {
3189
- ...itemBase(logIndexOfEvent(mintEvent)),
3190
- data: {
3191
- recipient: args.to.toLowerCase() as Address.Address,
3192
- sourceToken: token(args.amount, mintEvent.address as Address.Address),
3193
- },
3194
- type: 'mint',
3195
- },
3196
- ]
3891
+ // The allowance granted for this swap: same token, owner, and amount,
3892
+ // and causally tied to the input leg (spender is the route, or the
3893
+ // approval immediately precedes it). Unrelated approvals stay.
3894
+ unclaimed(
3895
+ (event) =>
3896
+ event.eventName === 'Approval' &&
3897
+ 'amount' in event.args &&
3898
+ 'owner' in event.args &&
3899
+ 'spender' in event.args &&
3900
+ Address.isEqual(event.address, source.address) &&
3901
+ Address.isEqual((event.args as { owner: Address.Address }).owner, sourceArgs.from) &&
3902
+ (event.args as { amount: bigint }).amount === sourceArgs.amount &&
3903
+ event.logIndex < source.logIndex &&
3904
+ (Address.isEqual((event.args as { spender: Address.Address }).spender, route) ||
3905
+ event.logIndex === source.logIndex - 1),
3906
+ )
3907
+ .slice(-1)
3908
+ .forEach(claim)
3909
+
3910
+ // DEX bookkeeping this swap produced: fills against resting orders and
3911
+ // the auto-flip re-placement, acted by the swapper or its route account.
3912
+ // A fill of the viewer's own resting order stays its own item.
3913
+ const isBookkeepingActor = (value: Address.Address) =>
3914
+ Address.isEqual(value, swapper) || Address.isEqual(value, route)
3915
+ unclaimed(
3916
+ (event) =>
3917
+ (Address.isEqual(event.address, route) ||
3918
+ Address.isEqual(event.address, Addresses.stablecoinDex)) &&
3919
+ ((event.eventName === 'OrderFilled' &&
3920
+ 'taker' in event.args &&
3921
+ isBookkeepingActor((event.args as { taker: Address.Address }).taker) &&
3922
+ !(
3923
+ 'maker' in event.args &&
3924
+ Address.isEqual((event.args as { maker: Address.Address }).maker, viewer)
3925
+ )) ||
3926
+ (event.eventName === 'OrderPlaced' &&
3927
+ isFlipOrder(event) &&
3928
+ 'maker' in event.args &&
3929
+ isBookkeepingActor((event.args as { maker: Address.Address }).maker))),
3930
+ ).forEach(claim)
3931
+
3932
+ // Cross-token transfer: the swapped-out token is forwarded whole from
3933
+ // the swapper to a new recipient after the swap (the account server's
3934
+ // approve+swap+transfer multicall), or the route delivered it to the
3935
+ // recipient directly; fold both shapes into one `transfer`. A leg
3936
+ // whose recipient later forwards a different token is the input of a
3937
+ // chained swap, not a delivery.
3938
+ const forwardsOn = (candidate: Decoded) => {
3939
+ const args = transferArgs(candidate)
3940
+ return (transfersByFrom.get(args.to.toLowerCase()) ?? []).some(
3941
+ (onward) =>
3942
+ !claimed.has(onward.logIndex) &&
3943
+ onward.logIndex > candidate.logIndex &&
3944
+ !Address.isEqual(onward.address, candidate.address),
3945
+ )
3946
+ }
3947
+ const delivery =
3948
+ !isDex && !Address.isEqual(destinationArgs.to, viewer)
3949
+ ? destination
3950
+ : takeTransfer((event) => {
3951
+ const args = transferArgs(event)
3952
+ return (
3953
+ event.logIndex > destination.logIndex &&
3954
+ Address.isEqual(event.address, destTokenAddress) &&
3955
+ Address.isEqual(args.from, swapper) &&
3956
+ args.amount === destinationArgs.amount &&
3957
+ !Address.isEqual(args.to, route) &&
3958
+ !Address.isEqual(args.to, swapper) &&
3959
+ !forwardsOn(event)
3960
+ )
3961
+ })
3962
+ // A malformed delivery memo degrades to a plain swap item; the claimed
3963
+ // legs must still produce an activity.
3964
+ const item = (() => {
3965
+ if (!delivery) return undefined
3966
+ try {
3967
+ const args = transferArgs(delivery)
3968
+ const rawMemo = delivery.eventName === 'TransferWithMemo' ? args.memo : undefined
3969
+ const memo = rawMemo ? Transfers.memoToString(rawMemo) : undefined
3970
+ const attribution = Mpp.resolve({ fingerprintMap, memo: rawMemo })
3971
+ return transfer(itemBase(delivery.logIndex), blockNum, {
3972
+ attribution,
3973
+ destinationToken: token(args.amount, delivery.address as Address.Address),
3974
+ direction: Address.isEqual(args.to, viewer) ? ('in' as const) : ('out' as const),
3975
+ memo,
3976
+ recipient: lc(args.to),
3977
+ sender: lc(swapper),
3978
+ signer,
3979
+ sourceToken,
3980
+ })
3981
+ } catch {
3982
+ return undefined
3983
+ }
3984
+ })()
3985
+ items.push(
3986
+ item ?? {
3987
+ ...itemBase(source.logIndex),
3988
+ data: {
3989
+ destinationToken: token(destinationArgs.amount, destTokenAddress),
3990
+ signer,
3991
+ sourceToken,
3992
+ },
3993
+ type: 'swap',
3994
+ },
3995
+ )
3996
+ } catch {
3997
+ // A malformed leg costs only this pair; release its claims so the
3998
+ // legs fall through to the later recognizers.
3999
+ for (const index of journal ?? []) claimed.delete(index)
4000
+ } finally {
4001
+ journal = undefined
4002
+ }
4003
+ }
3197
4004
  }
3198
- const zeroTransferIn = transfers.find((e) =>
3199
- Address.isEqual((e.args as { from: Address.Address }).from, zeroAddress as Address.Address),
4005
+
4006
+ // Mint: explicit `Mint` events (not the fee manager) and remaining transfers
4007
+ // from the zero address (the dedup above already folded event/transfer pairs).
4008
+ const mintItem = (event: Decoded, args: { amount: bigint; to: Address.Address }): RawItem => ({
4009
+ ...itemBase(event.logIndex),
4010
+ data: {
4011
+ recipient: lc(args.to),
4012
+ sourceToken: token(args.amount, event.address as Address.Address),
4013
+ },
4014
+ type: 'mint',
4015
+ })
4016
+ recognize(
4017
+ (event) =>
4018
+ event.eventName === 'Mint' &&
4019
+ 'amount' in event.args &&
4020
+ 'to' in event.args &&
4021
+ !Address.isEqual(event.address, Addresses.feeManager),
4022
+ (event) => mintItem(event, event.args as { amount: bigint; to: Address.Address }),
4023
+ )
4024
+ recognize(
4025
+ (event) =>
4026
+ isTransfer(event) &&
4027
+ Address.isEqual(transferArgs(event).from, zeroAddress as Address.Address),
4028
+ (event) => mintItem(event, transferArgs(event)),
3200
4029
  )
3201
- if (zeroTransferIn) {
3202
- const args = zeroTransferIn.args as { amount: bigint; to: Address.Address }
3203
- return [
3204
- {
3205
- ...itemBase(logIndexOfEvent(zeroTransferIn)),
3206
- data: {
3207
- recipient: (args.to as string).toLowerCase() as Address.Address,
3208
- sourceToken: token(args.amount, zeroTransferIn.address as Address.Address),
3209
- },
3210
- type: 'mint',
3211
- },
3212
- ]
3213
- }
3214
4030
 
3215
4031
  // Burn blocked: a compliance burn of receive-policy-blocked funds. It also
3216
- // emits a zero-address `Transfer` that would otherwise classify as `burn`, so
3217
- // detect the distinct `BurnBlocked` event first.
3218
- const burnBlockedEvent = finalEvents.find((e) => e.eventName === 'BurnBlocked')
3219
- if (burnBlockedEvent) {
3220
- const args = burnBlockedEvent.args as { amount: bigint; from: Address.Address }
3221
- return [
3222
- {
3223
- ...itemBase(logIndexOfEvent(burnBlockedEvent)),
3224
- data: {
3225
- sender: args.from.toLowerCase() as Address.Address,
3226
- signer,
3227
- sourceToken: token(args.amount, burnBlockedEvent.address as Address.Address),
3228
- },
3229
- type: 'burn-blocked',
4032
+ // emits a zero-address `Transfer`; claim that paired leg so it cannot
4033
+ // classify as a plain `burn`.
4034
+ recognize(named('BurnBlocked'), (event) => {
4035
+ const args = event.args as { amount: bigint; from: Address.Address }
4036
+ takeTransfer(
4037
+ (leg) =>
4038
+ Address.isEqual(leg.address, event.address) &&
4039
+ Address.isEqual(transferArgs(leg).to, zeroAddress as Address.Address) &&
4040
+ transferArgs(leg).amount === args.amount,
4041
+ )
4042
+ return {
4043
+ ...itemBase(event.logIndex),
4044
+ data: {
4045
+ sender: lc(args.from),
4046
+ signer,
4047
+ sourceToken: token(args.amount, event.address as Address.Address),
3230
4048
  },
3231
- ]
3232
- }
4049
+ type: 'burn-blocked',
4050
+ }
4051
+ })
3233
4052
 
3234
- // Burn: explicit `Burn` event or a transfer to zero.
3235
- const burnEvent = finalEvents.find((e) => e.eventName === 'Burn' && 'amount' in e.args)
3236
- if (burnEvent) {
3237
- const args = burnEvent.args as { amount: bigint; from: Address.Address }
3238
- return [
3239
- {
3240
- ...itemBase(logIndexOfEvent(burnEvent)),
3241
- data: {
3242
- sender: args.from.toLowerCase() as Address.Address,
3243
- signer,
3244
- sourceToken: token(args.amount, burnEvent.address as Address.Address),
3245
- },
3246
- type: 'burn',
3247
- },
3248
- ]
3249
- }
3250
- const zeroTransferOut = transfers.find((e) =>
3251
- Address.isEqual((e.args as { to: Address.Address }).to, zeroAddress as Address.Address),
4053
+ // Burn: explicit `Burn` events and remaining transfers to the zero address.
4054
+ const burnItem = (event: Decoded, args: { amount: bigint; from: Address.Address }): RawItem => ({
4055
+ ...itemBase(event.logIndex),
4056
+ data: {
4057
+ sender: lc(args.from),
4058
+ signer,
4059
+ sourceToken: token(args.amount, event.address as Address.Address),
4060
+ },
4061
+ type: 'burn',
4062
+ })
4063
+ recognize(
4064
+ (event) => event.eventName === 'Burn' && 'amount' in event.args && 'from' in event.args,
4065
+ (event) => burnItem(event, event.args as { amount: bigint; from: Address.Address }),
4066
+ )
4067
+ recognize(
4068
+ (event) =>
4069
+ isTransfer(event) && Address.isEqual(transferArgs(event).to, zeroAddress as Address.Address),
4070
+ (event) => burnItem(event, transferArgs(event)),
3252
4071
  )
3253
- if (zeroTransferOut) {
3254
- const args = zeroTransferOut.args as { amount: bigint; from: Address.Address }
3255
- return [
3256
- {
3257
- ...itemBase(logIndexOfEvent(zeroTransferOut)),
3258
- data: {
3259
- sender: (args.from as string).toLowerCase() as Address.Address,
3260
- signer,
3261
- sourceToken: token(args.amount, zeroTransferOut.address as Address.Address),
3262
- },
3263
- type: 'burn',
3264
- },
3265
- ]
3266
- }
3267
4072
 
3268
- // Approval (excluding zone-outbox spenders).
3269
- const approvalEvent = finalEvents.find(
3270
- (e) =>
3271
- e.eventName === 'Approval' &&
3272
- // ERC-721 uses the same selector but indexes its token ID as topic3. In
3273
- // non-strict mode viem otherwise decodes the missing ERC-20 amount as zero.
4073
+ // Approval (excluding zone-outbox spenders): only viewer-party approvals
4074
+ // surface here; the rest stay for the nothing-else-classified fallback.
4075
+ const isErc20Approval = (event: Decoded) => {
4076
+ if (event.eventName !== 'Approval') return false
4077
+ const args = event.args as { spender?: unknown }
4078
+ if (typeof args.spender !== 'string' || !Address.validate(args.spender)) return false
4079
+ // ERC-721 uses the same selector but indexes its token ID as topic3. In
4080
+ // non-strict mode viem otherwise decodes the missing ERC-20 amount as zero.
4081
+ return (
3274
4082
  rows.some(
3275
4083
  (row) =>
3276
- row.logIdx === e.logIndex &&
4084
+ row.logIdx === event.logIndex &&
3277
4085
  row.topic3 === null &&
3278
4086
  /^0x[0-9a-fA-F]{64}$/.test(row.data ?? ''),
3279
- ) &&
3280
- !Address.isEqual((e.args as { spender: Address.Address }).spender, Addresses.zoneOutbox),
3281
- )
3282
- if (approvalEvent) {
3283
- const args = approvalEvent.args as { amount: bigint; spender: Address.Address }
3284
- return [
3285
- {
3286
- ...itemBase(logIndexOfEvent(approvalEvent)),
3287
- data: {
3288
- signer,
3289
- sourceToken: token(args.amount, approvalEvent.address as Address.Address),
3290
- spender: args.spender.toLowerCase() as Address.Address,
3291
- },
3292
- type: 'approval',
4087
+ ) && !Address.isEqual(args.spender as Address.Address, Addresses.zoneOutbox)
4088
+ )
4089
+ }
4090
+ const approvalItem = (event: Decoded): RawItem => {
4091
+ const args = event.args as { amount: bigint; spender: Address.Address }
4092
+ return {
4093
+ ...itemBase(event.logIndex),
4094
+ data: {
4095
+ signer,
4096
+ sourceToken: token(args.amount, event.address as Address.Address),
4097
+ spender: lc(args.spender),
3293
4098
  },
3294
- ]
4099
+ type: 'approval',
4100
+ }
3295
4101
  }
4102
+ recognize(
4103
+ (event) =>
4104
+ isErc20Approval(event) &&
4105
+ 'owner' in event.args &&
4106
+ (Address.isEqual((event.args as { owner: Address.Address }).owner, viewer) ||
4107
+ Address.isEqual((event.args as { spender: Address.Address }).spender, viewer)),
4108
+ approvalItem,
4109
+ )
3296
4110
 
3297
- // Precompile-event activities. These run before the generic transfer
3298
- // fallback because each is emitted alongside a co-located TIP-20 `Transfer`
3299
- // whose counterparty is the precompile (the DEX, the channel reserve, the fee
3300
- // manager, the token itself), which would otherwise classify as a plain
3301
- // transfer. They are grouped by (transaction + precompile counterparty), not
3302
- // by log adjacency.
3303
-
3304
- // Payment channel opened: `ChannelOpened` carries the deposit token/amount
3305
- // directly, so no paired-transfer lookup is needed.
3306
- const channelOpened = finalEvents.find((e) => e.eventName === 'ChannelOpened')
3307
- if (channelOpened) {
3308
- const args = channelOpened.args as {
4111
+ // Payment channels. The reserve doesn't put the token on `TopUp`/`Settled`/
4112
+ // `ChannelClosed`, so each anchor claims its own reserve leg to recover it;
4113
+ // batched channel operations pair with their own legs one at a time.
4114
+ recognize(named('ChannelOpened'), (event) => {
4115
+ const args = event.args as {
3309
4116
  channelId: Hex.Hex
3310
4117
  deposit: bigint
3311
4118
  payee: Address.Address
3312
4119
  payer: Address.Address
3313
4120
  token: Address.Address
3314
4121
  }
3315
- return [
3316
- {
3317
- ...itemBase(logIndexOfEvent(channelOpened)),
3318
- data: {
3319
- channelId: args.channelId.toLowerCase() as Hex.Hex,
3320
- payee: args.payee.toLowerCase() as Address.Address,
3321
- payer: args.payer.toLowerCase() as Address.Address,
3322
- signer,
3323
- sourceToken: token(args.deposit, args.token),
3324
- },
3325
- type: 'channel-opened',
4122
+ takeTransfer(
4123
+ (leg) =>
4124
+ Address.isEqual(leg.address, args.token) &&
4125
+ Address.isEqual(transferArgs(leg).to, tip20ChannelReserve) &&
4126
+ transferArgs(leg).amount === args.deposit,
4127
+ )
4128
+ return {
4129
+ ...itemBase(event.logIndex),
4130
+ data: {
4131
+ channelId: hx(args.channelId),
4132
+ payee: lc(args.payee),
4133
+ payer: lc(args.payer),
4134
+ signer,
4135
+ sourceToken: token(args.deposit, args.token),
3326
4136
  },
3327
- ]
3328
- }
4137
+ type: 'channel-opened',
4138
+ }
4139
+ })
3329
4140
 
3330
- // The channel reserve doesn't put the token on `TopUp`/`Settled`/
3331
- // `ChannelClosed`, so recover it from the co-located funding/payout transfer.
3332
- const reserveInflow = transfers.find((e) =>
3333
- Address.isEqual((e.args as { to: Address.Address }).to, tip20ChannelReserve),
3334
- )
3335
- const reserveOutflowTo = (recipient: Address.Address) =>
3336
- transfers.find(
4141
+ recognize(named('TopUp'), (event) => {
4142
+ const args = event.args as { additionalDeposit: bigint; channelId: Hex.Hex }
4143
+ const inflow =
4144
+ takeTransfer(
4145
+ (leg) =>
4146
+ Address.isEqual(transferArgs(leg).to, tip20ChannelReserve) &&
4147
+ transferArgs(leg).amount === args.additionalDeposit,
4148
+ ) ?? takeTransfer((leg) => Address.isEqual(transferArgs(leg).to, tip20ChannelReserve))
4149
+ // A top-up cancels any pending close request on the channel; fold the
4150
+ // cancellation event it bundles.
4151
+ unclaimed(
3337
4152
  (e) =>
3338
- Address.isEqual((e.args as { from: Address.Address }).from, tip20ChannelReserve) &&
3339
- Address.isEqual((e.args as { to: Address.Address }).to, recipient),
4153
+ e.eventName === 'CloseRequestCancelled' &&
4154
+ 'channelId' in e.args &&
4155
+ (e.args as { channelId: Hex.Hex }).channelId === args.channelId,
3340
4156
  )
3341
-
3342
- // Payment channel topped up: `additionalDeposit` from the event, token from
3343
- // the inbound funding transfer to the reserve.
3344
- const topUp = finalEvents.find((e) => e.eventName === 'TopUp')
3345
- if (topUp) {
3346
- const args = topUp.args as { additionalDeposit: bigint; channelId: Hex.Hex }
3347
- const tokenAddress = reserveInflow
3348
- ? (reserveInflow.address as Address.Address)
4157
+ .slice(0, 1)
4158
+ .forEach(claim)
4159
+ const tokenAddress = inflow
4160
+ ? (inflow.address as Address.Address)
3349
4161
  : (zeroAddress as Address.Address)
3350
- return [
3351
- {
3352
- ...itemBase(logIndexOfEvent(topUp)),
3353
- data: {
3354
- channelId: args.channelId.toLowerCase() as Hex.Hex,
3355
- signer,
3356
- sourceToken: token(args.additionalDeposit, tokenAddress),
3357
- },
3358
- type: 'channel-funded',
4162
+ return {
4163
+ ...itemBase(event.logIndex),
4164
+ data: {
4165
+ channelId: hx(args.channelId),
4166
+ signer,
4167
+ sourceToken: token(args.additionalDeposit, tokenAddress),
3359
4168
  },
3360
- ]
3361
- }
4169
+ type: 'channel-funded',
4170
+ }
4171
+ })
3362
4172
 
3363
- // Payment channel settled: `deltaPaid` from the event, token from the payout
3364
- // transfer out of the reserve to the payee.
3365
- const settled = finalEvents.find((e) => e.eventName === 'Settled')
3366
- if (settled) {
3367
- const args = settled.args as {
4173
+ recognize(named('Settled'), (event) => {
4174
+ const args = event.args as {
3368
4175
  channelId: Hex.Hex
3369
4176
  deltaPaid: bigint
3370
4177
  payee: Address.Address
3371
4178
  payer: Address.Address
3372
4179
  }
3373
- const payeeLeg = reserveOutflowTo(args.payee.toLowerCase() as Address.Address)
4180
+ const payout = (leg: Decoded) =>
4181
+ Address.isEqual(transferArgs(leg).from, tip20ChannelReserve) &&
4182
+ Address.isEqual(transferArgs(leg).to, args.payee)
4183
+ // Prefer the amount-matched payout so batched channel operations sharing
4184
+ // a payee keep their own legs.
4185
+ const payeeLeg =
4186
+ takeTransfer((leg) => payout(leg) && transferArgs(leg).amount === args.deltaPaid) ??
4187
+ takeTransfer(payout)
3374
4188
  const tokenAddress = payeeLeg
3375
4189
  ? (payeeLeg.address as Address.Address)
3376
4190
  : (zeroAddress as Address.Address)
3377
- return [
3378
- {
3379
- ...itemBase(logIndexOfEvent(settled)),
3380
- data: {
3381
- channelId: args.channelId.toLowerCase() as Hex.Hex,
3382
- payee: args.payee.toLowerCase() as Address.Address,
3383
- payer: args.payer.toLowerCase() as Address.Address,
3384
- signer,
3385
- sourceToken: token(args.deltaPaid, tokenAddress),
3386
- },
3387
- type: 'channel-settled',
4191
+ return {
4192
+ ...itemBase(event.logIndex),
4193
+ data: {
4194
+ channelId: hx(args.channelId),
4195
+ payee: lc(args.payee),
4196
+ payer: lc(args.payer),
4197
+ signer,
4198
+ sourceToken: token(args.deltaPaid, tokenAddress),
3388
4199
  },
3389
- ]
3390
- }
4200
+ type: 'channel-settled',
4201
+ }
4202
+ })
3391
4203
 
3392
- // Payment channel closed: payout to the payee and refund to the payer arrive
3393
- // as up to two transfers out of the reserve; attach whichever are present.
3394
- const channelClosed = finalEvents.find((e) => e.eventName === 'ChannelClosed')
3395
- if (channelClosed) {
3396
- const args = channelClosed.args as {
4204
+ recognize(named('ChannelClosed'), (event) => {
4205
+ const args = event.args as {
3397
4206
  channelId: Hex.Hex
3398
4207
  payee: Address.Address
3399
4208
  payer: Address.Address
3400
4209
  refundedToPayer: bigint
3401
4210
  settledToPayee: bigint
3402
4211
  }
3403
- const payeeLeg = reserveOutflowTo(args.payee.toLowerCase() as Address.Address)
3404
- const payerLeg = reserveOutflowTo(args.payer.toLowerCase() as Address.Address)
3405
- return [
3406
- {
3407
- ...itemBase(logIndexOfEvent(channelClosed)),
3408
- data: {
3409
- channelId: args.channelId.toLowerCase() as Hex.Hex,
3410
- payee: args.payee.toLowerCase() as Address.Address,
3411
- payer: args.payer.toLowerCase() as Address.Address,
3412
- ...(payerLeg && args.refundedToPayer > 0n
3413
- ? { refund: token(args.refundedToPayer, payerLeg.address as Address.Address) }
3414
- : {}),
3415
- signer,
3416
- ...(payeeLeg
3417
- ? { sourceToken: token(args.settledToPayee, payeeLeg.address as Address.Address) }
3418
- : {}),
3419
- },
3420
- type: 'channel-closed',
4212
+ // A zero settlement or refund has no leg; claiming one would steal it
4213
+ // from a batched sibling operation.
4214
+ const reserveLegTo = (to: Address.Address, amount: bigint) => {
4215
+ if (amount === 0n) return undefined
4216
+ const payout = (leg: Decoded) =>
4217
+ Address.isEqual(transferArgs(leg).from, tip20ChannelReserve) &&
4218
+ Address.isEqual(transferArgs(leg).to, to)
4219
+ return (
4220
+ takeTransfer((leg) => payout(leg) && transferArgs(leg).amount === amount) ??
4221
+ takeTransfer(payout)
4222
+ )
4223
+ }
4224
+ const payeeLeg = reserveLegTo(args.payee, args.settledToPayee)
4225
+ const payerLeg = reserveLegTo(args.payer, args.refundedToPayer)
4226
+ return {
4227
+ ...itemBase(event.logIndex),
4228
+ data: {
4229
+ channelId: hx(args.channelId),
4230
+ payee: lc(args.payee),
4231
+ payer: lc(args.payer),
4232
+ ...(payerLeg && args.refundedToPayer > 0n
4233
+ ? { refund: token(args.refundedToPayer, payerLeg.address as Address.Address) }
4234
+ : {}),
4235
+ signer,
4236
+ ...(payeeLeg
4237
+ ? { sourceToken: token(args.settledToPayee, payeeLeg.address as Address.Address) }
4238
+ : {}),
3421
4239
  },
3422
- ]
3423
- }
4240
+ type: 'channel-closed',
4241
+ }
4242
+ })
3424
4243
 
3425
- // Payment channel close cancelled: the payer revoked a pending close request.
3426
- // When bundled with a top-up the `TopUp` classifier above wins; on its own
3427
- // this carries no transfer, so the event itself is the signal.
3428
- const closeCancelled = finalEvents.find((e) => e.eventName === 'CloseRequestCancelled')
3429
- if (closeCancelled) {
3430
- const args = closeCancelled.args as {
4244
+ // Payment channel close cancelled: carries no transfer, so the event itself
4245
+ // is the signal. Cancellations bundled into a top-up were claimed above.
4246
+ recognize(named('CloseRequestCancelled'), (event) => {
4247
+ const args = event.args as {
3431
4248
  channelId: Hex.Hex
3432
4249
  payee: Address.Address
3433
4250
  payer: Address.Address
3434
4251
  }
3435
- return [
3436
- {
3437
- ...itemBase(logIndexOfEvent(closeCancelled)),
3438
- data: {
3439
- channelId: args.channelId.toLowerCase() as Hex.Hex,
3440
- payee: args.payee.toLowerCase() as Address.Address,
3441
- payer: args.payer.toLowerCase() as Address.Address,
3442
- signer,
3443
- },
3444
- type: 'channel-close-cancelled',
4252
+ return {
4253
+ ...itemBase(event.logIndex),
4254
+ data: {
4255
+ channelId: hx(args.channelId),
4256
+ payee: lc(args.payee),
4257
+ payer: lc(args.payer),
4258
+ signer,
3445
4259
  },
3446
- ]
3447
- }
3448
-
3449
- // DEX order placed: `OrderPlaced` carries the escrow token/amount and side.
3450
- // (Auto-flip re-places are inside swaps, which already returned above.)
3451
- const orderPlaced = finalEvents.find((e) => e.eventName === 'OrderPlaced')
3452
- if (orderPlaced) {
3453
- const args = orderPlaced.args as {
3454
- amount: bigint
3455
- isBid: boolean
3456
- orderId: bigint
3457
- tick: number
3458
- token: Address.Address
4260
+ type: 'channel-close-cancelled',
3459
4261
  }
3460
- return [
3461
- {
3462
- ...itemBase(logIndexOfEvent(orderPlaced)),
3463
- data: {
3464
- orderId: args.orderId.toString(),
3465
- side: args.isBid ? 'bid' : 'ask',
3466
- signer,
3467
- sourceToken: token(args.amount, args.token),
3468
- tick: Number(args.tick),
3469
- },
3470
- type: 'order-placed',
3471
- },
3472
- ]
4262
+ })
4263
+
4264
+ // Payment-channel close requested: matched on the raw selector (the event
4265
+ // is not in the merged ABI). Runs after the channel recognizers claimed
4266
+ // their own reserve legs, then takes the leftover refund back to the viewer.
4267
+ for (const row of rows.filter((r) => r.selector === closeRequestedSelector)) {
4268
+ const refundLeg = takeTransfer((event) => {
4269
+ const args = transferArgs(event)
4270
+ if (!Address.isEqual(args.to, viewer)) return false
4271
+ return (
4272
+ Address.isEqual(args.from, tip20ChannelReserve) ||
4273
+ Address.isEqual(args.from, row.logAddress)
4274
+ )
4275
+ })
4276
+ const refund = refundLeg
4277
+ ? token(transferArgs(refundLeg).amount, refundLeg.address as Address.Address)
4278
+ : undefined
4279
+ items.push({
4280
+ ...itemBase(row.logIdx),
4281
+ data: { ...(refund ? { refund } : {}), signer },
4282
+ type: 'session-closed',
4283
+ })
3473
4284
  }
3474
4285
 
4286
+ // Auto-flip order placements not folded into a swap above.
4287
+ recognize(named('OrderPlaced'), orderPlacedItem)
4288
+
3475
4289
  // DEX order cancelled: refund is credited to the internal DEX balance, so
3476
- // there is no co-located transfer the `OrderCancelled` event is the signal.
3477
- const orderCancelled = finalEvents.find((e) => e.eventName === 'OrderCancelled')
3478
- if (orderCancelled) {
3479
- const args = orderCancelled.args as { orderId: bigint }
3480
- return [
3481
- {
3482
- ...itemBase(logIndexOfEvent(orderCancelled)),
3483
- data: { orderId: args.orderId.toString(), signer },
3484
- type: 'order-cancelled',
3485
- },
3486
- ]
3487
- }
4290
+ // there is no co-located transfer; the event itself is the signal.
4291
+ recognize(named('OrderCancelled'), (event) => {
4292
+ const args = event.args as { orderId: bigint }
4293
+ return {
4294
+ ...itemBase(event.logIndex),
4295
+ data: { orderId: args.orderId.toString(), signer },
4296
+ type: 'order-cancelled',
4297
+ }
4298
+ })
3488
4299
 
3489
- // Fee-AMM rebalance swap: `validatorToken` in, `userToken` out.
3490
- const rebalanceSwap = finalEvents.find((e) => e.eventName === 'RebalanceSwap')
3491
- if (rebalanceSwap) {
3492
- const args = rebalanceSwap.args as {
4300
+ // Fee-AMM rebalance swap: `validatorToken` in, `userToken` out; claim both
4301
+ // fee-manager legs so neither resurfaces as a plain transfer.
4302
+ recognize(named('RebalanceSwap'), (event) => {
4303
+ const args = event.args as {
3493
4304
  amountIn: bigint
3494
4305
  amountOut: bigint
3495
4306
  swapper: Address.Address
3496
4307
  userToken: Address.Address
3497
4308
  validatorToken: Address.Address
3498
4309
  }
3499
- return [
3500
- {
3501
- ...itemBase(logIndexOfEvent(rebalanceSwap)),
3502
- data: {
3503
- destinationToken: token(args.amountOut, args.userToken),
3504
- signer,
3505
- sourceToken: token(args.amountIn, args.validatorToken),
3506
- swapper: args.swapper.toLowerCase() as Address.Address,
3507
- },
3508
- type: 'fee-rebalance-swap',
4310
+ takeTransfer(
4311
+ (leg) =>
4312
+ Address.isEqual(leg.address, args.validatorToken) &&
4313
+ Address.isEqual(transferArgs(leg).from, args.swapper) &&
4314
+ Address.isEqual(transferArgs(leg).to, Addresses.feeManager) &&
4315
+ transferArgs(leg).amount === args.amountIn,
4316
+ )
4317
+ takeTransfer(
4318
+ (leg) =>
4319
+ Address.isEqual(leg.address, args.userToken) &&
4320
+ Address.isEqual(transferArgs(leg).from, Addresses.feeManager) &&
4321
+ Address.isEqual(transferArgs(leg).to, args.swapper) &&
4322
+ transferArgs(leg).amount === args.amountOut,
4323
+ )
4324
+ return {
4325
+ ...itemBase(event.logIndex),
4326
+ data: {
4327
+ destinationToken: token(args.amountOut, args.userToken),
4328
+ signer,
4329
+ sourceToken: token(args.amountIn, args.validatorToken),
4330
+ swapper: lc(args.swapper),
3509
4331
  },
3510
- ]
3511
- }
4332
+ type: 'fee-rebalance-swap',
4333
+ }
4334
+ })
3512
4335
 
3513
- // Validator fees distributed.
3514
- const feesDistributed = finalEvents.find((e) => e.eventName === 'FeesDistributed')
3515
- if (feesDistributed) {
3516
- const args = feesDistributed.args as {
4336
+ // Validator fees distributed; claim the fee-manager payout leg.
4337
+ recognize(named('FeesDistributed'), (event) => {
4338
+ const args = event.args as {
3517
4339
  amount: bigint
3518
4340
  token: Address.Address
3519
4341
  validator: Address.Address
3520
4342
  }
3521
- return [
3522
- {
3523
- ...itemBase(logIndexOfEvent(feesDistributed)),
3524
- data: {
3525
- signer,
3526
- sourceToken: token(args.amount, args.token),
3527
- validator: args.validator.toLowerCase() as Address.Address,
3528
- },
3529
- type: 'fees-distributed',
4343
+ takeTransfer(
4344
+ (leg) =>
4345
+ Address.isEqual(leg.address, args.token) &&
4346
+ Address.isEqual(transferArgs(leg).from, Addresses.feeManager) &&
4347
+ Address.isEqual(transferArgs(leg).to, args.validator) &&
4348
+ transferArgs(leg).amount === args.amount,
4349
+ )
4350
+ return {
4351
+ ...itemBase(event.logIndex),
4352
+ data: {
4353
+ signer,
4354
+ sourceToken: token(args.amount, args.token),
4355
+ validator: lc(args.validator),
3530
4356
  },
3531
- ]
3532
- }
4357
+ type: 'fees-distributed',
4358
+ }
4359
+ })
3533
4360
 
3534
- // Reward pool funded: the funded token is the emitting token contract itself.
3535
- const rewardDistributed = finalEvents.find((e) => e.eventName === 'RewardDistributed')
3536
- if (rewardDistributed) {
3537
- const args = rewardDistributed.args as { amount: bigint; funder: Address.Address }
3538
- return [
3539
- {
3540
- ...itemBase(logIndexOfEvent(rewardDistributed)),
3541
- data: {
3542
- funder: args.funder.toLowerCase() as Address.Address,
3543
- signer,
3544
- sourceToken: token(args.amount, rewardDistributed.address as Address.Address),
3545
- },
3546
- type: 'reward-distributed',
4361
+ // Reward pool funded: the funded token is the emitting contract itself;
4362
+ // claim the funding transfer.
4363
+ recognize(named('RewardDistributed'), (event) => {
4364
+ const args = event.args as { amount: bigint; funder: Address.Address }
4365
+ takeTransfer(
4366
+ (leg) =>
4367
+ Address.isEqual(leg.address, event.address as Address.Address) &&
4368
+ Address.isEqual(transferArgs(leg).from, args.funder) &&
4369
+ Address.isEqual(transferArgs(leg).to, event.address as Address.Address) &&
4370
+ transferArgs(leg).amount === args.amount,
4371
+ )
4372
+ return {
4373
+ ...itemBase(event.logIndex),
4374
+ data: {
4375
+ funder: lc(args.funder),
4376
+ signer,
4377
+ sourceToken: token(args.amount, event.address as Address.Address),
3547
4378
  },
3548
- ]
3549
- }
4379
+ type: 'reward-distributed',
4380
+ }
4381
+ })
3550
4382
 
3551
4383
  // Reward recipient redirected.
3552
- const rewardRecipientSet = finalEvents.find((e) => e.eventName === 'RewardRecipientSet')
3553
- if (rewardRecipientSet) {
3554
- const args = rewardRecipientSet.args as { holder: Address.Address; recipient: Address.Address }
3555
- return [
3556
- {
3557
- ...itemBase(logIndexOfEvent(rewardRecipientSet)),
3558
- data: {
3559
- holder: args.holder.toLowerCase() as Address.Address,
3560
- recipient: args.recipient.toLowerCase() as Address.Address,
3561
- signer,
3562
- },
3563
- type: 'reward-recipient-set',
4384
+ recognize(named('RewardRecipientSet'), (event) => {
4385
+ const args = event.args as { holder: Address.Address; recipient: Address.Address }
4386
+ return {
4387
+ ...itemBase(event.logIndex),
4388
+ data: {
4389
+ holder: lc(args.holder),
4390
+ recipient: lc(args.recipient),
4391
+ signer,
3564
4392
  },
3565
- ]
3566
- }
4393
+ type: 'reward-recipient-set',
4394
+ }
4395
+ })
3567
4396
 
3568
4397
  // Access-key spending limit updated: `newLimit` is carried as the token
3569
4398
  // amount so it enriches with the token's metadata.
3570
- const spendingLimitUpdated = finalEvents.find((e) => e.eventName === 'SpendingLimitUpdated')
3571
- if (spendingLimitUpdated) {
3572
- const args = spendingLimitUpdated.args as {
4399
+ recognize(named('SpendingLimitUpdated'), (event) => {
4400
+ const args = event.args as {
3573
4401
  account: Address.Address
3574
4402
  newLimit: bigint
3575
4403
  publicKey: Address.Address
3576
4404
  token: Address.Address
3577
4405
  }
3578
- return [
3579
- {
3580
- ...itemBase(logIndexOfEvent(spendingLimitUpdated)),
3581
- data: {
3582
- account: args.account.toLowerCase() as Address.Address,
3583
- publicKey: args.publicKey.toLowerCase() as Address.Address,
3584
- signer,
3585
- sourceToken: token(args.newLimit, args.token),
3586
- },
3587
- type: 'spending-limit-updated',
4406
+ return {
4407
+ ...itemBase(event.logIndex),
4408
+ data: {
4409
+ account: lc(args.account),
4410
+ publicKey: lc(args.publicKey),
4411
+ signer,
4412
+ sourceToken: token(args.newLimit, args.token),
3588
4413
  },
3589
- ]
3590
- }
4414
+ type: 'spending-limit-updated',
4415
+ }
4416
+ })
3591
4417
 
3592
- // Access-key lifecycle events — emitted alongside any co-located transfer.
3593
- const accessKeyItems: RawItem[] = []
3594
- for (const event of finalEvents) {
3595
- if (
4418
+ // Access-key lifecycle events.
4419
+ recognize(
4420
+ (event) =>
3596
4421
  event.eventName === 'KeyAuthorized' &&
3597
- Address.isEqual(event.address, Addresses.accountKeychain)
3598
- ) {
4422
+ Address.isEqual(event.address, Addresses.accountKeychain),
4423
+ (event) => {
3599
4424
  const args = event.args as {
3600
4425
  account: Address.Address
3601
4426
  expiry: bigint
3602
4427
  publicKey: Address.Address
3603
4428
  signatureType: number
3604
4429
  }
3605
- accessKeyItems.push({
3606
- ...itemBase(logIndexOfEvent(event)),
4430
+ return {
4431
+ ...itemBase(event.logIndex),
3607
4432
  data: {
3608
- account: args.account.toLowerCase() as Address.Address,
4433
+ account: lc(args.account),
3609
4434
  expiry: Number(args.expiry),
3610
- publicKey: args.publicKey.toLowerCase() as Address.Address,
4435
+ publicKey: lc(args.publicKey),
3611
4436
  signatureType: Number(args.signatureType),
3612
4437
  },
3613
4438
  type: 'access-key-created',
3614
- })
3615
- } else if (
3616
- event.eventName === 'KeyRevoked' &&
3617
- Address.isEqual(event.address, Addresses.accountKeychain)
3618
- ) {
4439
+ }
4440
+ },
4441
+ )
4442
+ recognize(
4443
+ (event) =>
4444
+ event.eventName === 'KeyRevoked' && Address.isEqual(event.address, Addresses.accountKeychain),
4445
+ (event) => {
3619
4446
  const args = event.args as { account: Address.Address; publicKey: Address.Address }
3620
- accessKeyItems.push({
3621
- ...itemBase(logIndexOfEvent(event)),
4447
+ return {
4448
+ ...itemBase(event.logIndex),
3622
4449
  data: {
3623
- account: args.account.toLowerCase() as Address.Address,
3624
- publicKey: args.publicKey.toLowerCase() as Address.Address,
4450
+ account: lc(args.account),
4451
+ publicKey: lc(args.publicKey),
3625
4452
  },
3626
4453
  type: 'access-key-revoked',
3627
- })
3628
- }
3629
- }
3630
-
3631
- // Token creation.
3632
- const tokenCreated = finalEvents.find(
3633
- (e) => e.eventName === 'TokenCreated' && Address.isEqual(e.address, Addresses.tip20Factory),
4454
+ }
4455
+ },
3634
4456
  )
3635
- if (tokenCreated) {
3636
- const args = tokenCreated.args as {
3637
- currency: string
3638
- name: string
3639
- symbol: string
3640
- token: Address.Address
3641
- }
3642
- return [
3643
- {
3644
- ...itemBase(logIndexOfEvent(tokenCreated)),
4457
+
4458
+ // Token creation. Factory initialization (role grants, config) precedes the
4459
+ // creation event; claim those, leaving later admin actions on the token as
4460
+ // independent activities.
4461
+ recognize(
4462
+ (event) =>
4463
+ event.eventName === 'TokenCreated' && Address.isEqual(event.address, Addresses.tip20Factory),
4464
+ (event) => {
4465
+ const args = event.args as {
4466
+ currency: string
4467
+ name: string
4468
+ symbol: string
4469
+ token: Address.Address
4470
+ }
4471
+ unclaimed(
4472
+ (e) => e.logIndex < event.logIndex && Address.isEqual(e.address, args.token),
4473
+ ).forEach(claim)
4474
+ return {
4475
+ ...itemBase(event.logIndex),
3645
4476
  data: {
3646
- address: args.token.toLowerCase() as Address.Address,
4477
+ address: lc(args.token),
3647
4478
  currency: args.currency,
3648
4479
  name: args.name,
3649
4480
  symbol: args.symbol,
3650
4481
  },
3651
4482
  type: 'token-created',
3652
- },
3653
- ...accessKeyItems,
3654
- ]
3655
- }
4483
+ }
4484
+ },
4485
+ )
3656
4486
 
3657
4487
  // Governance / admin / DEX-bookkeeping events. Each maps a single decoded
3658
- // event to a typed item. Checked after token creation (a token-creation tx
3659
- // also emits `RoleMembershipUpdated`) and before the plain-transfer fallback;
3660
- // these carry no token movement, so the event itself is the whole signal.
3661
- const lc = (value: Address.Address) => value.toLowerCase() as Address.Address
3662
- const hx = (value: Hex.Hex) => value.toLowerCase() as Hex.Hex
3663
- const findEvent = (name: string) => finalEvents.find((e) => e.eventName === name)
3664
-
3665
- // DEX bookkeeping (taker swaps and `OrderPlaced` already returned above).
3666
- const orderFilled = findEvent('OrderFilled')
3667
- if (orderFilled) {
3668
- const a = orderFilled.args as {
4488
+ // event to a typed item; every remaining occurrence emits. These carry no
4489
+ // token movement, so the event itself is the whole signal.
4490
+ recognize(named('OrderFilled'), (event) => {
4491
+ const a = event.args as {
3669
4492
  amountFilled: bigint
3670
4493
  maker: Address.Address
3671
4494
  orderId: bigint
3672
4495
  partialFill: boolean
3673
4496
  taker: Address.Address
3674
4497
  }
3675
- return [
3676
- {
3677
- ...itemBase(logIndexOfEvent(orderFilled)),
3678
- data: {
3679
- amountFilled: a.amountFilled.toString(),
3680
- maker: lc(a.maker),
3681
- orderId: a.orderId.toString(),
3682
- partialFill: a.partialFill,
3683
- signer,
3684
- taker: lc(a.taker),
3685
- },
3686
- type: 'order-filled',
4498
+ return {
4499
+ ...itemBase(event.logIndex),
4500
+ data: {
4501
+ amountFilled: a.amountFilled.toString(),
4502
+ maker: lc(a.maker),
4503
+ orderId: a.orderId.toString(),
4504
+ partialFill: a.partialFill,
4505
+ signer,
4506
+ taker: lc(a.taker),
3687
4507
  },
3688
- ]
3689
- }
3690
- const orderFlipped = findEvent('OrderFlipped')
3691
- if (orderFlipped) {
3692
- const a = orderFlipped.args as {
4508
+ type: 'order-filled',
4509
+ }
4510
+ })
4511
+ recognize(named('OrderFlipped'), (event) => {
4512
+ const a = event.args as {
3693
4513
  amount: bigint
3694
4514
  flipTick: number
3695
4515
  isBid: boolean
@@ -3698,342 +4518,299 @@ function classifyFromLogsCore(
3698
4518
  tick: number
3699
4519
  token: Address.Address
3700
4520
  }
3701
- return [
3702
- {
3703
- ...itemBase(logIndexOfEvent(orderFlipped)),
3704
- data: {
3705
- flipTick: Number(a.flipTick),
3706
- maker: lc(a.maker),
3707
- orderId: a.orderId.toString(),
3708
- side: a.isBid ? 'bid' : 'ask',
3709
- signer,
3710
- sourceToken: token(a.amount, a.token),
3711
- tick: Number(a.tick),
3712
- },
3713
- type: 'order-flipped',
3714
- },
3715
- ]
3716
- }
3717
- const pairCreated = findEvent('PairCreated')
3718
- if (pairCreated) {
3719
- const a = pairCreated.args as { base: Address.Address; key: Hex.Hex; quote: Address.Address }
3720
- return [
3721
- {
3722
- ...itemBase(logIndexOfEvent(pairCreated)),
3723
- data: { base: lc(a.base), key: hx(a.key), quote: lc(a.quote), signer },
3724
- type: 'pair-created',
4521
+ return {
4522
+ ...itemBase(event.logIndex),
4523
+ data: {
4524
+ flipTick: Number(a.flipTick),
4525
+ maker: lc(a.maker),
4526
+ orderId: a.orderId.toString(),
4527
+ side: a.isBid ? 'bid' : 'ask',
4528
+ signer,
4529
+ sourceToken: token(a.amount, a.token),
4530
+ tick: Number(a.tick),
3725
4531
  },
3726
- ]
3727
- }
4532
+ type: 'order-flipped',
4533
+ }
4534
+ })
4535
+ recognize(named('PairCreated'), (event) => {
4536
+ const a = event.args as { base: Address.Address; key: Hex.Hex; quote: Address.Address }
4537
+ return {
4538
+ ...itemBase(event.logIndex),
4539
+ data: { base: lc(a.base), key: hx(a.key), quote: lc(a.quote), signer },
4540
+ type: 'pair-created',
4541
+ }
4542
+ })
3728
4543
 
3729
4544
  // TIP-20 token configuration / admin.
3730
- const pauseStateUpdate = findEvent('PauseStateUpdate')
3731
- if (pauseStateUpdate) {
3732
- const a = pauseStateUpdate.args as { isPaused: boolean; updater: Address.Address }
3733
- return [
3734
- {
3735
- ...itemBase(logIndexOfEvent(pauseStateUpdate)),
3736
- data: { paused: a.isPaused, signer, updater: lc(a.updater) },
3737
- type: 'token-pause-set',
3738
- },
3739
- ]
3740
- }
3741
- const supplyCapUpdate = findEvent('SupplyCapUpdate')
3742
- if (supplyCapUpdate) {
3743
- const a = supplyCapUpdate.args as { newSupplyCap: bigint; updater: Address.Address }
3744
- return [
3745
- {
3746
- ...itemBase(logIndexOfEvent(supplyCapUpdate)),
3747
- data: { signer, supplyCap: a.newSupplyCap.toString(), updater: lc(a.updater) },
3748
- type: 'token-supply-cap-set',
3749
- },
3750
- ]
3751
- }
3752
- const transferPolicyUpdate = findEvent('TransferPolicyUpdate')
3753
- if (transferPolicyUpdate) {
3754
- const a = transferPolicyUpdate.args as { newPolicyId: bigint; updater: Address.Address }
3755
- return [
3756
- {
3757
- ...itemBase(logIndexOfEvent(transferPolicyUpdate)),
3758
- data: { policyId: a.newPolicyId.toString(), signer, updater: lc(a.updater) },
3759
- type: 'token-transfer-policy-set',
3760
- },
3761
- ]
3762
- }
3763
- const quoteTokenUpdate = findEvent('QuoteTokenUpdate')
3764
- if (quoteTokenUpdate) {
3765
- const a = quoteTokenUpdate.args as { newQuoteToken: Address.Address; updater: Address.Address }
3766
- return [
3767
- {
3768
- ...itemBase(logIndexOfEvent(quoteTokenUpdate)),
3769
- data: { quoteToken: lc(a.newQuoteToken), signer, updater: lc(a.updater) },
3770
- type: 'token-quote-token-set',
3771
- },
3772
- ]
3773
- }
3774
- const nextQuoteTokenSet = findEvent('NextQuoteTokenSet')
3775
- if (nextQuoteTokenSet) {
3776
- const a = nextQuoteTokenSet.args as {
3777
- nextQuoteToken: Address.Address
3778
- updater: Address.Address
4545
+ recognize(named('PauseStateUpdate'), (event) => {
4546
+ const a = event.args as { isPaused: boolean; updater: Address.Address }
4547
+ return {
4548
+ ...itemBase(event.logIndex),
4549
+ data: { paused: a.isPaused, signer, updater: lc(a.updater) },
4550
+ type: 'token-pause-set',
3779
4551
  }
3780
- return [
3781
- {
3782
- ...itemBase(logIndexOfEvent(nextQuoteTokenSet)),
3783
- data: { nextQuoteToken: lc(a.nextQuoteToken), signer, updater: lc(a.updater) },
3784
- type: 'token-next-quote-token-set',
3785
- },
3786
- ]
3787
- }
3788
- const logoUriUpdated = findEvent('LogoURIUpdated')
3789
- if (logoUriUpdated) {
3790
- const a = logoUriUpdated.args as { newLogoURI: string; updater: Address.Address }
3791
- return [
3792
- {
3793
- ...itemBase(logIndexOfEvent(logoUriUpdated)),
3794
- data: { logoUri: a.newLogoURI, signer, updater: lc(a.updater) },
3795
- type: 'token-logo-set',
3796
- },
3797
- ]
3798
- }
3799
- const roleMembershipUpdated = findEvent('RoleMembershipUpdated')
3800
- if (roleMembershipUpdated) {
3801
- const a = roleMembershipUpdated.args as {
4552
+ })
4553
+ recognize(named('SupplyCapUpdate'), (event) => {
4554
+ const a = event.args as { newSupplyCap: bigint; updater: Address.Address }
4555
+ return {
4556
+ ...itemBase(event.logIndex),
4557
+ data: { signer, supplyCap: a.newSupplyCap.toString(), updater: lc(a.updater) },
4558
+ type: 'token-supply-cap-set',
4559
+ }
4560
+ })
4561
+ recognize(named('TransferPolicyUpdate'), (event) => {
4562
+ const a = event.args as { newPolicyId: bigint; updater: Address.Address }
4563
+ return {
4564
+ ...itemBase(event.logIndex),
4565
+ data: { policyId: a.newPolicyId.toString(), signer, updater: lc(a.updater) },
4566
+ type: 'token-transfer-policy-set',
4567
+ }
4568
+ })
4569
+ recognize(named('QuoteTokenUpdate'), (event) => {
4570
+ const a = event.args as { newQuoteToken: Address.Address; updater: Address.Address }
4571
+ return {
4572
+ ...itemBase(event.logIndex),
4573
+ data: { quoteToken: lc(a.newQuoteToken), signer, updater: lc(a.updater) },
4574
+ type: 'token-quote-token-set',
4575
+ }
4576
+ })
4577
+ recognize(named('NextQuoteTokenSet'), (event) => {
4578
+ const a = event.args as { nextQuoteToken: Address.Address; updater: Address.Address }
4579
+ return {
4580
+ ...itemBase(event.logIndex),
4581
+ data: { nextQuoteToken: lc(a.nextQuoteToken), signer, updater: lc(a.updater) },
4582
+ type: 'token-next-quote-token-set',
4583
+ }
4584
+ })
4585
+ recognize(named('LogoURIUpdated'), (event) => {
4586
+ const a = event.args as { newLogoURI: string; updater: Address.Address }
4587
+ return {
4588
+ ...itemBase(event.logIndex),
4589
+ data: { logoUri: a.newLogoURI, signer, updater: lc(a.updater) },
4590
+ type: 'token-logo-set',
4591
+ }
4592
+ })
4593
+ recognize(named('RoleMembershipUpdated'), (event) => {
4594
+ const a = event.args as {
3802
4595
  account: Address.Address
3803
4596
  hasRole: boolean
3804
4597
  role: Hex.Hex
3805
4598
  sender: Address.Address
3806
4599
  }
3807
- return [
3808
- {
3809
- ...itemBase(logIndexOfEvent(roleMembershipUpdated)),
3810
- data: {
3811
- account: lc(a.account),
3812
- granted: a.hasRole,
3813
- role: hx(a.role),
3814
- sender: lc(a.sender),
3815
- signer,
3816
- },
3817
- type: 'role-membership-set',
4600
+ return {
4601
+ ...itemBase(event.logIndex),
4602
+ data: {
4603
+ account: lc(a.account),
4604
+ granted: a.hasRole,
4605
+ role: hx(a.role),
4606
+ sender: lc(a.sender),
4607
+ signer,
3818
4608
  },
3819
- ]
3820
- }
3821
- const roleAdminUpdated = findEvent('RoleAdminUpdated')
3822
- if (roleAdminUpdated) {
3823
- const a = roleAdminUpdated.args as {
4609
+ type: 'role-membership-set',
4610
+ }
4611
+ })
4612
+ recognize(named('RoleAdminUpdated'), (event) => {
4613
+ const a = event.args as {
3824
4614
  newAdminRole: Hex.Hex
3825
4615
  role: Hex.Hex
3826
4616
  sender: Address.Address
3827
4617
  }
3828
- return [
3829
- {
3830
- ...itemBase(logIndexOfEvent(roleAdminUpdated)),
3831
- data: {
3832
- newAdminRole: hx(a.newAdminRole),
3833
- role: hx(a.role),
3834
- sender: lc(a.sender),
3835
- signer,
3836
- },
3837
- type: 'role-admin-set',
4618
+ return {
4619
+ ...itemBase(event.logIndex),
4620
+ data: {
4621
+ newAdminRole: hx(a.newAdminRole),
4622
+ role: hx(a.role),
4623
+ sender: lc(a.sender),
4624
+ signer,
3838
4625
  },
3839
- ]
3840
- }
4626
+ type: 'role-admin-set',
4627
+ }
4628
+ })
3841
4629
 
3842
4630
  // Fee-manager token configuration.
3843
- const userTokenSet = findEvent('UserTokenSet')
3844
- if (userTokenSet) {
3845
- const a = userTokenSet.args as { token: Address.Address; user: Address.Address }
3846
- return [
3847
- {
3848
- ...itemBase(logIndexOfEvent(userTokenSet)),
3849
- data: { signer, token: lc(a.token), user: lc(a.user) },
3850
- type: 'fee-user-token-set',
3851
- },
3852
- ]
3853
- }
3854
- const validatorTokenSet = findEvent('ValidatorTokenSet')
3855
- if (validatorTokenSet) {
3856
- const a = validatorTokenSet.args as { token: Address.Address; validator: Address.Address }
3857
- return [
3858
- {
3859
- ...itemBase(logIndexOfEvent(validatorTokenSet)),
3860
- data: { signer, token: lc(a.token), validator: lc(a.validator) },
3861
- type: 'fee-validator-token-set',
3862
- },
3863
- ]
3864
- }
4631
+ recognize(named('UserTokenSet'), (event) => {
4632
+ const a = event.args as { token: Address.Address; user: Address.Address }
4633
+ return {
4634
+ ...itemBase(event.logIndex),
4635
+ data: { signer, token: lc(a.token), user: lc(a.user) },
4636
+ type: 'fee-user-token-set',
4637
+ }
4638
+ })
4639
+ recognize(named('ValidatorTokenSet'), (event) => {
4640
+ const a = event.args as { token: Address.Address; validator: Address.Address }
4641
+ return {
4642
+ ...itemBase(event.logIndex),
4643
+ data: { signer, token: lc(a.token), validator: lc(a.validator) },
4644
+ type: 'fee-validator-token-set',
4645
+ }
4646
+ })
3865
4647
 
3866
- // TIP-403 policy registry (create wins over the member events it bundles).
3867
- const compoundPolicyCreated = findEvent('CompoundPolicyCreated')
3868
- if (compoundPolicyCreated) {
3869
- const a = compoundPolicyCreated.args as {
4648
+ // TIP-403 policy registry. Creation events claim the member events they
4649
+ // bundle (which precede the anchor); later member updates stay independent.
4650
+ recognize(named('CompoundPolicyCreated'), (event) => {
4651
+ const a = event.args as {
3870
4652
  creator: Address.Address
3871
4653
  mintRecipientPolicyId: bigint
3872
4654
  policyId: bigint
3873
4655
  recipientPolicyId: bigint
3874
4656
  senderPolicyId: bigint
3875
4657
  }
3876
- return [
3877
- {
3878
- ...itemBase(logIndexOfEvent(compoundPolicyCreated)),
3879
- data: {
3880
- creator: lc(a.creator),
3881
- mintRecipientPolicyId: a.mintRecipientPolicyId.toString(),
3882
- policyId: a.policyId.toString(),
3883
- recipientPolicyId: a.recipientPolicyId.toString(),
3884
- senderPolicyId: a.senderPolicyId.toString(),
3885
- signer,
3886
- },
3887
- type: 'compound-policy-created',
4658
+ const componentIds = new Set(
4659
+ [a.mintRecipientPolicyId, a.recipientPolicyId, a.senderPolicyId].map(String),
4660
+ )
4661
+ unclaimed(
4662
+ (e) =>
4663
+ e.logIndex < event.logIndex &&
4664
+ Address.isEqual(e.address, event.address) &&
4665
+ ['BlacklistUpdated', 'PolicyAdminUpdated', 'PolicyCreated', 'WhitelistUpdated'].includes(
4666
+ e.eventName,
4667
+ ) &&
4668
+ 'policyId' in e.args &&
4669
+ componentIds.has(String((e.args as { policyId: bigint }).policyId)),
4670
+ ).forEach(claim)
4671
+ return {
4672
+ ...itemBase(event.logIndex),
4673
+ data: {
4674
+ creator: lc(a.creator),
4675
+ mintRecipientPolicyId: a.mintRecipientPolicyId.toString(),
4676
+ policyId: a.policyId.toString(),
4677
+ recipientPolicyId: a.recipientPolicyId.toString(),
4678
+ senderPolicyId: a.senderPolicyId.toString(),
4679
+ signer,
3888
4680
  },
3889
- ]
3890
- }
3891
- const policyCreated = findEvent('PolicyCreated')
3892
- if (policyCreated) {
3893
- const a = policyCreated.args as {
4681
+ type: 'compound-policy-created',
4682
+ }
4683
+ })
4684
+ recognize(named('PolicyCreated'), (event) => {
4685
+ const a = event.args as {
3894
4686
  policyId: bigint
3895
4687
  policyType: number
3896
4688
  updater: Address.Address
3897
4689
  }
3898
- return [
3899
- {
3900
- ...itemBase(logIndexOfEvent(policyCreated)),
3901
- data: {
3902
- policyId: a.policyId.toString(),
3903
- policyType: Number(a.policyType),
3904
- signer,
3905
- updater: lc(a.updater),
3906
- },
3907
- type: 'policy-created',
4690
+ unclaimed(
4691
+ (e) =>
4692
+ e.logIndex < event.logIndex &&
4693
+ Address.isEqual(e.address, event.address) &&
4694
+ ['BlacklistUpdated', 'PolicyAdminUpdated', 'WhitelistUpdated'].includes(e.eventName) &&
4695
+ 'policyId' in e.args &&
4696
+ (e.args as { policyId: bigint }).policyId === a.policyId,
4697
+ ).forEach(claim)
4698
+ return {
4699
+ ...itemBase(event.logIndex),
4700
+ data: {
4701
+ policyId: a.policyId.toString(),
4702
+ policyType: Number(a.policyType),
4703
+ signer,
4704
+ updater: lc(a.updater),
3908
4705
  },
3909
- ]
3910
- }
3911
- const policyAdminUpdated = findEvent('PolicyAdminUpdated')
3912
- if (policyAdminUpdated) {
3913
- const a = policyAdminUpdated.args as {
4706
+ type: 'policy-created',
4707
+ }
4708
+ })
4709
+ recognize(named('PolicyAdminUpdated'), (event) => {
4710
+ const a = event.args as {
3914
4711
  admin: Address.Address
3915
4712
  policyId: bigint
3916
4713
  updater: Address.Address
3917
4714
  }
3918
- return [
3919
- {
3920
- ...itemBase(logIndexOfEvent(policyAdminUpdated)),
3921
- data: {
3922
- admin: lc(a.admin),
3923
- policyId: a.policyId.toString(),
3924
- signer,
3925
- updater: lc(a.updater),
3926
- },
3927
- type: 'policy-admin-set',
4715
+ return {
4716
+ ...itemBase(event.logIndex),
4717
+ data: {
4718
+ admin: lc(a.admin),
4719
+ policyId: a.policyId.toString(),
4720
+ signer,
4721
+ updater: lc(a.updater),
3928
4722
  },
3929
- ]
3930
- }
3931
- const whitelistUpdated = findEvent('WhitelistUpdated')
3932
- if (whitelistUpdated) {
3933
- const a = whitelistUpdated.args as {
4723
+ type: 'policy-admin-set',
4724
+ }
4725
+ })
4726
+ recognize(named('WhitelistUpdated'), (event) => {
4727
+ const a = event.args as {
3934
4728
  account: Address.Address
3935
4729
  allowed: boolean
3936
4730
  policyId: bigint
3937
4731
  updater: Address.Address
3938
4732
  }
3939
- return [
3940
- {
3941
- ...itemBase(logIndexOfEvent(whitelistUpdated)),
3942
- data: {
3943
- account: lc(a.account),
3944
- allowed: a.allowed,
3945
- policyId: a.policyId.toString(),
3946
- signer,
3947
- updater: lc(a.updater),
3948
- },
3949
- type: 'whitelist-updated',
4733
+ return {
4734
+ ...itemBase(event.logIndex),
4735
+ data: {
4736
+ account: lc(a.account),
4737
+ allowed: a.allowed,
4738
+ policyId: a.policyId.toString(),
4739
+ signer,
4740
+ updater: lc(a.updater),
3950
4741
  },
3951
- ]
3952
- }
3953
- const blacklistUpdated = findEvent('BlacklistUpdated')
3954
- if (blacklistUpdated) {
3955
- const a = blacklistUpdated.args as {
4742
+ type: 'whitelist-updated',
4743
+ }
4744
+ })
4745
+ recognize(named('BlacklistUpdated'), (event) => {
4746
+ const a = event.args as {
3956
4747
  account: Address.Address
3957
4748
  policyId: bigint
3958
4749
  restricted: boolean
3959
4750
  updater: Address.Address
3960
4751
  }
3961
- return [
3962
- {
3963
- ...itemBase(logIndexOfEvent(blacklistUpdated)),
3964
- data: {
3965
- account: lc(a.account),
3966
- policyId: a.policyId.toString(),
3967
- restricted: a.restricted,
3968
- signer,
3969
- updater: lc(a.updater),
3970
- },
3971
- type: 'blacklist-updated',
4752
+ return {
4753
+ ...itemBase(event.logIndex),
4754
+ data: {
4755
+ account: lc(a.account),
4756
+ policyId: a.policyId.toString(),
4757
+ restricted: a.restricted,
4758
+ signer,
4759
+ updater: lc(a.updater),
3972
4760
  },
3973
- ]
3974
- }
4761
+ type: 'blacklist-updated',
4762
+ }
4763
+ })
3975
4764
 
3976
4765
  // Address / nonce registry.
3977
- const masterRegistered = findEvent('MasterRegistered')
3978
- if (masterRegistered) {
3979
- const a = masterRegistered.args as { masterAddress: Address.Address; masterId: Hex.Hex }
3980
- return [
3981
- {
3982
- ...itemBase(logIndexOfEvent(masterRegistered)),
3983
- data: { masterAddress: lc(a.masterAddress), masterId: hx(a.masterId), signer },
3984
- type: 'master-registered',
3985
- },
3986
- ]
3987
- }
3988
- const nonceIncremented = findEvent('NonceIncremented')
3989
- if (nonceIncremented) {
3990
- const a = nonceIncremented.args as {
4766
+ recognize(named('MasterRegistered'), (event) => {
4767
+ const a = event.args as { masterAddress: Address.Address; masterId: Hex.Hex }
4768
+ return {
4769
+ ...itemBase(event.logIndex),
4770
+ data: { masterAddress: lc(a.masterAddress), masterId: hx(a.masterId), signer },
4771
+ type: 'master-registered',
4772
+ }
4773
+ })
4774
+ recognize(named('NonceIncremented'), (event) => {
4775
+ const a = event.args as {
3991
4776
  account: Address.Address
3992
4777
  newNonce: bigint
3993
4778
  nonceKey: bigint
3994
4779
  }
3995
- return [
3996
- {
3997
- ...itemBase(logIndexOfEvent(nonceIncremented)),
3998
- data: {
3999
- account: lc(a.account),
4000
- nonce: a.newNonce.toString(),
4001
- nonceKey: a.nonceKey.toString(),
4002
- signer,
4003
- },
4004
- type: 'nonce-incremented',
4780
+ return {
4781
+ ...itemBase(event.logIndex),
4782
+ data: {
4783
+ account: lc(a.account),
4784
+ nonce: a.newNonce.toString(),
4785
+ nonceKey: a.nonceKey.toString(),
4786
+ signer,
4005
4787
  },
4006
- ]
4007
- }
4788
+ type: 'nonce-incremented',
4789
+ }
4790
+ })
4008
4791
 
4009
4792
  // Access-key authorization witnesses.
4010
- const witnessRecorded = findEvent('KeyAuthorizationWitness')
4011
- if (witnessRecorded) {
4012
- const a = witnessRecorded.args as { account: Address.Address; witness: Hex.Hex }
4013
- return [
4014
- {
4015
- ...itemBase(logIndexOfEvent(witnessRecorded)),
4016
- data: { account: lc(a.account), signer, witness: hx(a.witness) },
4017
- type: 'key-authorization-witness',
4018
- },
4019
- ]
4020
- }
4021
- const witnessBurned = findEvent('KeyAuthorizationWitnessBurned')
4022
- if (witnessBurned) {
4023
- const a = witnessBurned.args as { account: Address.Address; witness: Hex.Hex }
4024
- return [
4025
- {
4026
- ...itemBase(logIndexOfEvent(witnessBurned)),
4027
- data: { account: lc(a.account), signer, witness: hx(a.witness) },
4028
- type: 'key-authorization-witness-burned',
4029
- },
4030
- ]
4031
- }
4793
+ recognize(named('KeyAuthorizationWitness'), (event) => {
4794
+ const a = event.args as { account: Address.Address; witness: Hex.Hex }
4795
+ return {
4796
+ ...itemBase(event.logIndex),
4797
+ data: { account: lc(a.account), signer, witness: hx(a.witness) },
4798
+ type: 'key-authorization-witness',
4799
+ }
4800
+ })
4801
+ recognize(named('KeyAuthorizationWitnessBurned'), (event) => {
4802
+ const a = event.args as { account: Address.Address; witness: Hex.Hex }
4803
+ return {
4804
+ ...itemBase(event.logIndex),
4805
+ data: { account: lc(a.account), signer, witness: hx(a.witness) },
4806
+ type: 'key-authorization-witness-burned',
4807
+ }
4808
+ })
4032
4809
 
4033
- // Validator configuration (rotation supersedes the add/deactivate it bundles).
4034
- const validatorRotated = findEvent('ValidatorRotated')
4035
- if (validatorRotated) {
4036
- const a = validatorRotated.args as {
4810
+ // Validator configuration. Rotation claims the add/deactivate pair it
4811
+ // bundles, matched by validator index.
4812
+ recognize(named('ValidatorRotated'), (event) => {
4813
+ const a = event.args as {
4037
4814
  caller: Address.Address
4038
4815
  deactivatedIndex: bigint
4039
4816
  egress: string
@@ -4043,27 +4820,32 @@ function classifyFromLogsCore(
4043
4820
  oldPublicKey: Hex.Hex
4044
4821
  validatorAddress: Address.Address
4045
4822
  }
4046
- return [
4047
- {
4048
- ...itemBase(logIndexOfEvent(validatorRotated)),
4049
- data: {
4050
- caller: lc(a.caller),
4051
- deactivatedIndex: a.deactivatedIndex.toString(),
4052
- egress: a.egress,
4053
- index: a.index.toString(),
4054
- ingress: a.ingress,
4055
- newPublicKey: hx(a.newPublicKey),
4056
- oldPublicKey: hx(a.oldPublicKey),
4057
- signer,
4058
- validator: lc(a.validatorAddress),
4059
- },
4060
- type: 'validator-rotated',
4823
+ unclaimed((e) => {
4824
+ if (!('index' in e.args)) return false
4825
+ const index = (e.args as { index: bigint }).index
4826
+ return (
4827
+ (e.eventName === 'ValidatorAdded' && index === a.index) ||
4828
+ (e.eventName === 'ValidatorDeactivated' && index === a.deactivatedIndex)
4829
+ )
4830
+ }).forEach(claim)
4831
+ return {
4832
+ ...itemBase(event.logIndex),
4833
+ data: {
4834
+ caller: lc(a.caller),
4835
+ deactivatedIndex: a.deactivatedIndex.toString(),
4836
+ egress: a.egress,
4837
+ index: a.index.toString(),
4838
+ ingress: a.ingress,
4839
+ newPublicKey: hx(a.newPublicKey),
4840
+ oldPublicKey: hx(a.oldPublicKey),
4841
+ signer,
4842
+ validator: lc(a.validatorAddress),
4061
4843
  },
4062
- ]
4063
- }
4064
- const validatorAdded = findEvent('ValidatorAdded')
4065
- if (validatorAdded) {
4066
- const a = validatorAdded.args as {
4844
+ type: 'validator-rotated',
4845
+ }
4846
+ })
4847
+ recognize(named('ValidatorAdded'), (event) => {
4848
+ const a = event.args as {
4067
4849
  egress: string
4068
4850
  feeRecipient: Address.Address
4069
4851
  index: bigint
@@ -4071,236 +4853,207 @@ function classifyFromLogsCore(
4071
4853
  publicKey: Hex.Hex
4072
4854
  validatorAddress: Address.Address
4073
4855
  }
4074
- return [
4075
- {
4076
- ...itemBase(logIndexOfEvent(validatorAdded)),
4077
- data: {
4078
- egress: a.egress,
4079
- feeRecipient: lc(a.feeRecipient),
4080
- index: a.index.toString(),
4081
- ingress: a.ingress,
4082
- publicKey: hx(a.publicKey),
4083
- signer,
4084
- validator: lc(a.validatorAddress),
4085
- },
4086
- type: 'validator-added',
4087
- },
4088
- ]
4089
- }
4090
- const validatorDeactivated = findEvent('ValidatorDeactivated')
4091
- if (validatorDeactivated) {
4092
- const a = validatorDeactivated.args as { index: bigint; validatorAddress: Address.Address }
4093
- return [
4094
- {
4095
- ...itemBase(logIndexOfEvent(validatorDeactivated)),
4096
- data: { index: a.index.toString(), signer, validator: lc(a.validatorAddress) },
4097
- type: 'validator-deactivated',
4856
+ return {
4857
+ ...itemBase(event.logIndex),
4858
+ data: {
4859
+ egress: a.egress,
4860
+ feeRecipient: lc(a.feeRecipient),
4861
+ index: a.index.toString(),
4862
+ ingress: a.ingress,
4863
+ publicKey: hx(a.publicKey),
4864
+ signer,
4865
+ validator: lc(a.validatorAddress),
4098
4866
  },
4099
- ]
4100
- }
4101
- const validatorMigrated = findEvent('ValidatorMigrated')
4102
- if (validatorMigrated) {
4103
- const a = validatorMigrated.args as {
4867
+ type: 'validator-added',
4868
+ }
4869
+ })
4870
+ recognize(named('ValidatorDeactivated'), (event) => {
4871
+ const a = event.args as { index: bigint; validatorAddress: Address.Address }
4872
+ return {
4873
+ ...itemBase(event.logIndex),
4874
+ data: { index: a.index.toString(), signer, validator: lc(a.validatorAddress) },
4875
+ type: 'validator-deactivated',
4876
+ }
4877
+ })
4878
+ recognize(named('ValidatorMigrated'), (event) => {
4879
+ const a = event.args as {
4104
4880
  index: bigint
4105
4881
  publicKey: Hex.Hex
4106
4882
  validatorAddress: Address.Address
4107
4883
  }
4108
- return [
4109
- {
4110
- ...itemBase(logIndexOfEvent(validatorMigrated)),
4111
- data: {
4112
- index: a.index.toString(),
4113
- publicKey: hx(a.publicKey),
4114
- signer,
4115
- validator: lc(a.validatorAddress),
4116
- },
4117
- type: 'validator-migrated',
4884
+ return {
4885
+ ...itemBase(event.logIndex),
4886
+ data: {
4887
+ index: a.index.toString(),
4888
+ publicKey: hx(a.publicKey),
4889
+ signer,
4890
+ validator: lc(a.validatorAddress),
4118
4891
  },
4119
- ]
4120
- }
4121
- const validatorMigrationSkipped = findEvent('SkippedValidatorMigration')
4122
- if (validatorMigrationSkipped) {
4123
- const a = validatorMigrationSkipped.args as {
4892
+ type: 'validator-migrated',
4893
+ }
4894
+ })
4895
+ recognize(named('SkippedValidatorMigration'), (event) => {
4896
+ const a = event.args as {
4124
4897
  index: bigint
4125
4898
  publicKey: Hex.Hex
4126
4899
  validatorAddress: Address.Address
4127
4900
  }
4128
- return [
4129
- {
4130
- ...itemBase(logIndexOfEvent(validatorMigrationSkipped)),
4131
- data: {
4132
- index: a.index.toString(),
4133
- publicKey: hx(a.publicKey),
4134
- signer,
4135
- validator: lc(a.validatorAddress),
4136
- },
4137
- type: 'validator-migration-skipped',
4901
+ return {
4902
+ ...itemBase(event.logIndex),
4903
+ data: {
4904
+ index: a.index.toString(),
4905
+ publicKey: hx(a.publicKey),
4906
+ signer,
4907
+ validator: lc(a.validatorAddress),
4138
4908
  },
4139
- ]
4140
- }
4141
- const feeRecipientUpdated = findEvent('FeeRecipientUpdated')
4142
- if (feeRecipientUpdated) {
4143
- const a = feeRecipientUpdated.args as {
4909
+ type: 'validator-migration-skipped',
4910
+ }
4911
+ })
4912
+ recognize(named('FeeRecipientUpdated'), (event) => {
4913
+ const a = event.args as {
4144
4914
  caller: Address.Address
4145
4915
  feeRecipient: Address.Address
4146
4916
  index: bigint
4147
4917
  }
4148
- return [
4149
- {
4150
- ...itemBase(logIndexOfEvent(feeRecipientUpdated)),
4151
- data: {
4152
- caller: lc(a.caller),
4153
- feeRecipient: lc(a.feeRecipient),
4154
- index: a.index.toString(),
4155
- signer,
4156
- },
4157
- type: 'validator-fee-recipient-set',
4918
+ return {
4919
+ ...itemBase(event.logIndex),
4920
+ data: {
4921
+ caller: lc(a.caller),
4922
+ feeRecipient: lc(a.feeRecipient),
4923
+ index: a.index.toString(),
4924
+ signer,
4158
4925
  },
4159
- ]
4160
- }
4161
- const ipAddressesUpdated = findEvent('IpAddressesUpdated')
4162
- if (ipAddressesUpdated) {
4163
- const a = ipAddressesUpdated.args as {
4926
+ type: 'validator-fee-recipient-set',
4927
+ }
4928
+ })
4929
+ recognize(named('IpAddressesUpdated'), (event) => {
4930
+ const a = event.args as {
4164
4931
  caller: Address.Address
4165
4932
  egress: string
4166
4933
  index: bigint
4167
4934
  ingress: string
4168
4935
  }
4169
- return [
4170
- {
4171
- ...itemBase(logIndexOfEvent(ipAddressesUpdated)),
4172
- data: {
4173
- caller: lc(a.caller),
4174
- egress: a.egress,
4175
- index: a.index.toString(),
4176
- ingress: a.ingress,
4177
- signer,
4178
- },
4179
- type: 'validator-ip-set',
4936
+ return {
4937
+ ...itemBase(event.logIndex),
4938
+ data: {
4939
+ caller: lc(a.caller),
4940
+ egress: a.egress,
4941
+ index: a.index.toString(),
4942
+ ingress: a.ingress,
4943
+ signer,
4180
4944
  },
4181
- ]
4182
- }
4183
- const validatorOwnershipTransferred = findEvent('ValidatorOwnershipTransferred')
4184
- if (validatorOwnershipTransferred) {
4185
- const a = validatorOwnershipTransferred.args as {
4945
+ type: 'validator-ip-set',
4946
+ }
4947
+ })
4948
+ recognize(named('ValidatorOwnershipTransferred'), (event) => {
4949
+ const a = event.args as {
4186
4950
  caller: Address.Address
4187
4951
  index: bigint
4188
4952
  newAddress: Address.Address
4189
4953
  oldAddress: Address.Address
4190
4954
  }
4191
- return [
4192
- {
4193
- ...itemBase(logIndexOfEvent(validatorOwnershipTransferred)),
4194
- data: {
4195
- caller: lc(a.caller),
4196
- index: a.index.toString(),
4197
- newAddress: lc(a.newAddress),
4198
- oldAddress: lc(a.oldAddress),
4199
- signer,
4200
- },
4201
- type: 'validator-ownership-transferred',
4955
+ return {
4956
+ ...itemBase(event.logIndex),
4957
+ data: {
4958
+ caller: lc(a.caller),
4959
+ index: a.index.toString(),
4960
+ newAddress: lc(a.newAddress),
4961
+ oldAddress: lc(a.oldAddress),
4962
+ signer,
4202
4963
  },
4203
- ]
4204
- }
4205
- const ownershipTransferred = findEvent('OwnershipTransferred')
4206
- if (ownershipTransferred) {
4207
- const a = ownershipTransferred.args as {
4964
+ type: 'validator-ownership-transferred',
4965
+ }
4966
+ })
4967
+ recognize(named('OwnershipTransferred'), (event) => {
4968
+ const a = event.args as {
4208
4969
  newOwner: Address.Address
4209
4970
  oldOwner: Address.Address
4210
4971
  }
4211
- return [
4212
- {
4213
- ...itemBase(logIndexOfEvent(ownershipTransferred)),
4214
- data: { newOwner: lc(a.newOwner), oldOwner: lc(a.oldOwner), signer },
4215
- type: 'ownership-transferred',
4216
- },
4217
- ]
4218
- }
4219
- const rotationEpochSet = findEvent('NetworkIdentityRotationEpochSet')
4220
- if (rotationEpochSet) {
4221
- const a = rotationEpochSet.args as { nextEpoch: bigint; previousEpoch: bigint }
4222
- return [
4223
- {
4224
- ...itemBase(logIndexOfEvent(rotationEpochSet)),
4225
- data: {
4226
- nextEpoch: a.nextEpoch.toString(),
4227
- previousEpoch: a.previousEpoch.toString(),
4228
- signer,
4229
- },
4230
- type: 'network-identity-rotation-epoch-set',
4231
- },
4232
- ]
4233
- }
4234
- const initialized = findEvent('Initialized')
4235
- if (initialized) {
4236
- const a = initialized.args as { height: bigint }
4237
- return [
4238
- {
4239
- ...itemBase(logIndexOfEvent(initialized)),
4240
- data: { height: a.height.toString(), signer },
4241
- type: 'initialized',
4972
+ return {
4973
+ ...itemBase(event.logIndex),
4974
+ data: { newOwner: lc(a.newOwner), oldOwner: lc(a.oldOwner), signer },
4975
+ type: 'ownership-transferred',
4976
+ }
4977
+ })
4978
+ recognize(named('NetworkIdentityRotationEpochSet'), (event) => {
4979
+ const a = event.args as { nextEpoch: bigint; previousEpoch: bigint }
4980
+ return {
4981
+ ...itemBase(event.logIndex),
4982
+ data: {
4983
+ nextEpoch: a.nextEpoch.toString(),
4984
+ previousEpoch: a.previousEpoch.toString(),
4985
+ signer,
4242
4986
  },
4243
- ]
4244
- }
4245
-
4246
- // Default: plain transfer (excluding transfers to the fee manager).
4247
- const userTransfers = transfers.filter(
4248
- (e) => !Address.isEqual((e.args as { to: Address.Address }).to, Addresses.feeManager),
4249
- )
4250
- if (userTransfers.length > 0) {
4251
- const t = userTransfers[0]!
4252
- const args = t.args as {
4253
- amount: bigint
4254
- from: Address.Address
4255
- memo?: Hex.Hex | undefined
4256
- to: Address.Address
4987
+ type: 'network-identity-rotation-epoch-set',
4988
+ }
4989
+ })
4990
+ recognize(named('Initialized'), (event) => {
4991
+ const a = event.args as { height: bigint }
4992
+ return {
4993
+ ...itemBase(event.logIndex),
4994
+ data: { height: a.height.toString(), signer },
4995
+ type: 'initialized',
4257
4996
  }
4258
- const to = args.to.toLowerCase() as Address.Address
4259
- const from = args.from.toLowerCase() as Address.Address
4260
- const rawMemo = t.eventName === 'TransferWithMemo' ? args.memo : undefined
4997
+ })
4998
+
4999
+ // Plain transfers: every remaining viewer-party transfer is its own
5000
+ // activity. Non-viewer transfers are internal legs of what classified
5001
+ // above and surface only via the fallback below.
5002
+ const buildTransfer = (event: Decoded): RawItem => {
5003
+ const args = transferArgs(event)
5004
+ const to = lc(args.to)
5005
+ const from = lc(args.from)
5006
+ const rawMemo = event.eventName === 'TransferWithMemo' ? args.memo : undefined
4261
5007
  // The raw memo (not the suppressed display string) carries the MPP
4262
5008
  // attribution fingerprint, so resolve the service name from it.
4263
5009
  const memo = rawMemo ? Transfers.memoToString(rawMemo) : undefined
4264
5010
  const attribution = Mpp.resolve({ fingerprintMap, memo: rawMemo })
4265
- const sourceToken = token(args.amount, t.address as Address.Address)
5011
+ const sourceToken = token(args.amount, event.address as Address.Address)
4266
5012
  if (Address.isEqual(to, viewer))
4267
- return [
4268
- transfer(itemBase(logIndexOfEvent(t)), blockNum, {
4269
- attribution,
4270
- direction: 'in',
4271
- memo,
4272
- recipient: viewer,
4273
- sender: from,
4274
- sourceToken,
4275
- }),
4276
- ...accessKeyItems,
4277
- ]
4278
- return [
4279
- transfer(itemBase(logIndexOfEvent(t)), blockNum, {
5013
+ return transfer(itemBase(event.logIndex), blockNum, {
4280
5014
  attribution,
4281
- direction: 'out',
5015
+ direction: 'in',
4282
5016
  memo,
4283
- recipient: to,
5017
+ recipient: viewer,
4284
5018
  sender: from,
4285
- signer,
4286
5019
  sourceToken,
4287
- }),
4288
- ...accessKeyItems,
4289
- ]
5020
+ })
5021
+ return transfer(itemBase(event.logIndex), blockNum, {
5022
+ attribution,
5023
+ direction: 'out',
5024
+ memo,
5025
+ recipient: to,
5026
+ sender: from,
5027
+ signer,
5028
+ sourceToken,
5029
+ })
4290
5030
  }
5031
+ const isUserTransfer = (event: Decoded) =>
5032
+ isTransfer(event) && !Address.isEqual(transferArgs(event).to, Addresses.feeManager)
5033
+ recognize(
5034
+ (event) =>
5035
+ isUserTransfer(event) &&
5036
+ (Address.isEqual(transferArgs(event).from, viewer) ||
5037
+ Address.isEqual(transferArgs(event).to, viewer)),
5038
+ buildTransfer,
5039
+ )
4291
5040
 
4292
- // Pure access-key tx (no transfer / mint / burn / etc.).
4293
- if (accessKeyItems.length > 0) return accessKeyItems
5041
+ // Fallback when nothing viewer-facing classified: surface fold-only vault
5042
+ // items, third-party approvals, and transfers (e.g. a sponsored batch)
5043
+ // instead of reporting `unknown`.
5044
+ if (items.length === 0) {
5045
+ items.push(...suppressedVaultItems)
5046
+ recognize(isErc20Approval, approvalItem)
5047
+ recognize(isUserTransfer, buildTransfer)
5048
+ }
4294
5049
 
4295
- // Unclassified: a tx the address sent or paid for whose logs matched no known
4296
- // classifier. Surface it honestly as an `unknown` item carrying every log
4297
- // (decoded when possible), rather than fabricating a zero transfer.
4298
- return [unknown(itemBase(minLogIndex), { events: buildTxEvents(rows, events), signer })]
4299
- }
5050
+ // Unclassified: a tx the address sent or paid for whose logs matched no
5051
+ // known classifier. Surface it honestly as an `unknown` item carrying every
5052
+ // log (decoded when possible), rather than fabricating a zero transfer.
5053
+ if (items.length === 0)
5054
+ return [unknown(itemBase(minLogIndex), { events: buildTxEvents(rows, events), signer })]
4300
5055
 
4301
- /** Extracts the block-wide log index from a decoded viem event. */
4302
- function logIndexOfEvent(event: { logIndex: number }): number {
4303
- return event.logIndex
5056
+ return items.sort((a, b) => a.logIndex - b.logIndex)
4304
5057
  }
4305
5058
 
4306
5059
  /**
@@ -4364,8 +5117,8 @@ function unknown(
4364
5117
 
4365
5118
  /** Decodes a group's raw rows into `viem/tempo` events via `parseEventLogs` (non-strict). */
4366
5119
  function decodeRows(rows: readonly Row[]) {
4367
- return rows.flatMap((r) => {
4368
- const log: Log = {
5120
+ const logs = rows.map(
5121
+ (r): Log => ({
4369
5122
  address: r.logAddress as `0x${string}`,
4370
5123
  blockHash: '0x0' as Hex.Hex,
4371
5124
  blockNumber: BigInt(r.blockNum),
@@ -4375,17 +5128,21 @@ function decodeRows(rows: readonly Row[]) {
4375
5128
  topics: [r.selector, r.topic1, r.topic2, r.topic3].filter(Boolean) as [Hex.Hex, ...Hex.Hex[]],
4376
5129
  transactionHash: r.txHash as `0x${string}`,
4377
5130
  transactionIndex: r.txIdx,
4378
- }
4379
- try {
4380
- return parseEventLogs({
4381
- abi: [...Abis.abis, ...Abis.earnRouter, ...Abis.earnVault],
4382
- logs: [log],
4383
- strict: false,
4384
- })
4385
- } catch {
4386
- return []
4387
- }
4388
- })
5131
+ }),
5132
+ )
5133
+ try {
5134
+ // One batched call: viem re-processes the merged ABI per invocation, so
5135
+ // per-row calls cost ~55x more. Non-strict decoding skips undecodable
5136
+ // logs per log and can yield `args: undefined`; normalize so `in`-guards
5137
+ // never throw.
5138
+ return parseEventLogs({
5139
+ abi: [...Abis.abis, ...Abis.earnRouter, ...Abis.earnVault, ...erc4626Events],
5140
+ logs,
5141
+ strict: false,
5142
+ }).map((event) => ({ ...event, args: event.args ?? {} }))
5143
+ } catch {
5144
+ return []
5145
+ }
4389
5146
  }
4390
5147
 
4391
5148
  /**
@@ -4521,49 +5278,63 @@ async function enrichTokens(
4521
5278
  for (const item of items) collectTokens(item, addresses)
4522
5279
  const includeLogoUri = include.includes('token.logoUri')
4523
5280
  const includeVerified = include.includes('token.verified')
5281
+ const resolved_promise =
5282
+ addresses.size > 0
5283
+ ? Timing.time(c, 'tokens', () =>
5284
+ Promise.all(
5285
+ [...addresses].map(async (address) => {
5286
+ const [logoUri, metadata] = await Promise.all([
5287
+ includeLogoUri
5288
+ ? Tokens.getTokenLogo(c, { address, chainId }).catch(() => undefined)
5289
+ : undefined,
5290
+ getActivityTokenMetadata(c, { address, chainId }),
5291
+ ])
5292
+ return { address: address.toLowerCase(), logoUri, metadata }
5293
+ }),
5294
+ ),
5295
+ )
5296
+ : undefined
4524
5297
  // Curated data is needed only for requested valuation or token fields.
4525
- const snapshot =
5298
+ const snapshot_promise =
4526
5299
  addresses.size > 0 && (denomination !== undefined || includeLogoUri || includeVerified)
4527
- ? await VerifiedTokens.snapshot(c, chainId).catch(() => undefined)
4528
- : undefined
4529
- const rates = denomination
4530
- ? await Valuation.ratesFor(c, {
4531
- currencies: snapshot
4532
- ? [...addresses].flatMap((address) => {
4533
- const held = snapshot.byAddress.get(address)?.currency
4534
- return held === undefined ? [] : [held]
4535
- })
4536
- : [],
4537
- denomination,
4538
- oracle,
4539
- })
4540
- : undefined
4541
- if (addresses.size === 0) return { items: items as Item[], rates }
4542
-
4543
- const resolved = await Timing.time(c, 'tokens', () =>
4544
- Promise.all(
4545
- [...addresses].map(async (address) => {
4546
- const [logoUri, metadata] = await Promise.all([
4547
- includeLogoUri
4548
- ? Tokens.getTokenLogo(c, { address, chainId }).catch(() => undefined)
5300
+ ? VerifiedTokens.snapshot(c, chainId).catch(() => undefined)
5301
+ : Promise.resolve(undefined)
5302
+ const rates_promise = (async () => {
5303
+ if (!denomination) return undefined
5304
+ const snapshot = await snapshot_promise
5305
+ return Valuation.ratesFor(c, {
5306
+ currencies: snapshot
5307
+ ? [...addresses].flatMap((address) => {
5308
+ const held = snapshot.byAddress.get(address)?.currency
5309
+ return held === undefined ? [] : [held]
5310
+ })
5311
+ : [],
5312
+ denomination,
5313
+ oracle,
5314
+ })
5315
+ })()
5316
+ if (!resolved_promise) return { items: items as Item[], rates: await rates_promise }
5317
+
5318
+ const [resolved_raw, rates, snapshot] = await Promise.all([
5319
+ resolved_promise,
5320
+ rates_promise,
5321
+ snapshot_promise,
5322
+ ])
5323
+ const resolved = resolved_raw.map(
5324
+ ({ address, logoUri, metadata }) =>
5325
+ [
5326
+ address,
5327
+ {
5328
+ currency: metadata.currency,
5329
+ decimals: metadata.decimals,
5330
+ logoUri: includeLogoUri
5331
+ ? (logoUri ?? snapshot?.byAddress.get(address)?.logoUri ?? metadata.logoUri)
4549
5332
  : undefined,
4550
- getActivityTokenMetadata(c, { address, chainId }),
4551
- ])
4552
- return [
4553
- address.toLowerCase(),
4554
- {
4555
- currency: metadata.currency,
4556
- decimals: metadata.decimals,
4557
- logoUri: includeLogoUri
4558
- ? (logoUri ?? snapshot?.byAddress.get(address)?.logoUri ?? metadata.logoUri)
4559
- : undefined,
4560
- name: metadata.name,
4561
- symbol: metadata.symbol,
4562
- verified: includeVerified && snapshot ? snapshot.byAddress.has(address) : undefined,
4563
- },
4564
- ] as const
4565
- }),
4566
- ),
5333
+ name: metadata.name,
5334
+ symbol: metadata.symbol,
5335
+ verified: includeVerified && snapshot ? snapshot.byAddress.has(address) : undefined,
5336
+ },
5337
+ ] as const,
4567
5338
  )
4568
5339
  const byAddress = new Map(resolved)
4569
5340
  return {
@@ -4584,6 +5355,54 @@ declare namespace enrichTokens {
4584
5355
  }
4585
5356
  }
4586
5357
 
5358
+ /** Resolves token metadata on each item's source chain without changing feed order. */
5359
+ async function enrichChainTokens(
5360
+ c: Context<App.Environment>,
5361
+ options: enrichChainTokens.Options,
5362
+ ): Promise<{ items: Item[]; rates: FxOracle.RateSet | undefined }> {
5363
+ const byChain = new Map<number, listChains.Item[]>()
5364
+ for (const item of options.items) {
5365
+ const items = byChain.get(item.chainId) ?? []
5366
+ items.push(item)
5367
+ byChain.set(item.chainId, items)
5368
+ }
5369
+ const results = await Promise.all(
5370
+ [...byChain].map(async ([chainId, items]) => ({
5371
+ chainId,
5372
+ ...(await enrichTokens(c, {
5373
+ chainId,
5374
+ denomination: options.denomination,
5375
+ include: options.include,
5376
+ items,
5377
+ oracle: options.oracle,
5378
+ })),
5379
+ })),
5380
+ )
5381
+ type Bucket = { index: number; items: Item[] }
5382
+ const buckets = new Map<number, Bucket>()
5383
+ for (const result of results) buckets.set(result.chainId, { index: 0, items: result.items })
5384
+ return {
5385
+ items: options.items.map((item) => {
5386
+ const bucket = buckets.get(item.chainId)!
5387
+ return bucket.items[bucket.index++]!
5388
+ }),
5389
+ rates: results.find((result) => result.rates !== undefined)?.rates,
5390
+ }
5391
+ }
5392
+
5393
+ declare namespace enrichChainTokens {
5394
+ type Options = {
5395
+ /** Currency the amount valuations are denominated in. */
5396
+ denomination?: string | undefined
5397
+ /** Optional token fields to resolve. */
5398
+ include: readonly z.output<typeof schema.Include>[]
5399
+ /** Chain-tagged items awaiting token enrichment. */
5400
+ items: readonly listChains.Item[]
5401
+ /** FX rate oracle backing amount valuation. */
5402
+ oracle: FxOracle.Oracle
5403
+ }
5404
+ }
5405
+
4587
5406
  /** Returns an enriched copy of an item without mutating its cached token stubs. */
4588
5407
  function enrichItem(
4589
5408
  item: UnenrichedItem,
@@ -4651,6 +5470,7 @@ export function groupByAccessKey(items: Item[]): Entry[] {
4651
5470
  }
4652
5471
 
4653
5472
  type GroupableItem = {
5473
+ chainId?: number | undefined
4654
5474
  data: object
4655
5475
  id: string
4656
5476
  perspective: 'incoming' | 'outgoing'
@@ -4660,6 +5480,7 @@ type GroupableItem = {
4660
5480
  type GroupedItem<item extends GroupableItem> =
4661
5481
  | item
4662
5482
  | {
5483
+ chainId?: number | undefined
4663
5484
  data: { items: item[]; signer: Address.Address }
4664
5485
  id: string
4665
5486
  perspective: 'incoming' | 'outgoing'
@@ -4678,6 +5499,7 @@ function groupByAccessKeyItems<item extends GroupableItem>(items: item[]): Group
4678
5499
  if (itemSigner !== 'self') {
4679
5500
  if (
4680
5501
  currentGroup &&
5502
+ currentGroup.items[0]!.chainId === item.chainId &&
4681
5503
  Address.isEqual(currentGroup.signer, itemSigner) &&
4682
5504
  sameCalendarDay(currentGroup.items[0]!.timestamp, item.timestamp)
4683
5505
  ) {
@@ -4717,6 +5539,7 @@ function flushGroup<item extends GroupableItem>(group: {
4717
5539
  const firstItem = group.items[0]!
4718
5540
  const lastItem = group.items[group.items.length - 1]!
4719
5541
  return {
5542
+ ...(firstItem.chainId === undefined ? {} : { chainId: firstItem.chainId }),
4720
5543
  data: { items: group.items, signer: group.signer },
4721
5544
  perspective: firstItem.perspective,
4722
5545
  id: `${firstItem.id}..${lastItem.id}:${group.items.length}`,