tapimo 0.12.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 (257) hide show
  1. package/dist/App.d.ts +5561 -5026
  2. package/dist/App.d.ts.map +1 -1
  3. package/dist/App.js +38 -3
  4. package/dist/App.js.map +1 -1
  5. package/dist/Scope.d.ts +2 -0
  6. package/dist/Scope.d.ts.map +1 -1
  7. package/dist/Scope.js +17 -0
  8. package/dist/Scope.js.map +1 -1
  9. package/dist/admin/App.d.ts +123 -74
  10. package/dist/admin/App.d.ts.map +1 -1
  11. package/dist/admin/apps/api-keys.d.ts +80 -0
  12. package/dist/admin/apps/api-keys.d.ts.map +1 -1
  13. package/dist/admin/apps/api-keys.js +54 -0
  14. package/dist/admin/apps/api-keys.js.map +1 -1
  15. package/dist/admin/apps/earn-vaults.d.ts +15 -15
  16. package/dist/admin/apps/earn-vaults.js +1 -1
  17. package/dist/admin/apps/verified-tokens.d.ts +81 -81
  18. package/dist/admin/ui.generated.d.ts +1 -1
  19. package/dist/admin/ui.generated.d.ts.map +1 -1
  20. package/dist/admin/ui.generated.js +151 -151
  21. package/dist/admin/ui.generated.js.map +1 -1
  22. package/dist/apps/Zones.d.ts +16 -16
  23. package/dist/apps/Zones.js +4 -4
  24. package/dist/apps/data/App.js +18 -18
  25. package/dist/apps/data/App.js.map +1 -1
  26. package/dist/apps/data/ZoneSelection.d.ts +8 -8
  27. package/dist/apps/data/ZoneSelection.d.ts.map +1 -1
  28. package/dist/apps/data/ZoneSelection.js +1 -1
  29. package/dist/apps/data/ZoneSelection.js.map +1 -1
  30. package/dist/apps/data/routes/activities.d.ts +588 -584
  31. package/dist/apps/data/routes/activities.d.ts.map +1 -1
  32. package/dist/apps/data/routes/activities.js +1561 -1239
  33. package/dist/apps/data/routes/activities.js.map +1 -1
  34. package/dist/apps/data/routes/balances.d.ts +26 -26
  35. package/dist/apps/data/routes/balances.d.ts.map +1 -1
  36. package/dist/apps/data/routes/balances.js +43 -16
  37. package/dist/apps/data/routes/balances.js.map +1 -1
  38. package/dist/apps/data/routes/blocks.d.ts +44 -44
  39. package/dist/apps/data/routes/blocks.d.ts.map +1 -1
  40. package/dist/apps/data/routes/blocks.js +21 -11
  41. package/dist/apps/data/routes/blocks.js.map +1 -1
  42. package/dist/apps/data/routes/coingecko.d.ts +3 -3
  43. package/dist/apps/data/routes/earn.d.ts +18 -18
  44. package/dist/apps/data/routes/earn.js +13 -13
  45. package/dist/apps/data/routes/exchanges.d.ts +145 -145
  46. package/dist/apps/data/routes/fee-amm.d.ts +2 -2
  47. package/dist/apps/data/routes/receipts.d.ts +155 -55
  48. package/dist/apps/data/routes/receipts.d.ts.map +1 -1
  49. package/dist/apps/data/routes/receipts.js +28 -10
  50. package/dist/apps/data/routes/receipts.js.map +1 -1
  51. package/dist/apps/data/routes/tokenlist.d.ts +13 -13
  52. package/dist/apps/data/routes/tokens.d.ts +98 -98
  53. package/dist/apps/data/routes/tokens.d.ts.map +1 -1
  54. package/dist/apps/data/routes/tokens.js +51 -20
  55. package/dist/apps/data/routes/tokens.js.map +1 -1
  56. package/dist/apps/data/routes/transactions.d.ts +7 -2
  57. package/dist/apps/data/routes/transactions.d.ts.map +1 -1
  58. package/dist/apps/data/routes/transactions.js +37 -17
  59. package/dist/apps/data/routes/transactions.js.map +1 -1
  60. package/dist/apps/data/routes/transfers.d.ts +1 -1
  61. package/dist/apps/data/routes/transfers.d.ts.map +1 -1
  62. package/dist/apps/data/routes/transfers.js +18 -12
  63. package/dist/apps/data/routes/transfers.js.map +1 -1
  64. package/dist/apps/data/routes/valuation.d.ts +1 -1
  65. package/dist/apps/data/routes/valuation.d.ts.map +1 -1
  66. package/dist/apps/data/routes/valuation.js +12 -7
  67. package/dist/apps/data/routes/valuation.js.map +1 -1
  68. package/dist/apps/data/routes/verified-tokens.d.ts +3 -3
  69. package/dist/apps/data/routes/webhooks.d.ts +11 -11
  70. package/dist/apps/data/routes/zones.d.ts +14 -14
  71. package/dist/apps/data/routes/zones.js +1 -1
  72. package/dist/apps/funding/App.d.ts +11 -0
  73. package/dist/apps/funding/App.d.ts.map +1 -1
  74. package/dist/apps/funding/routes/deposit-addresses.d.ts.map +1 -1
  75. package/dist/apps/funding/routes/deposit-addresses.js +16 -11
  76. package/dist/apps/funding/routes/deposit-addresses.js.map +1 -1
  77. package/dist/apps/funding/routes/transfers.d.ts +60 -48
  78. package/dist/apps/funding/routes/transfers.d.ts.map +1 -1
  79. package/dist/apps/funding/routes/transfers.js +27 -19
  80. package/dist/apps/funding/routes/transfers.js.map +1 -1
  81. package/dist/apps/management/App.d.ts +4487 -3963
  82. package/dist/apps/management/App.d.ts.map +1 -1
  83. package/dist/apps/management/routes/api-keys.d.ts +710 -159
  84. package/dist/apps/management/routes/api-keys.d.ts.map +1 -1
  85. package/dist/apps/management/routes/api-keys.js +236 -4
  86. package/dist/apps/management/routes/api-keys.js.map +1 -1
  87. package/dist/apps/management/routes/faucet.d.ts +6 -6
  88. package/dist/apps/management/routes/invite-links.d.ts +42 -42
  89. package/dist/apps/management/routes/members.d.ts +30 -30
  90. package/dist/apps/management/routes/projects.d.ts +40 -40
  91. package/dist/apps/management/routes/usage.d.ts +24 -24
  92. package/dist/apps/management/routes/verified-token-requests.d.ts +1 -1
  93. package/dist/apps/management/routes/webhooks.d.ts +55 -55
  94. package/dist/db/Db.d.ts.map +1 -1
  95. package/dist/db/Db.js +12 -1
  96. package/dist/db/Db.js.map +1 -1
  97. package/dist/db/Schema.d.ts +3 -2
  98. package/dist/db/Schema.d.ts.map +1 -1
  99. package/dist/db/Schema.js +5 -2
  100. package/dist/db/Schema.js.map +1 -1
  101. package/dist/db/tables/fundingCatalog.d.ts +4 -2
  102. package/dist/db/tables/fundingCatalog.d.ts.map +1 -1
  103. package/dist/db/tables/fundingCatalog.js +5 -1
  104. package/dist/db/tables/fundingCatalog.js.map +1 -1
  105. package/dist/handlers/internal/utils.d.ts.map +1 -1
  106. package/dist/handlers/internal/utils.js +9 -0
  107. package/dist/handlers/internal/utils.js.map +1 -1
  108. package/dist/internal/Auth.d.ts +3 -1
  109. package/dist/internal/Auth.d.ts.map +1 -1
  110. package/dist/internal/Auth.js +6 -3
  111. package/dist/internal/Auth.js.map +1 -1
  112. package/dist/internal/EarnVaults.d.ts +3 -3
  113. package/dist/internal/EarnVaults.js +3 -3
  114. package/dist/internal/Fetch.d.ts +23 -0
  115. package/dist/internal/Fetch.d.ts.map +1 -0
  116. package/dist/internal/Fetch.js +26 -0
  117. package/dist/internal/Fetch.js.map +1 -0
  118. package/dist/internal/Log.d.ts +3 -1
  119. package/dist/internal/Log.d.ts.map +1 -1
  120. package/dist/internal/Log.js +16 -3
  121. package/dist/internal/Log.js.map +1 -1
  122. package/dist/internal/OpenApi.d.ts +5 -3
  123. package/dist/internal/OpenApi.d.ts.map +1 -1
  124. package/dist/internal/OpenApi.js +13 -3
  125. package/dist/internal/OpenApi.js.map +1 -1
  126. package/dist/internal/Response.d.ts +1 -1
  127. package/dist/internal/Response.d.ts.map +1 -1
  128. package/dist/internal/Schema.d.ts +6 -3
  129. package/dist/internal/Schema.d.ts.map +1 -1
  130. package/dist/internal/Schema.js +8 -2
  131. package/dist/internal/Schema.js.map +1 -1
  132. package/dist/internal/Store.d.ts +16 -3
  133. package/dist/internal/Store.d.ts.map +1 -1
  134. package/dist/internal/Store.js +82 -16
  135. package/dist/internal/Store.js.map +1 -1
  136. package/dist/internal/Tidx.d.ts.map +1 -1
  137. package/dist/internal/Tidx.js +7 -2
  138. package/dist/internal/Tidx.js.map +1 -1
  139. package/dist/internal/Timeout.d.ts +36 -0
  140. package/dist/internal/Timeout.d.ts.map +1 -0
  141. package/dist/internal/Timeout.js +37 -0
  142. package/dist/internal/Timeout.js.map +1 -0
  143. package/dist/internal/Timing.d.ts +9 -0
  144. package/dist/internal/Timing.d.ts.map +1 -1
  145. package/dist/internal/Timing.js +7 -0
  146. package/dist/internal/Timing.js.map +1 -1
  147. package/dist/internal/WebhookDestination.d.ts.map +1 -1
  148. package/dist/internal/WebhookDestination.js +3 -0
  149. package/dist/internal/WebhookDestination.js.map +1 -1
  150. package/dist/internal/funding/Action.d.ts +9 -0
  151. package/dist/internal/funding/Action.d.ts.map +1 -1
  152. package/dist/internal/funding/Action.js +16 -8
  153. package/dist/internal/funding/Action.js.map +1 -1
  154. package/dist/internal/funding/Catalog.d.ts +9 -2
  155. package/dist/internal/funding/Catalog.d.ts.map +1 -1
  156. package/dist/internal/funding/Catalog.js +19 -8
  157. package/dist/internal/funding/Catalog.js.map +1 -1
  158. package/dist/internal/funding/Provider.d.ts +68 -13
  159. package/dist/internal/funding/Provider.d.ts.map +1 -1
  160. package/dist/internal/funding/Provider.js +29 -8
  161. package/dist/internal/funding/Provider.js.map +1 -1
  162. package/dist/internal/funding/Transfer.d.ts +13 -12
  163. package/dist/internal/funding/Transfer.d.ts.map +1 -1
  164. package/dist/internal/funding/Transfer.js +23 -21
  165. package/dist/internal/funding/Transfer.js.map +1 -1
  166. package/dist/internal/funding/providers/stargate.d.ts +28 -9
  167. package/dist/internal/funding/providers/stargate.d.ts.map +1 -1
  168. package/dist/internal/funding/providers/stargate.js +251 -117
  169. package/dist/internal/funding/providers/stargate.js.map +1 -1
  170. package/dist/internal/index.d.ts +1 -0
  171. package/dist/internal/index.d.ts.map +1 -1
  172. package/dist/internal/index.js +1 -0
  173. package/dist/internal/index.js.map +1 -1
  174. package/package.json +2 -2
  175. package/src/App.test.ts +100 -0
  176. package/src/App.ts +42 -3
  177. package/src/Client.test-d.ts +46 -0
  178. package/src/Scope.test.ts +20 -0
  179. package/src/Scope.ts +16 -0
  180. package/src/admin/apps/api-keys.test.ts +75 -0
  181. package/src/admin/apps/api-keys.ts +64 -0
  182. package/src/admin/apps/earn-vaults.test.ts +1 -1
  183. package/src/admin/apps/earn-vaults.ts +1 -1
  184. package/src/admin/ui/src/lib/api-keys.ts +19 -0
  185. package/src/admin/ui/src/routes/api-keys.tsx +89 -2
  186. package/src/admin/ui.generated.ts +189 -151
  187. package/src/apps/Zones.test.ts +4 -4
  188. package/src/apps/Zones.ts +4 -4
  189. package/src/apps/composition.test.ts +1 -1
  190. package/src/apps/data/App.test.ts +41 -0
  191. package/src/apps/data/App.ts +18 -18
  192. package/src/apps/data/ZoneSelection.test.ts +2 -2
  193. package/src/apps/data/ZoneSelection.ts +8 -8
  194. package/src/apps/data/routes/activities.test.ts +1526 -12
  195. package/src/apps/data/routes/activities.ts +1774 -1356
  196. package/src/apps/data/routes/balances.ts +133 -97
  197. package/src/apps/data/routes/blocks.test.ts +533 -0
  198. package/src/apps/data/routes/blocks.ts +35 -16
  199. package/src/apps/data/routes/earn.test.ts +4 -2
  200. package/src/apps/data/routes/earn.ts +13 -13
  201. package/src/apps/data/routes/earn.zone-access.test.ts +6 -6
  202. package/src/apps/data/routes/receipts.ts +47 -16
  203. package/src/apps/data/routes/tokens.test.ts +578 -0
  204. package/src/apps/data/routes/tokens.ts +283 -225
  205. package/src/apps/data/routes/transactions.ts +68 -29
  206. package/src/apps/data/routes/transfers.test.ts +421 -0
  207. package/src/apps/data/routes/transfers.ts +45 -27
  208. package/src/apps/data/routes/valuation.ts +94 -78
  209. package/src/apps/data/routes/zones.test.ts +2 -2
  210. package/src/apps/data/routes/zones.ts +1 -1
  211. package/src/apps/funding/routes/deposit-addresses.test.ts +25 -3
  212. package/src/apps/funding/routes/deposit-addresses.ts +17 -12
  213. package/src/apps/funding/routes/quotes.test.ts +5 -9
  214. package/src/apps/funding/routes/transfers.test.ts +395 -8
  215. package/src/apps/funding/routes/transfers.ts +76 -59
  216. package/src/apps/management/routes/api-keys.test.ts +220 -0
  217. package/src/apps/management/routes/api-keys.ts +280 -3
  218. package/src/db/Db.ts +13 -1
  219. package/src/db/Schema.ts +5 -2
  220. package/src/db/tables/earnVaults.test.ts +9 -9
  221. package/src/db/tables/fundingCatalog.test.ts +16 -2
  222. package/src/db/tables/fundingCatalog.ts +9 -3
  223. package/src/handlers/internal/utils.test.ts +53 -0
  224. package/src/handlers/internal/utils.ts +15 -0
  225. package/src/internal/Auth.ts +12 -4
  226. package/src/internal/EarnVaults.capabilities.test.ts +2 -2
  227. package/src/internal/EarnVaults.test.ts +1 -1
  228. package/src/internal/EarnVaults.ts +3 -3
  229. package/src/internal/Fetch.test.ts +46 -0
  230. package/src/internal/Fetch.ts +56 -0
  231. package/src/internal/Log.test.ts +21 -0
  232. package/src/internal/Log.ts +20 -5
  233. package/src/internal/OpenApi.ts +14 -4
  234. package/src/internal/Response.ts +1 -1
  235. package/src/internal/Schema.test.ts +2 -0
  236. package/src/internal/Schema.ts +12 -2
  237. package/src/internal/Store.test.ts +178 -0
  238. package/src/internal/Store.ts +104 -18
  239. package/src/internal/Tidx.test.ts +2 -1
  240. package/src/internal/Tidx.ts +7 -2
  241. package/src/internal/Timeout.test.ts +35 -0
  242. package/src/internal/Timeout.ts +73 -0
  243. package/src/internal/Timing.ts +16 -0
  244. package/src/internal/WebhookDestination.ts +3 -0
  245. package/src/internal/funding/Action.test.ts +41 -1
  246. package/src/internal/funding/Action.ts +69 -41
  247. package/src/internal/funding/Catalog.test.ts +34 -0
  248. package/src/internal/funding/Catalog.ts +28 -11
  249. package/src/internal/funding/Provider.test.ts +73 -0
  250. package/src/internal/funding/Provider.ts +82 -28
  251. package/src/internal/funding/Transfer.ts +133 -110
  252. package/src/internal/funding/providers/index.test.ts +4 -167
  253. package/src/internal/funding/providers/relay.test.ts +2 -2
  254. package/src/internal/funding/providers/stargate.test.ts +163 -0
  255. package/src/internal/funding/providers/stargate.ts +359 -134
  256. package/src/internal/index.test.ts +1 -0
  257. package/src/internal/index.ts +1 -0
@@ -46,47 +46,49 @@ export namespace schema {
46
46
  )
47
47
 
48
48
  /** A token with required RPC metadata and optional curated fields. */
49
- export const Token = Schema.describe(
50
- Tokens.schema.TokenReference,
51
- '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',
52
52
  )
53
53
 
54
- /** A token amount carrying its nominal value in the requested denomination. */
55
- const ValuedAmount = 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.',
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
+ ),
62
63
  ),
63
- ),
64
- })
65
-
66
- /** Self-contained amount for a destination token. */
67
- const DestinationAmount = ValuedAmount.check(
68
- z.describe('Amount associated with `destinationToken`.'),
64
+ }),
65
+ 'A token amount carrying its nominal value in the requested denomination.',
69
66
  )
70
67
 
71
- /** Self-contained amount for a refunded token. */
72
- 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
73
73
 
74
- /** Self-contained amount for a source token. */
75
- const SourceAmount = ValuedAmount.check(z.describe('Amount associated with `sourceToken`.'))
74
+ const SourceAmount = valuedAmount
76
75
 
77
76
  /** Page-level resources: valuation rate provenance. */
78
- export const Meta = 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.',
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
+ ),
86
87
  ),
87
- ),
88
- })
89
- .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
+ )
90
92
 
91
93
  /** A token transfer with subject-relative direction and signer fields. */
92
94
  export const Transfer = z
@@ -160,6 +162,8 @@ export namespace schema {
160
162
  z.describe('One token transfer involving the requested address.'),
161
163
  )
162
164
 
165
+ const activityTransfer = OpenApi.component(Transfer, 'ActivityTransfer')
166
+
163
167
  /**
164
168
  * One transaction log, decoded into a known Tempo event when possible. The
165
169
  * raw fields (`address`/`data`/`logIndex`/`topics`) are always present so no
@@ -196,6 +200,8 @@ export namespace schema {
196
200
  ),
197
201
  )
198
202
 
203
+ const activityEvent = OpenApi.component(event, 'ActivityEvent')
204
+
199
205
  /**
200
206
  * Fields shared by every activity item. `transactionHash`/`timestamp` identify and sort
201
207
  * the entry; `data` carries the type-specific payload — shaped like the
@@ -206,7 +212,7 @@ export namespace schema {
206
212
  chainId: z
207
213
  .optional(Schema.ChainId)
208
214
  .check(
209
- z.describe('Chain id containing this activity. Returned for multi-Zone requests.'),
215
+ z.describe('Chain ID containing this activity. Returned when `include=zones`.'),
210
216
  z.meta({ examples: [421700001] }),
211
217
  ),
212
218
  perspective: z
@@ -217,7 +223,7 @@ export namespace schema {
217
223
  ),
218
224
  ),
219
225
  events: z
220
- .optional(z.array(event))
226
+ .optional(z.array(activityEvent))
221
227
  .check(
222
228
  z.describe(
223
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.',
@@ -253,8 +259,7 @@ export namespace schema {
253
259
  transactionHash: Schema.Hash.check(z.describe('Transaction hash for this activity.')),
254
260
  }
255
261
 
256
- /** The classified activity item variants (discriminated by `type`). */
257
- export const items = [
262
+ const itemSchemas = [
258
263
  z
259
264
  .object({
260
265
  ...base,
@@ -398,7 +403,7 @@ export namespace schema {
398
403
  z
399
404
  .object({
400
405
  ...base,
401
- data: Transfer.check(
406
+ data: activityTransfer.check(
402
407
  z.describe('Transfer details with feed-relative direction and signer fields.'),
403
408
  ),
404
409
  type: z
@@ -1479,10 +1484,21 @@ export namespace schema {
1479
1484
  .check(z.meta({ title: 'Unknown' })),
1480
1485
  ] as const
1481
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
+
1482
1497
  /** A single classified activity item. */
1483
- export const Item = z
1484
- .union(items)
1485
- .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
+ )
1486
1502
 
1487
1503
  /** A folded access-key group: consecutive same-signer items on one UTC day. */
1488
1504
  export const Group = z
@@ -1490,7 +1506,7 @@ export namespace schema {
1490
1506
  chainId: z
1491
1507
  .optional(Schema.ChainId)
1492
1508
  .check(
1493
- z.describe('Chain id containing this activity group. Returned for multi-Zone requests.'),
1509
+ z.describe('Chain ID containing this activity group. Returned when `include=zones`.'),
1494
1510
  z.meta({ examples: [421700001] }),
1495
1511
  ),
1496
1512
  data: z
@@ -1529,10 +1545,17 @@ export namespace schema {
1529
1545
  z.describe('A group of consecutive activity items signed by the same access key.'),
1530
1546
  )
1531
1547
 
1548
+ const activityGroup = OpenApi.component(Group, 'ActivityGroup')
1549
+
1532
1550
  /** A single activity item, or a folded access-key group. */
1533
- export const Entry = z
1534
- .union([...items, Group])
1535
- .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
+ )
1536
1559
 
1537
1560
  /** Optional activity resources and selection modes. */
1538
1561
  export const Include = z
@@ -1542,7 +1565,7 @@ export namespace schema {
1542
1565
  /** Parses comma-separated optional activity resources and selection modes. */
1543
1566
  export const includeQuery = Schema.includeQuery(
1544
1567
  Include,
1545
- 'Comma-separated activity options, such as `zones,token.logoUri,token.verified`.',
1568
+ 'Comma-separated activity options. `zones` combines the selected parent chain with every readable Zone.',
1546
1569
  )
1547
1570
 
1548
1571
  /** Schemas for the getAddressActivities operation. */
@@ -1596,15 +1619,18 @@ export namespace schema {
1596
1619
  * `group=true`) plus the root `nextCursor` pagination field shared with
1597
1620
  * other list endpoints.
1598
1621
  */
1599
- export const Response = z
1600
- .object({
1601
- data: z.array(Entry).check(z.describe('Activity feed entries for the address.')),
1602
- meta: z
1603
- .optional(schema.Meta)
1604
- .check(z.describe('Page-level resources, such as valuation rate provenance.')),
1605
- nextCursor: Schema.NextCursor,
1606
- })
1607
- .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
+ )
1608
1634
  }
1609
1635
 
1610
1636
  /** Schemas for the getTransactionActivities operation. */
@@ -1640,23 +1666,26 @@ export namespace schema {
1640
1666
  .check(z.describe('Query parameters for the transaction activity request.'))
1641
1667
 
1642
1668
  /**
1643
- * The classified activity for one transaction: a list of `data` items
1644
- * (usually one headline item, occasionally more) describing what the
1645
- * transaction did onchain. Unpaginated a single transaction is finite
1646
- * 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`.
1647
1673
  */
1648
- export const Response = z
1649
- .object({
1650
- chainId: Schema.ChainId.check(
1651
- z.describe('Chain id containing the transaction.'),
1652
- z.meta({ examples: [4217] }),
1653
- ),
1654
- data: z.array(Item).check(z.describe('Activity items classified from the transaction.')),
1655
- meta: z
1656
- .optional(schema.Meta)
1657
- .check(z.describe('Page-level resources, such as valuation rate provenance.')),
1658
- })
1659
- .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
+ )
1660
1689
  }
1661
1690
  }
1662
1691
 
@@ -1788,6 +1817,7 @@ const titleByEventName: Record<string, string> = {
1788
1817
  BlacklistUpdated: 'Blacklist updated',
1789
1818
  Burn: 'Token burned',
1790
1819
  BurnBlocked: 'Burn blocked',
1820
+ Deposit: 'Assets deposited',
1791
1821
  Deposited: 'Assets deposited',
1792
1822
  EarnDeposit: 'Private assets deposited',
1793
1823
  EarnRedeem: 'Private shares redeemed',
@@ -1796,6 +1826,7 @@ const titleByEventName: Record<string, string> = {
1796
1826
  RedeemRequested: 'Share redemption requested',
1797
1827
  Redeemed: 'Shares redeemed',
1798
1828
  VenueSharesDeposited: 'Shares deposited',
1829
+ Withdraw: 'Assets withdrawn',
1799
1830
  WithdrewExact: 'Assets withdrawn',
1800
1831
  ChannelClosed: 'Channel closed',
1801
1832
  ChannelOpened: 'Channel opened',
@@ -1863,6 +1894,33 @@ const accessKeySpendSelector = '0xe0815e3aaadddf4dd75bde97fc060f0c38afe18e87a169
1863
1894
  /** TIP-20 / ERC-20 `Transfer` event signature for the decoded CTE. */
1864
1895
  const transferSignature = 'event Transfer(address indexed from, address indexed to, uint256 value)'
1865
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
+
1866
1924
  // TIP-20 payment-channel reserve precompile. Not exported by `viem/tempo`
1867
1925
  // `Addresses`, so the known address is pinned here: channel events
1868
1926
  // (`ChannelOpened`/`Settled`/`TopUp`/`ChannelClosed`) are emitted here, and
@@ -1870,6 +1928,14 @@ const transferSignature = 'event Transfer(address indexed from, address indexed
1870
1928
  // is how `channel-funded`/`channel-settled`/`channel-closed` recover the token.
1871
1929
  const tip20ChannelReserve = '0x4d50500000000000000000000000000000000000' as Address.Address
1872
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
+
1873
1939
  /** Number of grouped entries to target before stopping the page-fetch loop. */
1874
1940
  const minGroupEntries = 5
1875
1941
 
@@ -1996,7 +2062,12 @@ export function addresses(options: addresses.Options = {}) {
1996
2062
  'Get a human-readable feed of what an address has been doing onchain, including payments sent and received, swaps, approvals, and more.',
1997
2063
  operationId: 'getAddressActivities',
1998
2064
  responses: OpenApi.responses({
1999
- 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
+ },
2000
2071
  success: {
2001
2072
  description: 'A page of address activity entries.',
2002
2073
  schema: schema.getAddressActivities.Response,
@@ -2037,8 +2108,8 @@ export function addresses(options: addresses.Options = {}) {
2037
2108
  const listed = await Timing.time(c, 'address_activities', async () => {
2038
2109
  if (!query.include.includes('zones')) {
2039
2110
  const result = await Store.memoize(
2040
- () =>
2041
- list(tidx({ chainId, tidx: c.get('getTidx')(chainId) }), {
2111
+ (signal) =>
2112
+ list(tidx({ chainId, signal, tidx: c.get('getTidx')(chainId) }), {
2042
2113
  address: address as Hex.Hex,
2043
2114
  cursor: query.cursor,
2044
2115
  group: query.group,
@@ -2057,25 +2128,27 @@ export function addresses(options: addresses.Options = {}) {
2057
2128
 
2058
2129
  const zoneChainIds = c.get('zoneChainIds')
2059
2130
  if (!zoneChainIds) throw new Error('Inferred Zone selection was not resolved.')
2131
+ const chainIds = [chainId, ...zoneChainIds]
2060
2132
  const result = await Store.memoize(
2061
- () =>
2062
- listZones({
2133
+ (signal) =>
2134
+ listChains({
2063
2135
  address: address as Hex.Hex,
2064
2136
  cursor: query.cursor,
2065
2137
  group: query.group,
2066
2138
  limit: query.limit,
2067
2139
  logs: query.logs,
2068
- providers: zoneChainIds.map((zoneChainId) => ({
2069
- chainId: zoneChainId,
2140
+ providers: chainIds.map((chainId) => ({
2141
+ chainId,
2070
2142
  provider: tidx({
2071
- chainId: zoneChainId,
2072
- tidx: c.get('getTidx')(zoneChainId),
2143
+ chainId,
2144
+ signal,
2145
+ tidx: c.get('getTidx')(chainId),
2073
2146
  }),
2074
2147
  })),
2075
2148
  store: c.get('store'),
2076
2149
  }),
2077
2150
  {
2078
- key: `activities:zones:v1:${zoneChainIds.join(',')}:${address.toLowerCase()}:${query.group ?? false}:${query.logs ?? false}:${query.cursor ?? 'head'}:${query.limit}`,
2151
+ key: `activities:zones:v2:${chainIds.join(',')}:${address.toLowerCase()}:${query.group ?? false}:${query.logs ?? false}:${query.cursor ?? 'head'}:${query.limit}`,
2079
2152
  store: c.get('store'),
2080
2153
  ttl: Ttl.seconds(15),
2081
2154
  },
@@ -2085,7 +2158,7 @@ export function addresses(options: addresses.Options = {}) {
2085
2158
 
2086
2159
  const { items, rates } =
2087
2160
  listed.kind === 'zones'
2088
- ? await enrichZoneTokens(c, {
2161
+ ? await enrichChainTokens(c, {
2089
2162
  denomination: query['valuation.currency'],
2090
2163
  include: query.include,
2091
2164
  items: listed.items,
@@ -2135,7 +2208,7 @@ export declare namespace addresses {
2135
2208
  * Runs the same `groupByTx` → `classifyGroup` pipeline the address feed uses
2136
2209
  * over a single transaction's logs, anchored on the transaction sender as the
2137
2210
  * viewer (a transaction-scoped feed has no subject account). Returns the
2138
- * classified item(s) usually one headline activity with the full per-tx
2211
+ * classified items (one per independent activity) with the full per-tx
2139
2212
  * event set available via `logs=true`.
2140
2213
  */
2141
2214
  export function transactions(options: transactions.Options = {}) {
@@ -2157,12 +2230,20 @@ export function transactions(options: transactions.Options = {}) {
2157
2230
  operationId: 'getTransactionActivities',
2158
2231
  responses: OpenApi.responses({
2159
2232
  errors: {
2233
+ 400: {
2234
+ codes: [
2235
+ 'chain_id_invalid',
2236
+ 'chain_id_unsupported',
2237
+ 'query_invalid',
2238
+ 'transaction_invalid',
2239
+ ],
2240
+ },
2160
2241
  404: {
2161
2242
  description: 'No mined transaction was found for that hash.',
2162
2243
  codes: ['transaction_not_found'],
2163
2244
  },
2164
2245
  409: {
2165
- description: 'More than one readable Zone contains that transaction hash.',
2246
+ description: 'More than one readable chain contains that transaction hash.',
2166
2247
  codes: ['transaction_ambiguous'],
2167
2248
  },
2168
2249
  502: 'Tempo could not read activity or token data from an upstream service.',
@@ -2210,13 +2291,14 @@ export function transactions(options: transactions.Options = {}) {
2210
2291
 
2211
2292
  const zoneChainIds = c.get('zoneChainIds')
2212
2293
  if (!zoneChainIds) throw new Error('Inferred Zone selection was not resolved.')
2294
+ const chainIds = [query.chainId ?? c.get('chainId'), ...zoneChainIds]
2213
2295
  // Cache only positive resolution. Missing transactions must remain
2214
- // visible as soon as their Zone indexer catches up.
2296
+ // visible as soon as any selected indexer catches up.
2215
2297
  const match = await Store.memoize(
2216
2298
  async () => {
2217
2299
  const probes: readonly ZoneSelection.uniqueMatch.Probe<TransactionReceipt>[] =
2218
2300
  await Promise.all(
2219
- zoneChainIds.map(async (chainId) => {
2301
+ chainIds.map(async (chainId) => {
2220
2302
  try {
2221
2303
  return {
2222
2304
  chainId,
@@ -2234,7 +2316,7 @@ export function transactions(options: transactions.Options = {}) {
2234
2316
  return ZoneSelection.uniqueMatch<TransactionReceipt>(probes)
2235
2317
  },
2236
2318
  {
2237
- key: `activities:zones:v1:${zoneChainIds.join(',')}:tx:${transactionHash.toLowerCase()}`,
2319
+ key: `activities:zones:v2:${chainIds.join(',')}:tx:${transactionHash.toLowerCase()}`,
2238
2320
  shouldCache: (match) => match.kind === 'selected',
2239
2321
  store: c.get('store'),
2240
2322
  ttl: Ttl.seconds(15),
@@ -2259,11 +2341,12 @@ export function transactions(options: transactions.Options = {}) {
2259
2341
  const unenrichedItems = await Store.memoize(
2260
2342
  () =>
2261
2343
  selected.receipt === null
2262
- ? getTransactionActivities({
2344
+ ? getTransactionActivities_inner({
2263
2345
  chainId,
2264
2346
  logs: query.logs,
2265
2347
  store: c.get('store'),
2266
2348
  tidx: c.get('getTidx')(chainId),
2349
+ time: (name, fn) => Timing.time(c, name, fn),
2267
2350
  transactionHash,
2268
2351
  })
2269
2352
  : classifyTransactionActivities({
@@ -2272,6 +2355,7 @@ export function transactions(options: transactions.Options = {}) {
2272
2355
  receipt: selected.receipt,
2273
2356
  store: c.get('store'),
2274
2357
  tidx: c.get('getTidx')(chainId),
2358
+ time: (name, fn) => Timing.time(c, name, fn),
2275
2359
  transactionHash,
2276
2360
  }),
2277
2361
  {
@@ -2294,7 +2378,7 @@ export function transactions(options: transactions.Options = {}) {
2294
2378
  if (result.kind === 'ambiguous')
2295
2379
  return Response.error(c, {
2296
2380
  code: 'transaction_ambiguous',
2297
- message: 'Transaction hash matched more than one readable Zone',
2381
+ message: 'Transaction hash matched more than one readable chain',
2298
2382
  status: 409,
2299
2383
  })
2300
2384
 
@@ -2349,7 +2433,18 @@ export declare namespace transactions {
2349
2433
  export async function getTransactionActivities(
2350
2434
  options: getTransactionActivities.Options,
2351
2435
  ): Promise<UnenrichedItem[] | null> {
2352
- const receipt = await getTransactionReceipt(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
+ )
2353
2448
  if (!receipt) return null
2354
2449
  return classifyTransactionActivities({ ...options, receipt })
2355
2450
  }
@@ -2398,15 +2493,19 @@ async function classifyTransactionActivities(
2398
2493
  const { chainId, logs = false, receipt, tidx: tidxClient } = options
2399
2494
  const hash = options.transactionHash.toLowerCase()
2400
2495
  const { blockNum, feePayer, txSender } = receipt
2401
- const fingerprintMap_promise = Mpp.fingerprintMap({ store: options.store }).catch(() => ({}))
2496
+ const fingerprintMap_promise = options
2497
+ .time('transaction_activity_mpp', () => Mpp.fingerprintMap({ store: options.store }))
2498
+ .catch(() => ({}))
2402
2499
 
2403
2500
  // `logs` is sorted by block position, so a bare `tx_hash =` scans to genesis;
2404
2501
  // pair it with the receipt's block number (see `embedReceipts` in receipts.ts).
2405
- const logsResult = await tidxClient.fetch({
2406
- chainId,
2407
- query:
2408
- `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,
2409
- })
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
+ )
2410
2509
 
2411
2510
  const rows: Row[] = logsResult.rows.map((r) => ({
2412
2511
  blockNum: Number(Value.toNumber(r['block_num'])),
@@ -2442,7 +2541,7 @@ async function classifyTransactionActivities(
2442
2541
  }
2443
2542
 
2444
2543
  declare namespace classifyTransactionActivities {
2445
- type Options = getTransactionActivities.Options & {
2544
+ type Options = TransactionActivityOptions & {
2446
2545
  /** Indexed receipt fields used to bound the log lookup. */
2447
2546
  receipt: TransactionReceipt
2448
2547
  }
@@ -2464,6 +2563,13 @@ export declare namespace getTransactionActivities {
2464
2563
  }
2465
2564
  }
2466
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
+
2467
2573
  /**
2468
2574
  * Lists classified activity for an address by running the shared pipeline
2469
2575
  * (`groupByTx` → `classifyGroup` → `enrichItem`) over rows from a
@@ -2544,38 +2650,38 @@ export declare namespace list {
2544
2650
  }
2545
2651
  }
2546
2652
 
2547
- const zoneCursorVersion = 1
2653
+ const chainCursorVersion = 2
2548
2654
 
2549
- type ZonePageState = {
2550
- /** Zone chain id backing this page state. */
2655
+ type ChainPageState = {
2656
+ /** Chain ID backing this page state. */
2551
2657
  chainId: number
2552
- /** Whether the current buffer reaches the end of this Zone feed. */
2658
+ /** Whether the current buffer reaches the end of this chain feed. */
2553
2659
  exhausted: boolean
2554
- /** Unconsumed transaction groups already fetched from this Zone. */
2660
+ /** Unconsumed transaction groups already fetched from this chain. */
2555
2661
  groups: TxGroup[]
2556
- /** Activity data source for this Zone. */
2662
+ /** Activity data source for this chain. */
2557
2663
  provider: Provider
2558
2664
  }
2559
2665
 
2560
2666
  /**
2561
- * Lists address activity across multiple Zones in one stable timestamp-ordered
2562
- * feed. Its opaque cursor retains each Zone provider's native continuation.
2667
+ * Lists address activity across multiple chains in one stable timestamp-ordered
2668
+ * feed. Its opaque cursor retains each provider's native continuation.
2563
2669
  */
2564
- export async function listZones(options: listZones.Options): Promise<listZones.ReturnType> {
2670
+ export async function listChains(options: listChains.Options): Promise<listChains.ReturnType> {
2565
2671
  const { address, cursor, limit = 20, logs = false, providers, store } = options
2566
2672
  const group = options.group ?? false
2567
2673
  const orderedProviders = [...providers].sort((a, b) => a.chainId - b.chainId)
2568
2674
  const chainIds = orderedProviders.map(({ chainId }) => chainId)
2569
- const cursors = decodeZoneCursor(cursor, chainIds)
2675
+ const cursors = decodeChainCursor(cursor, chainIds)
2570
2676
  const viewer = address.toLowerCase() as Address.Address
2571
2677
  const fingerprintMap_promise = Mpp.fingerprintMap({ store }).catch(() => ({}))
2572
- const states: ZonePageState[] = orderedProviders.map(({ chainId, provider }) => ({
2678
+ const states: ChainPageState[] = orderedProviders.map(({ chainId, provider }) => ({
2573
2679
  chainId,
2574
2680
  exhausted: false,
2575
2681
  groups: [],
2576
2682
  provider,
2577
2683
  }))
2578
- const allItems: listZones.Item[] = []
2684
+ const allItems: listChains.Item[] = []
2579
2685
  let hasMore = false
2580
2686
 
2581
2687
  for (let attempt = 0; attempt < 3; attempt++) {
@@ -2643,30 +2749,30 @@ export async function listZones(options: listZones.Options): Promise<listZones.R
2643
2749
 
2644
2750
  return {
2645
2751
  items: allItems,
2646
- nextCursor: hasMore ? encodeZoneCursor(cursors, chainIds) : null,
2752
+ nextCursor: hasMore ? encodeChainCursor(cursors, chainIds) : null,
2647
2753
  }
2648
2754
  }
2649
2755
 
2650
- export declare namespace listZones {
2651
- /** One Zone activity provider. */
2756
+ export declare namespace listChains {
2757
+ /** One chain activity provider. */
2652
2758
  type ChainProvider = {
2653
- /** Zone chain id. */
2759
+ /** Chain ID. */
2654
2760
  chainId: number
2655
- /** Activity data source for the Zone. */
2761
+ /** Activity data source for the chain. */
2656
2762
  provider: Provider
2657
2763
  }
2658
2764
 
2659
- /** A classified item tagged with its Zone chain id. */
2765
+ /** A classified item tagged with its chain ID. */
2660
2766
  type Item = UnenrichedItem & {
2661
- /** Zone chain id containing the activity. */
2767
+ /** Chain ID containing the activity. */
2662
2768
  chainId: number
2663
2769
  }
2664
2770
 
2665
- /** Options for {@link listZones}. */
2771
+ /** Options for {@link listChains}. */
2666
2772
  type Options = {
2667
2773
  /** Subject account address. */
2668
2774
  address: Address.Address
2669
- /** Opaque multi-Zone cursor from a previous page. */
2775
+ /** Opaque multi-chain cursor from a previous page. */
2670
2776
  cursor?: string | undefined
2671
2777
  /** Whether the caller will fold access-key items. */
2672
2778
  group?: boolean | undefined
@@ -2674,27 +2780,27 @@ export declare namespace listZones {
2674
2780
  limit?: number | undefined
2675
2781
  /** Attach each item's full transaction event capture. */
2676
2782
  logs?: boolean | undefined
2677
- /** Zone providers participating in the merged feed. */
2783
+ /** Chain providers participating in the merged feed. */
2678
2784
  providers: readonly ChainProvider[]
2679
2785
  /** Cache store for the MPP fingerprint-directory lookup. */
2680
2786
  store: Store.Store
2681
2787
  }
2682
2788
 
2683
- /** Multi-Zone classified page before token enrichment. */
2789
+ /** Multi-chain classified page before token enrichment. */
2684
2790
  type ReturnType = {
2685
2791
  /** Timestamp-ordered, chain-tagged activity items. */
2686
2792
  items: Item[]
2687
- /** Opaque continuation for every Zone, or null at the end. */
2793
+ /** Opaque continuation for every chain, or null at the end. */
2688
2794
  nextCursor: string | null
2689
2795
  }
2690
2796
  }
2691
2797
 
2692
- function decodeZoneCursor(cursor: string | undefined, chainIds: readonly number[]) {
2798
+ function decodeChainCursor(cursor: string | undefined, chainIds: readonly number[]) {
2693
2799
  const fields: Cursor.Field[] = ['int']
2694
2800
  for (const _chainId of chainIds) fields.push('int', 'id')
2695
2801
  const decoded = cursor ? Cursor.decode(cursor, fields) : undefined
2696
2802
  const cursors = new Map<number, string>()
2697
- if (!decoded || decoded[0] !== zoneCursorVersion) return cursors
2803
+ if (!decoded || decoded[0] !== chainCursorVersion) return cursors
2698
2804
 
2699
2805
  for (let index = 0; index < chainIds.length; index++) {
2700
2806
  const chainId = chainIds[index]!
@@ -2706,9 +2812,9 @@ function decodeZoneCursor(cursor: string | undefined, chainIds: readonly number[
2706
2812
  return cursors
2707
2813
  }
2708
2814
 
2709
- function encodeZoneCursor(cursors: ReadonlyMap<number, string>, chainIds: readonly number[]) {
2815
+ function encodeChainCursor(cursors: ReadonlyMap<number, string>, chainIds: readonly number[]) {
2710
2816
  return Cursor.encode([
2711
- zoneCursorVersion,
2817
+ chainCursorVersion,
2712
2818
  ...chainIds.flatMap((chainId) => [chainId, cursors.get(chainId) ?? 'head']),
2713
2819
  ])
2714
2820
  }
@@ -2723,7 +2829,18 @@ function encodeZoneCursor(cursors: ReadonlyMap<number, string>, chainIds: readon
2723
2829
  * (catches access-key-signed sends where `tx.from` differs from the address).
2724
2830
  */
2725
2831
  export function tidx(deps: tidx.Deps): Provider {
2726
- 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
+ }
2727
2844
 
2728
2845
  function parseCursor(cursor: string): { blockNum: number; txIdx: number } | undefined {
2729
2846
  // Opaque `[blockNum, txIdx]` keyset cursor, the same encoding every other
@@ -2749,7 +2866,7 @@ export function tidx(deps: tidx.Deps): Provider {
2749
2866
  : ''
2750
2867
  const queryLimit = limit + 1
2751
2868
  const fetchReceipts = (side: 'fee_payer' | 'from') =>
2752
- tidxClient.fetch({
2869
+ fetch({
2753
2870
  chainId,
2754
2871
  // Raw-table query (no signature-decoded CTE), so the inline SQL is cast
2755
2872
  // to `string`.
@@ -2793,7 +2910,7 @@ export function tidx(deps: tidx.Deps): Provider {
2793
2910
 
2794
2911
  // `logs` is not keyed by transaction hash, so pair the bounded hash list
2795
2912
  // with the selected receipts' block range to avoid a full archive scan.
2796
- const logs = await tidxClient.fetch({
2913
+ const logs = await fetch({
2797
2914
  chainId,
2798
2915
  // Raw-table query (no signature-decoded CTE), so the inline SQL is cast
2799
2916
  // to `string`.
@@ -2853,7 +2970,7 @@ export function tidx(deps: tidx.Deps): Provider {
2853
2970
  `
2854
2971
  const result = await (async () => {
2855
2972
  try {
2856
- return await tidxClient.fetch({
2973
+ return await fetch({
2857
2974
  chainId,
2858
2975
  query: query('block_num'),
2859
2976
  signatures: [transferSignature],
@@ -2862,14 +2979,14 @@ export function tidx(deps: tidx.Deps): Provider {
2862
2979
  if (!Tidx.isDeterministicError(error)) throw error
2863
2980
  try {
2864
2981
  // The expression blocks a mis-selected block-ordered top-N walk.
2865
- return await tidxClient.fetch({
2982
+ return await fetch({
2866
2983
  chainId,
2867
2984
  query: query('block_num + 0'),
2868
2985
  signatures: [transferSignature],
2869
2986
  })
2870
2987
  } catch (fallbackError) {
2871
2988
  if (!Tidx.isDeterministicError(fallbackError)) throw fallbackError
2872
- const result = await tidxClient.fetch({
2989
+ const result = await fetch({
2873
2990
  chainId,
2874
2991
  query: query(),
2875
2992
  signatures: [transferSignature],
@@ -2924,6 +3041,8 @@ export declare namespace tidx {
2924
3041
  type Deps = {
2925
3042
  /** Source chain id. */
2926
3043
  chainId: number
3044
+ /** Optional deadline signal shared by this provider's TIDX reads. */
3045
+ signal?: AbortSignal | undefined
2927
3046
  /** TIDX query client. */
2928
3047
  tidx: Tidx.Client
2929
3048
  }
@@ -2938,8 +3057,10 @@ export function mergeAndSort(q1: readonly Row[], q2: readonly Row[], q3: readonl
2938
3057
  const q1Hashes = new Set(q1.map((r) => r.txHash))
2939
3058
  const filteredQ2 = q2.filter((r) => !q1Hashes.has(r.txHash))
2940
3059
  const filteredQ3 = q3.filter((r) => !q1Hashes.has(r.txHash))
2941
- const q2Hashes = new Set(filteredQ2.map((r) => r.txHash))
2942
- 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}`))
2943
3064
  return [...q1, ...filteredQ2, ...dedupedQ3].sort((a, b) => {
2944
3065
  if (a.blockNum !== b.blockNum) return b.blockNum - a.blockNum
2945
3066
  return b.txIdx - a.txIdx
@@ -3016,34 +3137,38 @@ function classifyGroup(
3016
3137
  viewer,
3017
3138
  })
3018
3139
 
3019
- // Q2-only — a transfer row without a joined receipt. Classify by comparing
3020
- // `from`/`to` against the viewer (zero-address sides are mints/burns).
3021
- if (q2Rows.length > 0) {
3022
- const r = q2Rows[0]!
3023
- const base = itemBase(r.logIdx)
3024
- const from = r.transferFrom!
3025
- const to = r.transferTo!
3026
- const sourceToken = token(r.transferAmount, r.transferToken!)
3027
- if (Address.isEqual(from, zeroAddress as Address.Address))
3028
- return [{ ...base, data: { recipient: viewer, sourceToken }, type: 'mint' }]
3029
- if (Address.isEqual(to, zeroAddress as Address.Address))
3030
- return [{ ...base, data: { sender: from, signer, sourceToken }, type: 'burn' }]
3031
- // Q2-only rows carry no memo, so MPP service attribution is unavailable
3032
- // here; `attribution` is resolved only on the log-bearing (Q1) path.
3033
- if (Address.isEqual(from, viewer))
3034
- return [
3035
- transfer(base, blockNum, {
3036
- direction: 'out',
3037
- recipient: to,
3038
- sender: viewer,
3039
- 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,
3040
3169
  sourceToken,
3041
- }),
3042
- ]
3043
- return [
3044
- transfer(base, blockNum, { direction: 'in', recipient: viewer, sender: from, sourceToken }),
3045
- ]
3046
- }
3170
+ })
3171
+ })
3047
3172
 
3048
3173
  // Fallback — should not happen (a group with neither q1 nor q2 rows).
3049
3174
  return [
@@ -3082,7 +3207,7 @@ function classifyFromLogs(
3082
3207
  })()
3083
3208
  // When the caller requested raw logs, attach the full per-transaction log set
3084
3209
  // (decoded when possible) to every classified item so no co-located log is
3085
- // hidden behind the headline. `unknown` items already carry it.
3210
+ // hidden. `unknown` items already carry it.
3086
3211
  if (!ctx.includeLogs) return items
3087
3212
  const events = buildTxEvents(rows, decoded)
3088
3213
  return items.map((item) => (item.type === 'unknown' ? item : { ...item, events }))
@@ -3106,7 +3231,11 @@ function signerForRows(
3106
3231
  : options.fallback
3107
3232
  }
3108
3233
 
3109
- /** 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
+ */
3110
3239
  function classifyFromLogsCore(
3111
3240
  rows: Row[],
3112
3241
  ctx: {
@@ -3124,97 +3253,196 @@ function classifyFromLogsCore(
3124
3253
 
3125
3254
  const minLogIndex = Math.min(...rows.map((r) => r.logIdx))
3126
3255
 
3127
- // Payment-channel settlement: a CloseRequested topic plus an optional refund
3128
- // transfer back to the viewer.
3129
- const hasCloseRequested = rows.some((r) => r.selector === closeRequestedSelector)
3130
- if (hasCloseRequested) {
3131
- const closeRequestedLogIndex =
3132
- rows.find((r) => r.selector === closeRequestedSelector)?.logIdx ?? minLogIndex
3133
- const refundTransfer = events.find(
3134
- (e) =>
3135
- (e.eventName === 'Transfer' || e.eventName === 'TransferWithMemo') &&
3136
- 'to' in e.args &&
3137
- Address.isEqual((e.args as { to: Address.Address }).to, viewer),
3138
- )
3139
- const refund = refundTransfer
3140
- ? token(
3141
- (refundTransfer.args as { amount: bigint }).amount,
3142
- refundTransfer.address as Address.Address,
3143
- )
3144
- : undefined
3145
- return [
3146
- {
3147
- ...itemBase(closeRequestedLogIndex),
3148
- data: { ...(refund ? { refund } : {}), signer },
3149
- type: 'session-closed',
3150
- },
3151
- ]
3152
- }
3153
-
3154
- // Dedup preference: TransferWithMemo beats Transfer; Mint/Burn beat
3155
- // zero-address Transfer.
3156
- 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)
3157
3262
  for (const event of events) {
3158
3263
  if (event.eventName === 'TransferWithMemo' && 'from' in event.args && 'to' in event.args) {
3159
3264
  const args = event.args as { amount: bigint; from: Address.Address; to: Address.Address }
3160
- preferenceMap.set(`${args.from}:${args.to}:${args.amount}`, 'TransferWithMemo')
3265
+ addFold(`${event.address}:${args.from}:${args.to}:${args.amount}`)
3161
3266
  }
3162
- if (event.eventName === 'Mint' && 'amount' in event.args)
3163
- preferenceMap.set(
3164
- `mint:${event.address}:${(event.args as { amount: bigint }).amount}`,
3165
- 'Mint',
3166
- )
3167
- if (event.eventName === 'Burn' && 'amount' in event.args)
3168
- preferenceMap.set(
3169
- `burn:${event.address}:${(event.args as { amount: bigint }).amount}`,
3170
- 'Burn',
3171
- )
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
3172
3277
  }
3173
3278
 
3174
- const dedupedEvents = events.filter((event) => {
3175
- 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
3176
3282
  const args = event.args as { amount: bigint; from: Address.Address; to: Address.Address }
3177
- if (preferenceMap.has(`${args.from}:${args.to}:${args.amount}`)) return false
3178
- if (
3179
- Address.isEqual(args.from, zeroAddress as Address.Address) &&
3180
- preferenceMap.has(`mint:${event.address}:${args.amount}`)
3181
- )
3182
- return false
3183
3283
  if (
3184
- Address.isEqual(args.to, zeroAddress as Address.Address) &&
3185
- preferenceMap.has(`burn:${event.address}:${args.amount}`)
3284
+ event.eventName === 'Transfer' &&
3285
+ takeFold(`${event.address}:${args.from}:${args.to}:${args.amount}`)
3186
3286
  )
3187
3287
  return false
3188
- return true
3189
- })
3190
-
3191
- const finalEvents = dedupedEvents.filter((event) => {
3192
- if (event.eventName !== 'TransferWithMemo') return true
3193
- const args = event.args as { amount: bigint; from: Address.Address; to: Address.Address }
3194
3288
  if (
3195
3289
  Address.isEqual(args.from, zeroAddress as Address.Address) &&
3196
- preferenceMap.has(`mint:${event.address}:${args.amount}`)
3290
+ takeFold(`mint:${event.address}:${args.amount}`)
3197
3291
  )
3198
3292
  return false
3199
3293
  if (
3200
3294
  Address.isEqual(args.to, zeroAddress as Address.Address) &&
3201
- preferenceMap.has(`burn:${event.address}:${args.amount}`)
3295
+ takeFold(`burn:${event.address}:${args.amount}`)
3202
3296
  )
3203
3297
  return false
3204
3298
  return true
3205
3299
  })
3206
3300
 
3207
- const transfers = finalEvents.filter(
3208
- (e) =>
3209
- (e.eventName === 'Transfer' || e.eventName === 'TransferWithMemo') &&
3210
- 'to' in e.args &&
3211
- 'from' in e.args &&
3212
- '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),
3213
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
+ }
3214
3440
 
3215
- const privateDeposit = finalEvents.find((event) => event.eventName === 'EarnDeposit')
3216
- if (privateDeposit) {
3217
- 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 {
3218
3446
  actionId: Hex.Hex
3219
3447
  earnShares: bigint
3220
3448
  earnVault: Address.Address
@@ -3223,28 +3451,38 @@ function classifyFromLogsCore(
3223
3451
  vaultAssets: bigint
3224
3452
  zoneDepositHash: Hex.Hex
3225
3453
  }
3226
- return [
3227
- {
3228
- ...itemBase(logIndexOfEvent(privateDeposit)),
3229
- data: {
3230
- actionId: args.actionId,
3231
- assets: args.vaultAssets.toString(),
3232
- inputAmount: args.inputAmount.toString(),
3233
- inputToken: args.inputToken.toLowerCase() as Address.Address,
3234
- shares: args.earnShares.toString(),
3235
- signer,
3236
- status: 'completed',
3237
- vault: args.earnVault.toLowerCase() as Address.Address,
3238
- zoneDepositHash: args.zoneDepositHash,
3239
- },
3240
- type: 'private-assets-deposited',
3241
- },
3242
- ]
3243
- }
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
+ })
3244
3483
 
3245
- const privateRedeem = finalEvents.find((event) => event.eventName === 'EarnRedeem')
3246
- if (privateRedeem) {
3247
- const args = privateRedeem.args as {
3484
+ recognize(named('EarnRedeem'), (event) => {
3485
+ const args = event.args as {
3248
3486
  actionId: Hex.Hex
3249
3487
  earnShares: bigint
3250
3488
  earnVault: Address.Address
@@ -3253,782 +3491,1025 @@ function classifyFromLogsCore(
3253
3491
  vaultAssets: bigint
3254
3492
  zoneDepositHash: Hex.Hex
3255
3493
  }
3256
- return [
3257
- {
3258
- ...itemBase(logIndexOfEvent(privateRedeem)),
3259
- data: {
3260
- actionId: args.actionId,
3261
- assets: args.vaultAssets.toString(),
3262
- outputAmount: args.outputAmount.toString(),
3263
- outputToken: args.outputToken.toLowerCase() as Address.Address,
3264
- shares: args.earnShares.toString(),
3265
- signer,
3266
- status: 'completed',
3267
- vault: args.earnVault.toLowerCase() as Address.Address,
3268
- zoneDepositHash: args.zoneDepositHash,
3269
- },
3270
- type: 'private-shares-redeemed',
3271
- },
3272
- ]
3273
- }
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
+ })
3274
3528
 
3275
- const deposited = finalEvents.find((event) => event.eventName === 'Deposited')
3276
- if (deposited) {
3277
- 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 {
3278
3532
  assets: bigint
3279
3533
  caller: Address.Address
3280
3534
  earnShares: bigint
3281
3535
  receiver: Address.Address
3282
3536
  }
3283
- return [
3284
- {
3285
- ...itemBase(logIndexOfEvent(deposited)),
3286
- data: {
3287
- assets: args.assets.toString(),
3288
- caller: args.caller.toLowerCase() as Address.Address,
3289
- receiver: args.receiver.toLowerCase() as Address.Address,
3290
- shares: args.earnShares.toString(),
3291
- signer,
3292
- status: 'completed',
3293
- vault: deposited.address.toLowerCase() as Address.Address,
3294
- },
3295
- 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),
3296
3548
  },
3297
- ]
3298
- }
3549
+ type: 'assets-deposited',
3550
+ }
3551
+ })
3299
3552
 
3300
- const venueDeposit = finalEvents.find((event) => event.eventName === 'VenueSharesDeposited')
3301
- if (venueDeposit) {
3302
- const args = venueDeposit.args as {
3553
+ recognize(named('VenueSharesDeposited'), (event) => {
3554
+ const args = event.args as {
3303
3555
  caller: Address.Address
3304
3556
  earnShares: bigint
3305
3557
  receivedEngineShares: bigint
3306
3558
  receiver: Address.Address
3307
3559
  requestedVenueShares: bigint
3308
3560
  }
3309
- return [
3310
- {
3311
- ...itemBase(logIndexOfEvent(venueDeposit)),
3312
- data: {
3313
- caller: args.caller.toLowerCase() as Address.Address,
3314
- receivedEngineShares: args.receivedEngineShares.toString(),
3315
- receiver: args.receiver.toLowerCase() as Address.Address,
3316
- requestedVenueShares: args.requestedVenueShares.toString(),
3317
- shares: args.earnShares.toString(),
3318
- signer,
3319
- status: 'completed',
3320
- vault: venueDeposit.address.toLowerCase() as Address.Address,
3321
- },
3322
- 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),
3323
3577
  },
3324
- ]
3325
- }
3578
+ type: 'shares-deposited',
3579
+ }
3580
+ })
3326
3581
 
3327
- const redeemed = finalEvents.find((event) => event.eventName === 'Redeemed')
3328
- if (redeemed) {
3329
- const args = redeemed.args as {
3582
+ recognize(named('Redeemed'), (event) => {
3583
+ const args = event.args as {
3330
3584
  assets: bigint
3331
3585
  caller: Address.Address
3332
3586
  earnShares: bigint
3333
3587
  receiver: Address.Address
3334
3588
  }
3335
- return [
3336
- {
3337
- ...itemBase(logIndexOfEvent(redeemed)),
3338
- data: {
3339
- assets: args.assets.toString(),
3340
- caller: args.caller.toLowerCase() as Address.Address,
3341
- receiver: args.receiver.toLowerCase() as Address.Address,
3342
- shares: args.earnShares.toString(),
3343
- signer,
3344
- status: 'completed',
3345
- vault: redeemed.address.toLowerCase() as Address.Address,
3346
- },
3347
- 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),
3348
3600
  },
3349
- ]
3350
- }
3601
+ type: 'shares-redeemed',
3602
+ }
3603
+ })
3351
3604
 
3352
- const withdrew = finalEvents.find((event) => event.eventName === 'WithdrewExact')
3353
- if (withdrew) {
3354
- const args = withdrew.args as {
3605
+ recognize(named('WithdrewExact'), (event) => {
3606
+ const args = event.args as {
3355
3607
  assets: bigint
3356
3608
  caller: Address.Address
3357
3609
  earnSharesBurned: bigint
3358
3610
  receiver: Address.Address
3359
3611
  }
3360
- return [
3361
- {
3362
- ...itemBase(logIndexOfEvent(withdrew)),
3363
- data: {
3364
- assets: args.assets.toString(),
3365
- caller: args.caller.toLowerCase() as Address.Address,
3366
- sharesBurned: args.earnSharesBurned.toString(),
3367
- receiver: args.receiver.toLowerCase() as Address.Address,
3368
- signer,
3369
- status: 'completed',
3370
- vault: withdrew.address.toLowerCase() as Address.Address,
3371
- },
3372
- 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),
3373
3623
  },
3374
- ]
3375
- }
3624
+ type: 'assets-withdrawn',
3625
+ }
3626
+ })
3376
3627
 
3377
- const requested = finalEvents.find((event) => event.eventName === 'RedeemRequested')
3378
- if (requested) {
3379
- const args = requested.args as {
3628
+ recognize(named('RedeemRequested'), (event) => {
3629
+ const args = event.args as {
3380
3630
  earnShares: bigint
3381
3631
  receiver: Address.Address
3382
3632
  requestId: Hex.Hex
3383
3633
  requester: Address.Address
3384
3634
  }
3385
- return [
3386
- {
3387
- ...itemBase(logIndexOfEvent(requested)),
3388
- data: {
3389
- receiver: args.receiver.toLowerCase() as Address.Address,
3390
- requestId: args.requestId,
3391
- requester: args.requester.toLowerCase() as Address.Address,
3392
- shares: args.earnShares.toString(),
3393
- signer,
3394
- status: 'pending',
3395
- vault: requested.address.toLowerCase() as Address.Address,
3396
- },
3397
- 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),
3398
3646
  },
3399
- ]
3400
- }
3647
+ type: 'shares-redemption-requested',
3648
+ }
3649
+ })
3401
3650
 
3402
- const finalized = finalEvents.find((event) => event.eventName === 'RedeemFinalized')
3403
- if (finalized) {
3404
- const args = finalized.args as {
3651
+ recognize(named('RedeemFinalized'), (event) => {
3652
+ const args = event.args as {
3405
3653
  asset: Address.Address
3406
3654
  assets: bigint
3407
3655
  earnShares: bigint
3408
3656
  receiver: Address.Address
3409
3657
  requestId: Hex.Hex
3410
3658
  }
3411
- return [
3412
- {
3413
- ...itemBase(logIndexOfEvent(finalized)),
3414
- data: {
3415
- asset: args.asset.toLowerCase() as Address.Address,
3416
- assets: args.assets.toString(),
3417
- receiver: args.receiver.toLowerCase() as Address.Address,
3418
- requestId: args.requestId,
3419
- shares: args.earnShares.toString(),
3420
- signer,
3421
- status: 'completed',
3422
- vault: finalized.address.toLowerCase() as Address.Address,
3423
- },
3424
- 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),
3425
3671
  },
3426
- ]
3427
- }
3672
+ type: 'shares-redemption-finalized',
3673
+ }
3674
+ })
3428
3675
 
3429
- const cancelled = finalEvents.find((event) => event.eventName === 'RedeemCancelled')
3430
- if (cancelled) {
3431
- const args = cancelled.args as {
3676
+ recognize(named('RedeemCancelled'), (event) => {
3677
+ const args = event.args as {
3432
3678
  earnShares: bigint
3433
3679
  receiver: Address.Address
3434
3680
  requestId: Hex.Hex
3435
3681
  }
3436
- return [
3437
- {
3438
- ...itemBase(logIndexOfEvent(cancelled)),
3439
- data: {
3440
- receiver: args.receiver.toLowerCase() as Address.Address,
3441
- requestId: args.requestId,
3442
- shares: args.earnShares.toString(),
3443
- signer,
3444
- status: 'cancelled',
3445
- vault: cancelled.address.toLowerCase() as Address.Address,
3446
- },
3447
- 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),
3448
3692
  },
3449
- ]
3450
- }
3693
+ type: 'shares-redemption-cancelled',
3694
+ }
3695
+ })
3451
3696
 
3452
- // Swap: a transfer into the DEX paired with one out of the DEX.
3453
- const toDex = transfers.find((e) =>
3454
- Address.isEqual((e.args as { to: Address.Address }).to, Addresses.stablecoinDex),
3455
- )
3456
- const fromDex = transfers.find((e) =>
3457
- Address.isEqual((e.args as { from: Address.Address }).from, Addresses.stablecoinDex),
3458
- )
3459
- if (toDex && fromDex) {
3460
- const swapper = (fromDex.args as { to: Address.Address }).to
3461
- const destTokenAddress = fromDex.address as Address.Address
3462
- const sourceToken = token(
3463
- (toDex.args as { amount: bigint }).amount,
3464
- 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)),
3465
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
+ })
3466
3735
 
3467
- // Cross-token transfer: the swapped-out token is forwarded from the swapper
3468
- // to a different recipient in the same transaction (the account server's
3469
- // approve+swap+transfer multicall). Fold the swap and its delivery leg into
3470
- // one `transfer` carrying both the source token spent into the DEX and the
3471
- // destination token delivered to the recipient. A swap whose output stays
3472
- // with the swapper (no onward leg) stays a plain `swap` below.
3473
- const delivery = transfers.find(
3474
- (e) =>
3475
- Address.isEqual(e.address, destTokenAddress) &&
3476
- Address.isEqual((e.args as { from: Address.Address }).from, swapper) &&
3477
- !Address.isEqual((e.args as { to: Address.Address }).to, Addresses.stablecoinDex) &&
3478
- !Address.isEqual((e.args as { to: Address.Address }).to, swapper),
3479
- )
3480
- if (delivery) {
3481
- const args = delivery.args as {
3482
- amount: bigint
3483
- from: Address.Address
3484
- memo?: Hex.Hex | undefined
3485
- to: Address.Address
3486
- }
3487
- const rawMemo = delivery.eventName === 'TransferWithMemo' ? args.memo : undefined
3488
- const memo = rawMemo ? Transfers.memoToString(rawMemo) : undefined
3489
- const attribution = Mpp.resolve({ fingerprintMap, memo: rawMemo })
3490
- return [
3491
- transfer(itemBase(logIndexOfEvent(delivery)), blockNum, {
3492
- attribution,
3493
- destinationToken: token(args.amount, delivery.address as Address.Address),
3494
- direction: Address.isEqual(args.to, viewer) ? 'in' : 'out',
3495
- memo,
3496
- recipient: args.to.toLowerCase() as Address.Address,
3497
- sender: swapper.toLowerCase() as Address.Address,
3498
- signer,
3499
- sourceToken,
3500
- }),
3501
- ]
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
3502
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),
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
+ })
3503
3760
 
3504
- return [
3505
- {
3506
- ...itemBase(Math.min(logIndexOfEvent(toDex), logIndexOfEvent(fromDex))),
3507
- data: {
3508
- destinationToken: token((fromDex.args as { amount: bigint }).amount, destTokenAddress),
3509
- signer,
3510
- sourceToken,
3511
- },
3512
- 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),
3513
3791
  },
3514
- ]
3792
+ type: 'order-placed',
3793
+ }
3515
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
+ }
3516
3890
 
3517
- // Mint: explicit `Mint` event (not the fee manager) or a transfer from zero.
3518
- const mintEvent = finalEvents.find(
3519
- (e) =>
3520
- e.eventName === 'Mint' &&
3521
- 'amount' in e.args &&
3522
- !Address.isEqual(e.address, Addresses.feeManager),
3523
- )
3524
- if (mintEvent) {
3525
- const args = mintEvent.args as { amount: bigint; to: Address.Address }
3526
- return [
3527
- {
3528
- ...itemBase(logIndexOfEvent(mintEvent)),
3529
- data: {
3530
- recipient: args.to.toLowerCase() as Address.Address,
3531
- sourceToken: token(args.amount, mintEvent.address as Address.Address),
3532
- },
3533
- type: 'mint',
3534
- },
3535
- ]
3536
- }
3537
- const zeroTransferIn = transfers.find((e) =>
3538
- Address.isEqual((e.args as { from: Address.Address }).from, zeroAddress as Address.Address),
3539
- )
3540
- if (zeroTransferIn) {
3541
- const args = zeroTransferIn.args as { amount: bigint; to: Address.Address }
3542
- return [
3543
- {
3544
- ...itemBase(logIndexOfEvent(zeroTransferIn)),
3545
- data: {
3546
- recipient: (args.to as string).toLowerCase() as Address.Address,
3547
- sourceToken: token(args.amount, zeroTransferIn.address as Address.Address),
3548
- },
3549
- type: 'mint',
3550
- },
3551
- ]
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
+ }
3552
4004
  }
3553
4005
 
3554
- // Burn blocked: a compliance burn of receive-policy-blocked funds. It also
3555
- // emits a zero-address `Transfer` that would otherwise classify as `burn`, so
3556
- // detect the distinct `BurnBlocked` event first.
3557
- const burnBlockedEvent = finalEvents.find((e) => e.eventName === 'BurnBlocked')
3558
- if (burnBlockedEvent) {
3559
- const args = burnBlockedEvent.args as { amount: bigint; from: Address.Address }
3560
- return [
3561
- {
3562
- ...itemBase(logIndexOfEvent(burnBlockedEvent)),
3563
- data: {
3564
- sender: args.from.toLowerCase() as Address.Address,
3565
- signer,
3566
- sourceToken: token(args.amount, burnBlockedEvent.address as Address.Address),
3567
- },
3568
- type: 'burn-blocked',
3569
- },
3570
- ]
3571
- }
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)),
4029
+ )
3572
4030
 
3573
- // Burn: explicit `Burn` event or a transfer to zero.
3574
- const burnEvent = finalEvents.find((e) => e.eventName === 'Burn' && 'amount' in e.args)
3575
- if (burnEvent) {
3576
- const args = burnEvent.args as { amount: bigint; from: Address.Address }
3577
- return [
3578
- {
3579
- ...itemBase(logIndexOfEvent(burnEvent)),
3580
- data: {
3581
- sender: args.from.toLowerCase() as Address.Address,
3582
- signer,
3583
- sourceToken: token(args.amount, burnEvent.address as Address.Address),
3584
- },
3585
- type: 'burn',
4031
+ // Burn blocked: a compliance burn of receive-policy-blocked funds. It also
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),
3586
4048
  },
3587
- ]
3588
- }
3589
- const zeroTransferOut = transfers.find((e) =>
3590
- Address.isEqual((e.args as { to: Address.Address }).to, zeroAddress as Address.Address),
4049
+ type: 'burn-blocked',
4050
+ }
4051
+ })
4052
+
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)),
3591
4071
  )
3592
- if (zeroTransferOut) {
3593
- const args = zeroTransferOut.args as { amount: bigint; from: Address.Address }
3594
- return [
3595
- {
3596
- ...itemBase(logIndexOfEvent(zeroTransferOut)),
3597
- data: {
3598
- sender: (args.from as string).toLowerCase() as Address.Address,
3599
- signer,
3600
- sourceToken: token(args.amount, zeroTransferOut.address as Address.Address),
3601
- },
3602
- type: 'burn',
3603
- },
3604
- ]
3605
- }
3606
4072
 
3607
- // Approval (excluding zone-outbox spenders).
3608
- const approvalEvent = finalEvents.find(
3609
- (e) =>
3610
- e.eventName === 'Approval' &&
3611
- // ERC-721 uses the same selector but indexes its token ID as topic3. In
3612
- // 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 (
3613
4082
  rows.some(
3614
4083
  (row) =>
3615
- row.logIdx === e.logIndex &&
4084
+ row.logIdx === event.logIndex &&
3616
4085
  row.topic3 === null &&
3617
4086
  /^0x[0-9a-fA-F]{64}$/.test(row.data ?? ''),
3618
- ) &&
3619
- !Address.isEqual((e.args as { spender: Address.Address }).spender, Addresses.zoneOutbox),
3620
- )
3621
- if (approvalEvent) {
3622
- const args = approvalEvent.args as { amount: bigint; spender: Address.Address }
3623
- return [
3624
- {
3625
- ...itemBase(logIndexOfEvent(approvalEvent)),
3626
- data: {
3627
- signer,
3628
- sourceToken: token(args.amount, approvalEvent.address as Address.Address),
3629
- spender: args.spender.toLowerCase() as Address.Address,
3630
- },
3631
- 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),
3632
4098
  },
3633
- ]
4099
+ type: 'approval',
4100
+ }
3634
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
+ )
3635
4110
 
3636
- // Precompile-event activities. These run before the generic transfer
3637
- // fallback because each is emitted alongside a co-located TIP-20 `Transfer`
3638
- // whose counterparty is the precompile (the DEX, the channel reserve, the fee
3639
- // manager, the token itself), which would otherwise classify as a plain
3640
- // transfer. They are grouped by (transaction + precompile counterparty), not
3641
- // by log adjacency.
3642
-
3643
- // Payment channel opened: `ChannelOpened` carries the deposit token/amount
3644
- // directly, so no paired-transfer lookup is needed.
3645
- const channelOpened = finalEvents.find((e) => e.eventName === 'ChannelOpened')
3646
- if (channelOpened) {
3647
- 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 {
3648
4116
  channelId: Hex.Hex
3649
4117
  deposit: bigint
3650
4118
  payee: Address.Address
3651
4119
  payer: Address.Address
3652
4120
  token: Address.Address
3653
4121
  }
3654
- return [
3655
- {
3656
- ...itemBase(logIndexOfEvent(channelOpened)),
3657
- data: {
3658
- channelId: args.channelId.toLowerCase() as Hex.Hex,
3659
- payee: args.payee.toLowerCase() as Address.Address,
3660
- payer: args.payer.toLowerCase() as Address.Address,
3661
- signer,
3662
- sourceToken: token(args.deposit, args.token),
3663
- },
3664
- 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),
3665
4136
  },
3666
- ]
3667
- }
4137
+ type: 'channel-opened',
4138
+ }
4139
+ })
3668
4140
 
3669
- // The channel reserve doesn't put the token on `TopUp`/`Settled`/
3670
- // `ChannelClosed`, so recover it from the co-located funding/payout transfer.
3671
- const reserveInflow = transfers.find((e) =>
3672
- Address.isEqual((e.args as { to: Address.Address }).to, tip20ChannelReserve),
3673
- )
3674
- const reserveOutflowTo = (recipient: Address.Address) =>
3675
- 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(
3676
4152
  (e) =>
3677
- Address.isEqual((e.args as { from: Address.Address }).from, tip20ChannelReserve) &&
3678
- 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,
3679
4156
  )
3680
-
3681
- // Payment channel topped up: `additionalDeposit` from the event, token from
3682
- // the inbound funding transfer to the reserve.
3683
- const topUp = finalEvents.find((e) => e.eventName === 'TopUp')
3684
- if (topUp) {
3685
- const args = topUp.args as { additionalDeposit: bigint; channelId: Hex.Hex }
3686
- const tokenAddress = reserveInflow
3687
- ? (reserveInflow.address as Address.Address)
4157
+ .slice(0, 1)
4158
+ .forEach(claim)
4159
+ const tokenAddress = inflow
4160
+ ? (inflow.address as Address.Address)
3688
4161
  : (zeroAddress as Address.Address)
3689
- return [
3690
- {
3691
- ...itemBase(logIndexOfEvent(topUp)),
3692
- data: {
3693
- channelId: args.channelId.toLowerCase() as Hex.Hex,
3694
- signer,
3695
- sourceToken: token(args.additionalDeposit, tokenAddress),
3696
- },
3697
- type: 'channel-funded',
4162
+ return {
4163
+ ...itemBase(event.logIndex),
4164
+ data: {
4165
+ channelId: hx(args.channelId),
4166
+ signer,
4167
+ sourceToken: token(args.additionalDeposit, tokenAddress),
3698
4168
  },
3699
- ]
3700
- }
4169
+ type: 'channel-funded',
4170
+ }
4171
+ })
3701
4172
 
3702
- // Payment channel settled: `deltaPaid` from the event, token from the payout
3703
- // transfer out of the reserve to the payee.
3704
- const settled = finalEvents.find((e) => e.eventName === 'Settled')
3705
- if (settled) {
3706
- const args = settled.args as {
4173
+ recognize(named('Settled'), (event) => {
4174
+ const args = event.args as {
3707
4175
  channelId: Hex.Hex
3708
4176
  deltaPaid: bigint
3709
4177
  payee: Address.Address
3710
4178
  payer: Address.Address
3711
4179
  }
3712
- 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)
3713
4188
  const tokenAddress = payeeLeg
3714
4189
  ? (payeeLeg.address as Address.Address)
3715
4190
  : (zeroAddress as Address.Address)
3716
- return [
3717
- {
3718
- ...itemBase(logIndexOfEvent(settled)),
3719
- data: {
3720
- channelId: args.channelId.toLowerCase() as Hex.Hex,
3721
- payee: args.payee.toLowerCase() as Address.Address,
3722
- payer: args.payer.toLowerCase() as Address.Address,
3723
- signer,
3724
- sourceToken: token(args.deltaPaid, tokenAddress),
3725
- },
3726
- 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),
3727
4199
  },
3728
- ]
3729
- }
4200
+ type: 'channel-settled',
4201
+ }
4202
+ })
3730
4203
 
3731
- // Payment channel closed: payout to the payee and refund to the payer arrive
3732
- // as up to two transfers out of the reserve; attach whichever are present.
3733
- const channelClosed = finalEvents.find((e) => e.eventName === 'ChannelClosed')
3734
- if (channelClosed) {
3735
- const args = channelClosed.args as {
4204
+ recognize(named('ChannelClosed'), (event) => {
4205
+ const args = event.args as {
3736
4206
  channelId: Hex.Hex
3737
4207
  payee: Address.Address
3738
4208
  payer: Address.Address
3739
4209
  refundedToPayer: bigint
3740
4210
  settledToPayee: bigint
3741
4211
  }
3742
- const payeeLeg = reserveOutflowTo(args.payee.toLowerCase() as Address.Address)
3743
- const payerLeg = reserveOutflowTo(args.payer.toLowerCase() as Address.Address)
3744
- return [
3745
- {
3746
- ...itemBase(logIndexOfEvent(channelClosed)),
3747
- data: {
3748
- channelId: args.channelId.toLowerCase() as Hex.Hex,
3749
- payee: args.payee.toLowerCase() as Address.Address,
3750
- payer: args.payer.toLowerCase() as Address.Address,
3751
- ...(payerLeg && args.refundedToPayer > 0n
3752
- ? { refund: token(args.refundedToPayer, payerLeg.address as Address.Address) }
3753
- : {}),
3754
- signer,
3755
- ...(payeeLeg
3756
- ? { sourceToken: token(args.settledToPayee, payeeLeg.address as Address.Address) }
3757
- : {}),
3758
- },
3759
- 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
+ : {}),
3760
4239
  },
3761
- ]
3762
- }
4240
+ type: 'channel-closed',
4241
+ }
4242
+ })
3763
4243
 
3764
- // Payment channel close cancelled: the payer revoked a pending close request.
3765
- // When bundled with a top-up the `TopUp` classifier above wins; on its own
3766
- // this carries no transfer, so the event itself is the signal.
3767
- const closeCancelled = finalEvents.find((e) => e.eventName === 'CloseRequestCancelled')
3768
- if (closeCancelled) {
3769
- 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 {
3770
4248
  channelId: Hex.Hex
3771
4249
  payee: Address.Address
3772
4250
  payer: Address.Address
3773
4251
  }
3774
- return [
3775
- {
3776
- ...itemBase(logIndexOfEvent(closeCancelled)),
3777
- data: {
3778
- channelId: args.channelId.toLowerCase() as Hex.Hex,
3779
- payee: args.payee.toLowerCase() as Address.Address,
3780
- payer: args.payer.toLowerCase() as Address.Address,
3781
- signer,
3782
- },
3783
- 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,
3784
4259
  },
3785
- ]
3786
- }
3787
-
3788
- // DEX order placed: `OrderPlaced` carries the escrow token/amount and side.
3789
- // (Auto-flip re-places are inside swaps, which already returned above.)
3790
- const orderPlaced = finalEvents.find((e) => e.eventName === 'OrderPlaced')
3791
- if (orderPlaced) {
3792
- const args = orderPlaced.args as {
3793
- amount: bigint
3794
- isBid: boolean
3795
- orderId: bigint
3796
- tick: number
3797
- token: Address.Address
4260
+ type: 'channel-close-cancelled',
3798
4261
  }
3799
- return [
3800
- {
3801
- ...itemBase(logIndexOfEvent(orderPlaced)),
3802
- data: {
3803
- orderId: args.orderId.toString(),
3804
- side: args.isBid ? 'bid' : 'ask',
3805
- signer,
3806
- sourceToken: token(args.amount, args.token),
3807
- tick: Number(args.tick),
3808
- },
3809
- type: 'order-placed',
3810
- },
3811
- ]
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
+ })
3812
4284
  }
3813
4285
 
4286
+ // Auto-flip order placements not folded into a swap above.
4287
+ recognize(named('OrderPlaced'), orderPlacedItem)
4288
+
3814
4289
  // DEX order cancelled: refund is credited to the internal DEX balance, so
3815
- // there is no co-located transfer the `OrderCancelled` event is the signal.
3816
- const orderCancelled = finalEvents.find((e) => e.eventName === 'OrderCancelled')
3817
- if (orderCancelled) {
3818
- const args = orderCancelled.args as { orderId: bigint }
3819
- return [
3820
- {
3821
- ...itemBase(logIndexOfEvent(orderCancelled)),
3822
- data: { orderId: args.orderId.toString(), signer },
3823
- type: 'order-cancelled',
3824
- },
3825
- ]
3826
- }
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
+ })
3827
4299
 
3828
- // Fee-AMM rebalance swap: `validatorToken` in, `userToken` out.
3829
- const rebalanceSwap = finalEvents.find((e) => e.eventName === 'RebalanceSwap')
3830
- if (rebalanceSwap) {
3831
- 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 {
3832
4304
  amountIn: bigint
3833
4305
  amountOut: bigint
3834
4306
  swapper: Address.Address
3835
4307
  userToken: Address.Address
3836
4308
  validatorToken: Address.Address
3837
4309
  }
3838
- return [
3839
- {
3840
- ...itemBase(logIndexOfEvent(rebalanceSwap)),
3841
- data: {
3842
- destinationToken: token(args.amountOut, args.userToken),
3843
- signer,
3844
- sourceToken: token(args.amountIn, args.validatorToken),
3845
- swapper: args.swapper.toLowerCase() as Address.Address,
3846
- },
3847
- 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),
3848
4331
  },
3849
- ]
3850
- }
4332
+ type: 'fee-rebalance-swap',
4333
+ }
4334
+ })
3851
4335
 
3852
- // Validator fees distributed.
3853
- const feesDistributed = finalEvents.find((e) => e.eventName === 'FeesDistributed')
3854
- if (feesDistributed) {
3855
- 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 {
3856
4339
  amount: bigint
3857
4340
  token: Address.Address
3858
4341
  validator: Address.Address
3859
4342
  }
3860
- return [
3861
- {
3862
- ...itemBase(logIndexOfEvent(feesDistributed)),
3863
- data: {
3864
- signer,
3865
- sourceToken: token(args.amount, args.token),
3866
- validator: args.validator.toLowerCase() as Address.Address,
3867
- },
3868
- 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),
3869
4356
  },
3870
- ]
3871
- }
4357
+ type: 'fees-distributed',
4358
+ }
4359
+ })
3872
4360
 
3873
- // Reward pool funded: the funded token is the emitting token contract itself.
3874
- const rewardDistributed = finalEvents.find((e) => e.eventName === 'RewardDistributed')
3875
- if (rewardDistributed) {
3876
- const args = rewardDistributed.args as { amount: bigint; funder: Address.Address }
3877
- return [
3878
- {
3879
- ...itemBase(logIndexOfEvent(rewardDistributed)),
3880
- data: {
3881
- funder: args.funder.toLowerCase() as Address.Address,
3882
- signer,
3883
- sourceToken: token(args.amount, rewardDistributed.address as Address.Address),
3884
- },
3885
- 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),
3886
4378
  },
3887
- ]
3888
- }
4379
+ type: 'reward-distributed',
4380
+ }
4381
+ })
3889
4382
 
3890
4383
  // Reward recipient redirected.
3891
- const rewardRecipientSet = finalEvents.find((e) => e.eventName === 'RewardRecipientSet')
3892
- if (rewardRecipientSet) {
3893
- const args = rewardRecipientSet.args as { holder: Address.Address; recipient: Address.Address }
3894
- return [
3895
- {
3896
- ...itemBase(logIndexOfEvent(rewardRecipientSet)),
3897
- data: {
3898
- holder: args.holder.toLowerCase() as Address.Address,
3899
- recipient: args.recipient.toLowerCase() as Address.Address,
3900
- signer,
3901
- },
3902
- 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,
3903
4392
  },
3904
- ]
3905
- }
4393
+ type: 'reward-recipient-set',
4394
+ }
4395
+ })
3906
4396
 
3907
4397
  // Access-key spending limit updated: `newLimit` is carried as the token
3908
4398
  // amount so it enriches with the token's metadata.
3909
- const spendingLimitUpdated = finalEvents.find((e) => e.eventName === 'SpendingLimitUpdated')
3910
- if (spendingLimitUpdated) {
3911
- const args = spendingLimitUpdated.args as {
4399
+ recognize(named('SpendingLimitUpdated'), (event) => {
4400
+ const args = event.args as {
3912
4401
  account: Address.Address
3913
4402
  newLimit: bigint
3914
4403
  publicKey: Address.Address
3915
4404
  token: Address.Address
3916
4405
  }
3917
- return [
3918
- {
3919
- ...itemBase(logIndexOfEvent(spendingLimitUpdated)),
3920
- data: {
3921
- account: args.account.toLowerCase() as Address.Address,
3922
- publicKey: args.publicKey.toLowerCase() as Address.Address,
3923
- signer,
3924
- sourceToken: token(args.newLimit, args.token),
3925
- },
3926
- 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),
3927
4413
  },
3928
- ]
3929
- }
4414
+ type: 'spending-limit-updated',
4415
+ }
4416
+ })
3930
4417
 
3931
- // Access-key lifecycle events — emitted alongside any co-located transfer.
3932
- const accessKeyItems: RawItem[] = []
3933
- for (const event of finalEvents) {
3934
- if (
4418
+ // Access-key lifecycle events.
4419
+ recognize(
4420
+ (event) =>
3935
4421
  event.eventName === 'KeyAuthorized' &&
3936
- Address.isEqual(event.address, Addresses.accountKeychain)
3937
- ) {
4422
+ Address.isEqual(event.address, Addresses.accountKeychain),
4423
+ (event) => {
3938
4424
  const args = event.args as {
3939
4425
  account: Address.Address
3940
4426
  expiry: bigint
3941
4427
  publicKey: Address.Address
3942
4428
  signatureType: number
3943
4429
  }
3944
- accessKeyItems.push({
3945
- ...itemBase(logIndexOfEvent(event)),
4430
+ return {
4431
+ ...itemBase(event.logIndex),
3946
4432
  data: {
3947
- account: args.account.toLowerCase() as Address.Address,
4433
+ account: lc(args.account),
3948
4434
  expiry: Number(args.expiry),
3949
- publicKey: args.publicKey.toLowerCase() as Address.Address,
4435
+ publicKey: lc(args.publicKey),
3950
4436
  signatureType: Number(args.signatureType),
3951
4437
  },
3952
4438
  type: 'access-key-created',
3953
- })
3954
- } else if (
3955
- event.eventName === 'KeyRevoked' &&
3956
- Address.isEqual(event.address, Addresses.accountKeychain)
3957
- ) {
4439
+ }
4440
+ },
4441
+ )
4442
+ recognize(
4443
+ (event) =>
4444
+ event.eventName === 'KeyRevoked' && Address.isEqual(event.address, Addresses.accountKeychain),
4445
+ (event) => {
3958
4446
  const args = event.args as { account: Address.Address; publicKey: Address.Address }
3959
- accessKeyItems.push({
3960
- ...itemBase(logIndexOfEvent(event)),
4447
+ return {
4448
+ ...itemBase(event.logIndex),
3961
4449
  data: {
3962
- account: args.account.toLowerCase() as Address.Address,
3963
- publicKey: args.publicKey.toLowerCase() as Address.Address,
4450
+ account: lc(args.account),
4451
+ publicKey: lc(args.publicKey),
3964
4452
  },
3965
4453
  type: 'access-key-revoked',
3966
- })
3967
- }
3968
- }
3969
-
3970
- // Token creation.
3971
- const tokenCreated = finalEvents.find(
3972
- (e) => e.eventName === 'TokenCreated' && Address.isEqual(e.address, Addresses.tip20Factory),
4454
+ }
4455
+ },
3973
4456
  )
3974
- if (tokenCreated) {
3975
- const args = tokenCreated.args as {
3976
- currency: string
3977
- name: string
3978
- symbol: string
3979
- token: Address.Address
3980
- }
3981
- return [
3982
- {
3983
- ...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),
3984
4476
  data: {
3985
- address: args.token.toLowerCase() as Address.Address,
4477
+ address: lc(args.token),
3986
4478
  currency: args.currency,
3987
4479
  name: args.name,
3988
4480
  symbol: args.symbol,
3989
4481
  },
3990
4482
  type: 'token-created',
3991
- },
3992
- ...accessKeyItems,
3993
- ]
3994
- }
4483
+ }
4484
+ },
4485
+ )
3995
4486
 
3996
4487
  // Governance / admin / DEX-bookkeeping events. Each maps a single decoded
3997
- // event to a typed item. Checked after token creation (a token-creation tx
3998
- // also emits `RoleMembershipUpdated`) and before the plain-transfer fallback;
3999
- // these carry no token movement, so the event itself is the whole signal.
4000
- const lc = (value: Address.Address) => value.toLowerCase() as Address.Address
4001
- const hx = (value: Hex.Hex) => value.toLowerCase() as Hex.Hex
4002
- const findEvent = (name: string) => finalEvents.find((e) => e.eventName === name)
4003
-
4004
- // DEX bookkeeping (taker swaps and `OrderPlaced` already returned above).
4005
- const orderFilled = findEvent('OrderFilled')
4006
- if (orderFilled) {
4007
- 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 {
4008
4492
  amountFilled: bigint
4009
4493
  maker: Address.Address
4010
4494
  orderId: bigint
4011
4495
  partialFill: boolean
4012
4496
  taker: Address.Address
4013
4497
  }
4014
- return [
4015
- {
4016
- ...itemBase(logIndexOfEvent(orderFilled)),
4017
- data: {
4018
- amountFilled: a.amountFilled.toString(),
4019
- maker: lc(a.maker),
4020
- orderId: a.orderId.toString(),
4021
- partialFill: a.partialFill,
4022
- signer,
4023
- taker: lc(a.taker),
4024
- },
4025
- 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),
4026
4507
  },
4027
- ]
4028
- }
4029
- const orderFlipped = findEvent('OrderFlipped')
4030
- if (orderFlipped) {
4031
- const a = orderFlipped.args as {
4508
+ type: 'order-filled',
4509
+ }
4510
+ })
4511
+ recognize(named('OrderFlipped'), (event) => {
4512
+ const a = event.args as {
4032
4513
  amount: bigint
4033
4514
  flipTick: number
4034
4515
  isBid: boolean
@@ -4037,342 +4518,299 @@ function classifyFromLogsCore(
4037
4518
  tick: number
4038
4519
  token: Address.Address
4039
4520
  }
4040
- return [
4041
- {
4042
- ...itemBase(logIndexOfEvent(orderFlipped)),
4043
- data: {
4044
- flipTick: Number(a.flipTick),
4045
- maker: lc(a.maker),
4046
- orderId: a.orderId.toString(),
4047
- side: a.isBid ? 'bid' : 'ask',
4048
- signer,
4049
- sourceToken: token(a.amount, a.token),
4050
- tick: Number(a.tick),
4051
- },
4052
- type: 'order-flipped',
4053
- },
4054
- ]
4055
- }
4056
- const pairCreated = findEvent('PairCreated')
4057
- if (pairCreated) {
4058
- const a = pairCreated.args as { base: Address.Address; key: Hex.Hex; quote: Address.Address }
4059
- return [
4060
- {
4061
- ...itemBase(logIndexOfEvent(pairCreated)),
4062
- data: { base: lc(a.base), key: hx(a.key), quote: lc(a.quote), signer },
4063
- 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),
4064
4531
  },
4065
- ]
4066
- }
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
+ })
4067
4543
 
4068
4544
  // TIP-20 token configuration / admin.
4069
- const pauseStateUpdate = findEvent('PauseStateUpdate')
4070
- if (pauseStateUpdate) {
4071
- const a = pauseStateUpdate.args as { isPaused: boolean; updater: Address.Address }
4072
- return [
4073
- {
4074
- ...itemBase(logIndexOfEvent(pauseStateUpdate)),
4075
- data: { paused: a.isPaused, signer, updater: lc(a.updater) },
4076
- type: 'token-pause-set',
4077
- },
4078
- ]
4079
- }
4080
- const supplyCapUpdate = findEvent('SupplyCapUpdate')
4081
- if (supplyCapUpdate) {
4082
- const a = supplyCapUpdate.args as { newSupplyCap: bigint; updater: Address.Address }
4083
- return [
4084
- {
4085
- ...itemBase(logIndexOfEvent(supplyCapUpdate)),
4086
- data: { signer, supplyCap: a.newSupplyCap.toString(), updater: lc(a.updater) },
4087
- type: 'token-supply-cap-set',
4088
- },
4089
- ]
4090
- }
4091
- const transferPolicyUpdate = findEvent('TransferPolicyUpdate')
4092
- if (transferPolicyUpdate) {
4093
- const a = transferPolicyUpdate.args as { newPolicyId: bigint; updater: Address.Address }
4094
- return [
4095
- {
4096
- ...itemBase(logIndexOfEvent(transferPolicyUpdate)),
4097
- data: { policyId: a.newPolicyId.toString(), signer, updater: lc(a.updater) },
4098
- type: 'token-transfer-policy-set',
4099
- },
4100
- ]
4101
- }
4102
- const quoteTokenUpdate = findEvent('QuoteTokenUpdate')
4103
- if (quoteTokenUpdate) {
4104
- const a = quoteTokenUpdate.args as { newQuoteToken: Address.Address; updater: Address.Address }
4105
- return [
4106
- {
4107
- ...itemBase(logIndexOfEvent(quoteTokenUpdate)),
4108
- data: { quoteToken: lc(a.newQuoteToken), signer, updater: lc(a.updater) },
4109
- type: 'token-quote-token-set',
4110
- },
4111
- ]
4112
- }
4113
- const nextQuoteTokenSet = findEvent('NextQuoteTokenSet')
4114
- if (nextQuoteTokenSet) {
4115
- const a = nextQuoteTokenSet.args as {
4116
- nextQuoteToken: Address.Address
4117
- 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',
4118
4551
  }
4119
- return [
4120
- {
4121
- ...itemBase(logIndexOfEvent(nextQuoteTokenSet)),
4122
- data: { nextQuoteToken: lc(a.nextQuoteToken), signer, updater: lc(a.updater) },
4123
- type: 'token-next-quote-token-set',
4124
- },
4125
- ]
4126
- }
4127
- const logoUriUpdated = findEvent('LogoURIUpdated')
4128
- if (logoUriUpdated) {
4129
- const a = logoUriUpdated.args as { newLogoURI: string; updater: Address.Address }
4130
- return [
4131
- {
4132
- ...itemBase(logIndexOfEvent(logoUriUpdated)),
4133
- data: { logoUri: a.newLogoURI, signer, updater: lc(a.updater) },
4134
- type: 'token-logo-set',
4135
- },
4136
- ]
4137
- }
4138
- const roleMembershipUpdated = findEvent('RoleMembershipUpdated')
4139
- if (roleMembershipUpdated) {
4140
- 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 {
4141
4595
  account: Address.Address
4142
4596
  hasRole: boolean
4143
4597
  role: Hex.Hex
4144
4598
  sender: Address.Address
4145
4599
  }
4146
- return [
4147
- {
4148
- ...itemBase(logIndexOfEvent(roleMembershipUpdated)),
4149
- data: {
4150
- account: lc(a.account),
4151
- granted: a.hasRole,
4152
- role: hx(a.role),
4153
- sender: lc(a.sender),
4154
- signer,
4155
- },
4156
- 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,
4157
4608
  },
4158
- ]
4159
- }
4160
- const roleAdminUpdated = findEvent('RoleAdminUpdated')
4161
- if (roleAdminUpdated) {
4162
- const a = roleAdminUpdated.args as {
4609
+ type: 'role-membership-set',
4610
+ }
4611
+ })
4612
+ recognize(named('RoleAdminUpdated'), (event) => {
4613
+ const a = event.args as {
4163
4614
  newAdminRole: Hex.Hex
4164
4615
  role: Hex.Hex
4165
4616
  sender: Address.Address
4166
4617
  }
4167
- return [
4168
- {
4169
- ...itemBase(logIndexOfEvent(roleAdminUpdated)),
4170
- data: {
4171
- newAdminRole: hx(a.newAdminRole),
4172
- role: hx(a.role),
4173
- sender: lc(a.sender),
4174
- signer,
4175
- },
4176
- 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,
4177
4625
  },
4178
- ]
4179
- }
4626
+ type: 'role-admin-set',
4627
+ }
4628
+ })
4180
4629
 
4181
4630
  // Fee-manager token configuration.
4182
- const userTokenSet = findEvent('UserTokenSet')
4183
- if (userTokenSet) {
4184
- const a = userTokenSet.args as { token: Address.Address; user: Address.Address }
4185
- return [
4186
- {
4187
- ...itemBase(logIndexOfEvent(userTokenSet)),
4188
- data: { signer, token: lc(a.token), user: lc(a.user) },
4189
- type: 'fee-user-token-set',
4190
- },
4191
- ]
4192
- }
4193
- const validatorTokenSet = findEvent('ValidatorTokenSet')
4194
- if (validatorTokenSet) {
4195
- const a = validatorTokenSet.args as { token: Address.Address; validator: Address.Address }
4196
- return [
4197
- {
4198
- ...itemBase(logIndexOfEvent(validatorTokenSet)),
4199
- data: { signer, token: lc(a.token), validator: lc(a.validator) },
4200
- type: 'fee-validator-token-set',
4201
- },
4202
- ]
4203
- }
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
+ })
4204
4647
 
4205
- // TIP-403 policy registry (create wins over the member events it bundles).
4206
- const compoundPolicyCreated = findEvent('CompoundPolicyCreated')
4207
- if (compoundPolicyCreated) {
4208
- 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 {
4209
4652
  creator: Address.Address
4210
4653
  mintRecipientPolicyId: bigint
4211
4654
  policyId: bigint
4212
4655
  recipientPolicyId: bigint
4213
4656
  senderPolicyId: bigint
4214
4657
  }
4215
- return [
4216
- {
4217
- ...itemBase(logIndexOfEvent(compoundPolicyCreated)),
4218
- data: {
4219
- creator: lc(a.creator),
4220
- mintRecipientPolicyId: a.mintRecipientPolicyId.toString(),
4221
- policyId: a.policyId.toString(),
4222
- recipientPolicyId: a.recipientPolicyId.toString(),
4223
- senderPolicyId: a.senderPolicyId.toString(),
4224
- signer,
4225
- },
4226
- 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,
4227
4680
  },
4228
- ]
4229
- }
4230
- const policyCreated = findEvent('PolicyCreated')
4231
- if (policyCreated) {
4232
- const a = policyCreated.args as {
4681
+ type: 'compound-policy-created',
4682
+ }
4683
+ })
4684
+ recognize(named('PolicyCreated'), (event) => {
4685
+ const a = event.args as {
4233
4686
  policyId: bigint
4234
4687
  policyType: number
4235
4688
  updater: Address.Address
4236
4689
  }
4237
- return [
4238
- {
4239
- ...itemBase(logIndexOfEvent(policyCreated)),
4240
- data: {
4241
- policyId: a.policyId.toString(),
4242
- policyType: Number(a.policyType),
4243
- signer,
4244
- updater: lc(a.updater),
4245
- },
4246
- 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),
4247
4705
  },
4248
- ]
4249
- }
4250
- const policyAdminUpdated = findEvent('PolicyAdminUpdated')
4251
- if (policyAdminUpdated) {
4252
- const a = policyAdminUpdated.args as {
4706
+ type: 'policy-created',
4707
+ }
4708
+ })
4709
+ recognize(named('PolicyAdminUpdated'), (event) => {
4710
+ const a = event.args as {
4253
4711
  admin: Address.Address
4254
4712
  policyId: bigint
4255
4713
  updater: Address.Address
4256
4714
  }
4257
- return [
4258
- {
4259
- ...itemBase(logIndexOfEvent(policyAdminUpdated)),
4260
- data: {
4261
- admin: lc(a.admin),
4262
- policyId: a.policyId.toString(),
4263
- signer,
4264
- updater: lc(a.updater),
4265
- },
4266
- 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),
4267
4722
  },
4268
- ]
4269
- }
4270
- const whitelistUpdated = findEvent('WhitelistUpdated')
4271
- if (whitelistUpdated) {
4272
- const a = whitelistUpdated.args as {
4723
+ type: 'policy-admin-set',
4724
+ }
4725
+ })
4726
+ recognize(named('WhitelistUpdated'), (event) => {
4727
+ const a = event.args as {
4273
4728
  account: Address.Address
4274
4729
  allowed: boolean
4275
4730
  policyId: bigint
4276
4731
  updater: Address.Address
4277
4732
  }
4278
- return [
4279
- {
4280
- ...itemBase(logIndexOfEvent(whitelistUpdated)),
4281
- data: {
4282
- account: lc(a.account),
4283
- allowed: a.allowed,
4284
- policyId: a.policyId.toString(),
4285
- signer,
4286
- updater: lc(a.updater),
4287
- },
4288
- 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),
4289
4741
  },
4290
- ]
4291
- }
4292
- const blacklistUpdated = findEvent('BlacklistUpdated')
4293
- if (blacklistUpdated) {
4294
- const a = blacklistUpdated.args as {
4742
+ type: 'whitelist-updated',
4743
+ }
4744
+ })
4745
+ recognize(named('BlacklistUpdated'), (event) => {
4746
+ const a = event.args as {
4295
4747
  account: Address.Address
4296
4748
  policyId: bigint
4297
4749
  restricted: boolean
4298
4750
  updater: Address.Address
4299
4751
  }
4300
- return [
4301
- {
4302
- ...itemBase(logIndexOfEvent(blacklistUpdated)),
4303
- data: {
4304
- account: lc(a.account),
4305
- policyId: a.policyId.toString(),
4306
- restricted: a.restricted,
4307
- signer,
4308
- updater: lc(a.updater),
4309
- },
4310
- 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),
4311
4760
  },
4312
- ]
4313
- }
4761
+ type: 'blacklist-updated',
4762
+ }
4763
+ })
4314
4764
 
4315
4765
  // Address / nonce registry.
4316
- const masterRegistered = findEvent('MasterRegistered')
4317
- if (masterRegistered) {
4318
- const a = masterRegistered.args as { masterAddress: Address.Address; masterId: Hex.Hex }
4319
- return [
4320
- {
4321
- ...itemBase(logIndexOfEvent(masterRegistered)),
4322
- data: { masterAddress: lc(a.masterAddress), masterId: hx(a.masterId), signer },
4323
- type: 'master-registered',
4324
- },
4325
- ]
4326
- }
4327
- const nonceIncremented = findEvent('NonceIncremented')
4328
- if (nonceIncremented) {
4329
- 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 {
4330
4776
  account: Address.Address
4331
4777
  newNonce: bigint
4332
4778
  nonceKey: bigint
4333
4779
  }
4334
- return [
4335
- {
4336
- ...itemBase(logIndexOfEvent(nonceIncremented)),
4337
- data: {
4338
- account: lc(a.account),
4339
- nonce: a.newNonce.toString(),
4340
- nonceKey: a.nonceKey.toString(),
4341
- signer,
4342
- },
4343
- 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,
4344
4787
  },
4345
- ]
4346
- }
4788
+ type: 'nonce-incremented',
4789
+ }
4790
+ })
4347
4791
 
4348
4792
  // Access-key authorization witnesses.
4349
- const witnessRecorded = findEvent('KeyAuthorizationWitness')
4350
- if (witnessRecorded) {
4351
- const a = witnessRecorded.args as { account: Address.Address; witness: Hex.Hex }
4352
- return [
4353
- {
4354
- ...itemBase(logIndexOfEvent(witnessRecorded)),
4355
- data: { account: lc(a.account), signer, witness: hx(a.witness) },
4356
- type: 'key-authorization-witness',
4357
- },
4358
- ]
4359
- }
4360
- const witnessBurned = findEvent('KeyAuthorizationWitnessBurned')
4361
- if (witnessBurned) {
4362
- const a = witnessBurned.args as { account: Address.Address; witness: Hex.Hex }
4363
- return [
4364
- {
4365
- ...itemBase(logIndexOfEvent(witnessBurned)),
4366
- data: { account: lc(a.account), signer, witness: hx(a.witness) },
4367
- type: 'key-authorization-witness-burned',
4368
- },
4369
- ]
4370
- }
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
+ })
4371
4809
 
4372
- // Validator configuration (rotation supersedes the add/deactivate it bundles).
4373
- const validatorRotated = findEvent('ValidatorRotated')
4374
- if (validatorRotated) {
4375
- 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 {
4376
4814
  caller: Address.Address
4377
4815
  deactivatedIndex: bigint
4378
4816
  egress: string
@@ -4382,27 +4820,32 @@ function classifyFromLogsCore(
4382
4820
  oldPublicKey: Hex.Hex
4383
4821
  validatorAddress: Address.Address
4384
4822
  }
4385
- return [
4386
- {
4387
- ...itemBase(logIndexOfEvent(validatorRotated)),
4388
- data: {
4389
- caller: lc(a.caller),
4390
- deactivatedIndex: a.deactivatedIndex.toString(),
4391
- egress: a.egress,
4392
- index: a.index.toString(),
4393
- ingress: a.ingress,
4394
- newPublicKey: hx(a.newPublicKey),
4395
- oldPublicKey: hx(a.oldPublicKey),
4396
- signer,
4397
- validator: lc(a.validatorAddress),
4398
- },
4399
- 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),
4400
4843
  },
4401
- ]
4402
- }
4403
- const validatorAdded = findEvent('ValidatorAdded')
4404
- if (validatorAdded) {
4405
- const a = validatorAdded.args as {
4844
+ type: 'validator-rotated',
4845
+ }
4846
+ })
4847
+ recognize(named('ValidatorAdded'), (event) => {
4848
+ const a = event.args as {
4406
4849
  egress: string
4407
4850
  feeRecipient: Address.Address
4408
4851
  index: bigint
@@ -4410,236 +4853,207 @@ function classifyFromLogsCore(
4410
4853
  publicKey: Hex.Hex
4411
4854
  validatorAddress: Address.Address
4412
4855
  }
4413
- return [
4414
- {
4415
- ...itemBase(logIndexOfEvent(validatorAdded)),
4416
- data: {
4417
- egress: a.egress,
4418
- feeRecipient: lc(a.feeRecipient),
4419
- index: a.index.toString(),
4420
- ingress: a.ingress,
4421
- publicKey: hx(a.publicKey),
4422
- signer,
4423
- validator: lc(a.validatorAddress),
4424
- },
4425
- type: 'validator-added',
4426
- },
4427
- ]
4428
- }
4429
- const validatorDeactivated = findEvent('ValidatorDeactivated')
4430
- if (validatorDeactivated) {
4431
- const a = validatorDeactivated.args as { index: bigint; validatorAddress: Address.Address }
4432
- return [
4433
- {
4434
- ...itemBase(logIndexOfEvent(validatorDeactivated)),
4435
- data: { index: a.index.toString(), signer, validator: lc(a.validatorAddress) },
4436
- 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),
4437
4866
  },
4438
- ]
4439
- }
4440
- const validatorMigrated = findEvent('ValidatorMigrated')
4441
- if (validatorMigrated) {
4442
- 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 {
4443
4880
  index: bigint
4444
4881
  publicKey: Hex.Hex
4445
4882
  validatorAddress: Address.Address
4446
4883
  }
4447
- return [
4448
- {
4449
- ...itemBase(logIndexOfEvent(validatorMigrated)),
4450
- data: {
4451
- index: a.index.toString(),
4452
- publicKey: hx(a.publicKey),
4453
- signer,
4454
- validator: lc(a.validatorAddress),
4455
- },
4456
- 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),
4457
4891
  },
4458
- ]
4459
- }
4460
- const validatorMigrationSkipped = findEvent('SkippedValidatorMigration')
4461
- if (validatorMigrationSkipped) {
4462
- const a = validatorMigrationSkipped.args as {
4892
+ type: 'validator-migrated',
4893
+ }
4894
+ })
4895
+ recognize(named('SkippedValidatorMigration'), (event) => {
4896
+ const a = event.args as {
4463
4897
  index: bigint
4464
4898
  publicKey: Hex.Hex
4465
4899
  validatorAddress: Address.Address
4466
4900
  }
4467
- return [
4468
- {
4469
- ...itemBase(logIndexOfEvent(validatorMigrationSkipped)),
4470
- data: {
4471
- index: a.index.toString(),
4472
- publicKey: hx(a.publicKey),
4473
- signer,
4474
- validator: lc(a.validatorAddress),
4475
- },
4476
- 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),
4477
4908
  },
4478
- ]
4479
- }
4480
- const feeRecipientUpdated = findEvent('FeeRecipientUpdated')
4481
- if (feeRecipientUpdated) {
4482
- const a = feeRecipientUpdated.args as {
4909
+ type: 'validator-migration-skipped',
4910
+ }
4911
+ })
4912
+ recognize(named('FeeRecipientUpdated'), (event) => {
4913
+ const a = event.args as {
4483
4914
  caller: Address.Address
4484
4915
  feeRecipient: Address.Address
4485
4916
  index: bigint
4486
4917
  }
4487
- return [
4488
- {
4489
- ...itemBase(logIndexOfEvent(feeRecipientUpdated)),
4490
- data: {
4491
- caller: lc(a.caller),
4492
- feeRecipient: lc(a.feeRecipient),
4493
- index: a.index.toString(),
4494
- signer,
4495
- },
4496
- 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,
4497
4925
  },
4498
- ]
4499
- }
4500
- const ipAddressesUpdated = findEvent('IpAddressesUpdated')
4501
- if (ipAddressesUpdated) {
4502
- const a = ipAddressesUpdated.args as {
4926
+ type: 'validator-fee-recipient-set',
4927
+ }
4928
+ })
4929
+ recognize(named('IpAddressesUpdated'), (event) => {
4930
+ const a = event.args as {
4503
4931
  caller: Address.Address
4504
4932
  egress: string
4505
4933
  index: bigint
4506
4934
  ingress: string
4507
4935
  }
4508
- return [
4509
- {
4510
- ...itemBase(logIndexOfEvent(ipAddressesUpdated)),
4511
- data: {
4512
- caller: lc(a.caller),
4513
- egress: a.egress,
4514
- index: a.index.toString(),
4515
- ingress: a.ingress,
4516
- signer,
4517
- },
4518
- 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,
4519
4944
  },
4520
- ]
4521
- }
4522
- const validatorOwnershipTransferred = findEvent('ValidatorOwnershipTransferred')
4523
- if (validatorOwnershipTransferred) {
4524
- const a = validatorOwnershipTransferred.args as {
4945
+ type: 'validator-ip-set',
4946
+ }
4947
+ })
4948
+ recognize(named('ValidatorOwnershipTransferred'), (event) => {
4949
+ const a = event.args as {
4525
4950
  caller: Address.Address
4526
4951
  index: bigint
4527
4952
  newAddress: Address.Address
4528
4953
  oldAddress: Address.Address
4529
4954
  }
4530
- return [
4531
- {
4532
- ...itemBase(logIndexOfEvent(validatorOwnershipTransferred)),
4533
- data: {
4534
- caller: lc(a.caller),
4535
- index: a.index.toString(),
4536
- newAddress: lc(a.newAddress),
4537
- oldAddress: lc(a.oldAddress),
4538
- signer,
4539
- },
4540
- 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,
4541
4963
  },
4542
- ]
4543
- }
4544
- const ownershipTransferred = findEvent('OwnershipTransferred')
4545
- if (ownershipTransferred) {
4546
- const a = ownershipTransferred.args as {
4964
+ type: 'validator-ownership-transferred',
4965
+ }
4966
+ })
4967
+ recognize(named('OwnershipTransferred'), (event) => {
4968
+ const a = event.args as {
4547
4969
  newOwner: Address.Address
4548
4970
  oldOwner: Address.Address
4549
4971
  }
4550
- return [
4551
- {
4552
- ...itemBase(logIndexOfEvent(ownershipTransferred)),
4553
- data: { newOwner: lc(a.newOwner), oldOwner: lc(a.oldOwner), signer },
4554
- type: 'ownership-transferred',
4555
- },
4556
- ]
4557
- }
4558
- const rotationEpochSet = findEvent('NetworkIdentityRotationEpochSet')
4559
- if (rotationEpochSet) {
4560
- const a = rotationEpochSet.args as { nextEpoch: bigint; previousEpoch: bigint }
4561
- return [
4562
- {
4563
- ...itemBase(logIndexOfEvent(rotationEpochSet)),
4564
- data: {
4565
- nextEpoch: a.nextEpoch.toString(),
4566
- previousEpoch: a.previousEpoch.toString(),
4567
- signer,
4568
- },
4569
- type: 'network-identity-rotation-epoch-set',
4570
- },
4571
- ]
4572
- }
4573
- const initialized = findEvent('Initialized')
4574
- if (initialized) {
4575
- const a = initialized.args as { height: bigint }
4576
- return [
4577
- {
4578
- ...itemBase(logIndexOfEvent(initialized)),
4579
- data: { height: a.height.toString(), signer },
4580
- 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,
4581
4986
  },
4582
- ]
4583
- }
4584
-
4585
- // Default: plain transfer (excluding transfers to the fee manager).
4586
- const userTransfers = transfers.filter(
4587
- (e) => !Address.isEqual((e.args as { to: Address.Address }).to, Addresses.feeManager),
4588
- )
4589
- if (userTransfers.length > 0) {
4590
- const t = userTransfers[0]!
4591
- const args = t.args as {
4592
- amount: bigint
4593
- from: Address.Address
4594
- memo?: Hex.Hex | undefined
4595
- 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',
4596
4996
  }
4597
- const to = args.to.toLowerCase() as Address.Address
4598
- const from = args.from.toLowerCase() as Address.Address
4599
- 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
4600
5007
  // The raw memo (not the suppressed display string) carries the MPP
4601
5008
  // attribution fingerprint, so resolve the service name from it.
4602
5009
  const memo = rawMemo ? Transfers.memoToString(rawMemo) : undefined
4603
5010
  const attribution = Mpp.resolve({ fingerprintMap, memo: rawMemo })
4604
- const sourceToken = token(args.amount, t.address as Address.Address)
5011
+ const sourceToken = token(args.amount, event.address as Address.Address)
4605
5012
  if (Address.isEqual(to, viewer))
4606
- return [
4607
- transfer(itemBase(logIndexOfEvent(t)), blockNum, {
4608
- attribution,
4609
- direction: 'in',
4610
- memo,
4611
- recipient: viewer,
4612
- sender: from,
4613
- sourceToken,
4614
- }),
4615
- ...accessKeyItems,
4616
- ]
4617
- return [
4618
- transfer(itemBase(logIndexOfEvent(t)), blockNum, {
5013
+ return transfer(itemBase(event.logIndex), blockNum, {
4619
5014
  attribution,
4620
- direction: 'out',
5015
+ direction: 'in',
4621
5016
  memo,
4622
- recipient: to,
5017
+ recipient: viewer,
4623
5018
  sender: from,
4624
- signer,
4625
5019
  sourceToken,
4626
- }),
4627
- ...accessKeyItems,
4628
- ]
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
+ })
4629
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
+ )
4630
5040
 
4631
- // Pure access-key tx (no transfer / mint / burn / etc.).
4632
- 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
+ }
4633
5049
 
4634
- // Unclassified: a tx the address sent or paid for whose logs matched no known
4635
- // classifier. Surface it honestly as an `unknown` item carrying every log
4636
- // (decoded when possible), rather than fabricating a zero transfer.
4637
- return [unknown(itemBase(minLogIndex), { events: buildTxEvents(rows, events), signer })]
4638
- }
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 })]
4639
5055
 
4640
- /** Extracts the block-wide log index from a decoded viem event. */
4641
- function logIndexOfEvent(event: { logIndex: number }): number {
4642
- return event.logIndex
5056
+ return items.sort((a, b) => a.logIndex - b.logIndex)
4643
5057
  }
4644
5058
 
4645
5059
  /**
@@ -4703,8 +5117,8 @@ function unknown(
4703
5117
 
4704
5118
  /** Decodes a group's raw rows into `viem/tempo` events via `parseEventLogs` (non-strict). */
4705
5119
  function decodeRows(rows: readonly Row[]) {
4706
- return rows.flatMap((r) => {
4707
- const log: Log = {
5120
+ const logs = rows.map(
5121
+ (r): Log => ({
4708
5122
  address: r.logAddress as `0x${string}`,
4709
5123
  blockHash: '0x0' as Hex.Hex,
4710
5124
  blockNumber: BigInt(r.blockNum),
@@ -4714,17 +5128,21 @@ function decodeRows(rows: readonly Row[]) {
4714
5128
  topics: [r.selector, r.topic1, r.topic2, r.topic3].filter(Boolean) as [Hex.Hex, ...Hex.Hex[]],
4715
5129
  transactionHash: r.txHash as `0x${string}`,
4716
5130
  transactionIndex: r.txIdx,
4717
- }
4718
- try {
4719
- return parseEventLogs({
4720
- abi: [...Abis.abis, ...Abis.earnRouter, ...Abis.earnVault],
4721
- logs: [log],
4722
- strict: false,
4723
- })
4724
- } catch {
4725
- return []
4726
- }
4727
- })
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
+ }
4728
5146
  }
4729
5147
 
4730
5148
  /**
@@ -4937,12 +5355,12 @@ declare namespace enrichTokens {
4937
5355
  }
4938
5356
  }
4939
5357
 
4940
- /** Resolves token metadata on each item's source Zone without changing feed order. */
4941
- async function enrichZoneTokens(
5358
+ /** Resolves token metadata on each item's source chain without changing feed order. */
5359
+ async function enrichChainTokens(
4942
5360
  c: Context<App.Environment>,
4943
- options: enrichZoneTokens.Options,
5361
+ options: enrichChainTokens.Options,
4944
5362
  ): Promise<{ items: Item[]; rates: FxOracle.RateSet | undefined }> {
4945
- const byChain = new Map<number, listZones.Item[]>()
5363
+ const byChain = new Map<number, listChains.Item[]>()
4946
5364
  for (const item of options.items) {
4947
5365
  const items = byChain.get(item.chainId) ?? []
4948
5366
  items.push(item)
@@ -4972,14 +5390,14 @@ async function enrichZoneTokens(
4972
5390
  }
4973
5391
  }
4974
5392
 
4975
- declare namespace enrichZoneTokens {
5393
+ declare namespace enrichChainTokens {
4976
5394
  type Options = {
4977
5395
  /** Currency the amount valuations are denominated in. */
4978
5396
  denomination?: string | undefined
4979
5397
  /** Optional token fields to resolve. */
4980
5398
  include: readonly z.output<typeof schema.Include>[]
4981
5399
  /** Chain-tagged items awaiting token enrichment. */
4982
- items: readonly listZones.Item[]
5400
+ items: readonly listChains.Item[]
4983
5401
  /** FX rate oracle backing amount valuation. */
4984
5402
  oracle: FxOracle.Oracle
4985
5403
  }