tapimo 0.10.2 → 0.11.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 (135) hide show
  1. package/dist/App.d.ts +3412 -1968
  2. package/dist/App.d.ts.map +1 -1
  3. package/dist/App.js.map +1 -1
  4. package/dist/Webhooks.d.ts +0 -75
  5. package/dist/Webhooks.d.ts.map +1 -1
  6. package/dist/Webhooks.js +1 -143
  7. package/dist/Webhooks.js.map +1 -1
  8. package/dist/admin/App.d.ts +74 -74
  9. package/dist/admin/apps/api-keys.d.ts +4 -4
  10. package/dist/admin/apps/earn-vaults.d.ts +1 -1
  11. package/dist/admin/apps/organizations.d.ts +3 -3
  12. package/dist/admin/apps/verified-tokens.d.ts +74 -74
  13. package/dist/apps/data/App.d.ts +3618 -2954
  14. package/dist/apps/data/App.d.ts.map +1 -1
  15. package/dist/apps/data/App.js +5 -2
  16. package/dist/apps/data/App.js.map +1 -1
  17. package/dist/apps/data/routes/activities.d.ts +1 -1
  18. package/dist/apps/data/routes/activities.d.ts.map +1 -1
  19. package/dist/apps/data/routes/activities.js +55 -27
  20. package/dist/apps/data/routes/activities.js.map +1 -1
  21. package/dist/apps/data/routes/addresses.d.ts +343 -343
  22. package/dist/apps/data/routes/balances.d.ts +11 -11
  23. package/dist/apps/data/routes/blocks.d.ts +5 -5
  24. package/dist/apps/data/routes/coingecko.d.ts +176 -176
  25. package/dist/apps/data/routes/earn.d.ts +4 -4
  26. package/dist/apps/data/routes/exchanges.d.ts +4 -4
  27. package/dist/apps/data/routes/fee-amm.d.ts +26 -26
  28. package/dist/apps/data/routes/indexer.d.ts +34 -34
  29. package/dist/apps/data/routes/receipts.d.ts +236 -236
  30. package/dist/apps/data/routes/tokenlist.d.ts +12 -12
  31. package/dist/apps/data/routes/tokens.d.ts +3 -3
  32. package/dist/apps/data/routes/transactions.d.ts +1 -1
  33. package/dist/apps/data/routes/transfers.d.ts +13 -13
  34. package/dist/apps/data/routes/valuation.d.ts +21 -21
  35. package/dist/apps/data/routes/verified-tokens.d.ts +5 -5
  36. package/dist/apps/data/routes/webhooks.d.ts +1011 -151
  37. package/dist/apps/data/routes/webhooks.d.ts.map +1 -1
  38. package/dist/apps/data/routes/webhooks.js +124 -737
  39. package/dist/apps/data/routes/webhooks.js.map +1 -1
  40. package/dist/apps/data/routes/zones.d.ts +31 -31
  41. package/dist/apps/funding/routes/deposit-addresses.d.ts +2 -2
  42. package/dist/apps/funding/routes/providers.d.ts +13 -13
  43. package/dist/apps/funding/routes/transfers.d.ts +33 -33
  44. package/dist/apps/management/App.d.ts +793 -16
  45. package/dist/apps/management/App.d.ts.map +1 -1
  46. package/dist/apps/management/routes/api-keys.d.ts +50 -50
  47. package/dist/apps/management/routes/faucet.d.ts +31 -31
  48. package/dist/apps/management/routes/invite-links.d.ts +42 -42
  49. package/dist/apps/management/routes/members.d.ts +79 -79
  50. package/dist/apps/management/routes/projects.d.ts +40 -40
  51. package/dist/apps/management/routes/usage.d.ts +80 -80
  52. package/dist/apps/management/routes/webhooks.d.ts +1031 -59
  53. package/dist/apps/management/routes/webhooks.d.ts.map +1 -1
  54. package/dist/apps/management/routes/webhooks.js +27 -4
  55. package/dist/apps/management/routes/webhooks.js.map +1 -1
  56. package/dist/db/Db.d.ts.map +1 -1
  57. package/dist/db/Db.js +21 -0
  58. package/dist/db/Db.js.map +1 -1
  59. package/dist/db/Schema.d.ts +7 -1
  60. package/dist/db/Schema.d.ts.map +1 -1
  61. package/dist/db/Schema.js +9 -0
  62. package/dist/db/Schema.js.map +1 -1
  63. package/dist/db/tables/webhookQueueEvents.d.ts +3 -1
  64. package/dist/db/tables/webhookQueueEvents.d.ts.map +1 -1
  65. package/dist/db/tables/webhookQueueEvents.js +4 -0
  66. package/dist/db/tables/webhookQueueEvents.js.map +1 -1
  67. package/dist/db/tables/webhookSubscriptions.d.ts +33 -69
  68. package/dist/db/tables/webhookSubscriptions.d.ts.map +1 -1
  69. package/dist/db/tables/webhookSubscriptions.js +63 -100
  70. package/dist/db/tables/webhookSubscriptions.js.map +1 -1
  71. package/dist/internal/MetricSink.d.ts +35 -125
  72. package/dist/internal/MetricSink.d.ts.map +1 -1
  73. package/dist/internal/MetricSink.js +50 -121
  74. package/dist/internal/MetricSink.js.map +1 -1
  75. package/dist/internal/Schema.d.ts +3 -1
  76. package/dist/internal/Schema.d.ts.map +1 -1
  77. package/dist/internal/Schema.js +4 -2
  78. package/dist/internal/Schema.js.map +1 -1
  79. package/dist/internal/WebhookTransfer.d.ts +1 -55
  80. package/dist/internal/WebhookTransfer.d.ts.map +1 -1
  81. package/dist/internal/WebhookTransfer.js +1 -239
  82. package/dist/internal/WebhookTransfer.js.map +1 -1
  83. package/dist/internal/Webhooks.d.ts +83 -44
  84. package/dist/internal/Webhooks.d.ts.map +1 -1
  85. package/dist/internal/Webhooks.js +87 -73
  86. package/dist/internal/Webhooks.js.map +1 -1
  87. package/dist/internal/funding/DepositWebhook.d.ts +25 -0
  88. package/dist/internal/funding/DepositWebhook.d.ts.map +1 -0
  89. package/dist/internal/funding/DepositWebhook.js +74 -0
  90. package/dist/internal/funding/DepositWebhook.js.map +1 -0
  91. package/dist/internal/funding/Reconciliation.d.ts +6 -0
  92. package/dist/internal/funding/Reconciliation.d.ts.map +1 -1
  93. package/dist/internal/funding/Reconciliation.js +42 -10
  94. package/dist/internal/funding/Reconciliation.js.map +1 -1
  95. package/dist/internal/funding/index.d.ts +1 -0
  96. package/dist/internal/funding/index.d.ts.map +1 -1
  97. package/dist/internal/funding/index.js +1 -0
  98. package/dist/internal/funding/index.js.map +1 -1
  99. package/dist/internal/webhookDestinations/slack.d.ts.map +1 -1
  100. package/dist/internal/webhookDestinations/slack.js +16 -0
  101. package/dist/internal/webhookDestinations/slack.js.map +1 -1
  102. package/package.json +1 -1
  103. package/src/App.ts +3 -0
  104. package/src/Client.test-d.ts +22 -1
  105. package/src/Webhooks.ts +1 -220
  106. package/src/apps/data/App.ts +8 -2
  107. package/src/apps/data/routes/activities.test.ts +107 -11
  108. package/src/apps/data/routes/activities.ts +64 -32
  109. package/src/apps/data/routes/webhooks.test.ts +179 -1497
  110. package/src/apps/data/routes/webhooks.ts +150 -974
  111. package/src/apps/management/routes/webhooks.test.ts +79 -1
  112. package/src/apps/management/routes/webhooks.ts +40 -4
  113. package/src/db/Db.test.ts +1 -5
  114. package/src/db/Db.ts +23 -0
  115. package/src/db/Schema.ts +13 -0
  116. package/src/db/tables/organizations.test.ts +5 -2
  117. package/src/db/tables/webhookQueueEvents.ts +7 -1
  118. package/src/db/tables/webhookSubscriptions.ts +89 -138
  119. package/src/internal/MetricSink.test.ts +41 -373
  120. package/src/internal/MetricSink.ts +92 -273
  121. package/src/internal/Schema.test.ts +18 -0
  122. package/src/internal/Schema.ts +13 -2
  123. package/src/internal/WebhookTransfer.ts +1 -351
  124. package/src/internal/Webhooks.test.ts +102 -139
  125. package/src/internal/Webhooks.ts +175 -106
  126. package/src/internal/funding/DepositWebhook.test.ts +131 -0
  127. package/src/internal/funding/DepositWebhook.ts +101 -0
  128. package/src/internal/funding/Reconciliation.test.ts +14 -0
  129. package/src/internal/funding/Reconciliation.ts +161 -86
  130. package/src/internal/funding/index.test.ts +1 -0
  131. package/src/internal/funding/index.ts +1 -0
  132. package/src/internal/webhookDestinations/slack.ts +15 -0
  133. package/src/Webhooks.bench.ts +0 -714
  134. package/src/Webhooks.memory.bench.ts +0 -250
  135. package/src/Webhooks.test.ts +0 -146
package/src/Webhooks.ts CHANGED
@@ -1,18 +1,6 @@
1
- import * as AppWebhooks from './apps/data/routes/webhooks.js'
2
- import * as Db from './db/Db.js'
3
- import * as core_Webhooks from './internal/Webhooks.js'
4
- import * as MetricSink from './internal/MetricSink.js'
5
- import * as Store from './internal/Store.js'
6
- import type * as Metrics from './Metrics.js'
7
- import * as Viem from './internal/Viem.js'
8
-
9
- /** How often a poller reclaims expired rows, independent of tick cadence. */
10
- const pruneIntervalMs = 60_000
11
-
12
1
  // Re-export the runtime-agnostic primitives (subscriptions, signing, delivery,
13
2
  // `deliverAndRecord`, cursors, …) so consumers reach everything through one
14
- // `Webhooks` namespace. The apps-layer poller stays private behind
15
- // `createPoller` below.
3
+ // `Webhooks` namespace.
16
4
  export * from './internal/Webhooks.js'
17
5
 
18
6
  // Re-export the webhook resource's zod schemas so consumers can validate and
@@ -20,210 +8,3 @@ export * from './internal/Webhooks.js'
20
8
  // `type`), plus the per-event `TransferEvent` / `TransactionEvent` / `PingEvent`
21
9
  // payloads. Infer a type with `z.infer<typeof Webhooks.schema.Envelope>`.
22
10
  export { enrichEventData, eventTypes, scanBlock, schema } from './apps/data/routes/webhooks.js'
23
-
24
- /**
25
- * Builds a webhook poller bound to a state store and chain config.
26
- *
27
- * This is the entry point a host runtime drives from chain-head notifications
28
- * or a timer. Each {@link Poller.tick} scans active subscriptions from their
29
- * cursors up to `head - confirmations` and dispatches matching events.
30
- *
31
- * Every onchain event type scans a confirmed window directly over JSON-RPC, so
32
- * the poller builds memoized viem clients via {@link Viem.createGetClient}. The
33
- * poller keeps `App.create` a pure Hono app: it constructs its own clients
34
- * instead of reaching into app internals, and owns no request state.
35
- */
36
- export function createPoller(options: createPoller.Options): createPoller.Poller {
37
- // Persistent memoize cache for token metadata, shared across ticks (the
38
- // webhook state store used to double-duty as this cache).
39
- const cache = options.cache ?? Store.memory()
40
- const getClient = Viem.createGetClient({
41
- defaultChainId: options.defaultChainId,
42
- rpc: options.rpc,
43
- })
44
- // Chain-head ticks run about once per block, so the janitor is throttled here
45
- // rather than issuing deletes per block. Reads filter expiry at query time, so
46
- // reclaiming late only costs space.
47
- let prunedAt: number | undefined
48
-
49
- const metrics = options.metrics ? MetricSink.webhooks(options.metrics) : undefined
50
-
51
- return {
52
- async tick(input = {}) {
53
- const chainId = input.chainId ?? options.defaultChainId ?? Viem.defaultChainId
54
-
55
- // Wrap the transport so each enqueue (or inline delivery) emits a timed
56
- // `webhook:enqueue` metric. The wrapper preserves the dispatch contract: it
57
- // returns the underlying result unchanged and is a no-op when no metrics
58
- // backend is configured.
59
- const inner = input.dispatch ?? options.dispatch
60
- const innerBatch =
61
- input.dispatchBatch ?? (input.dispatch === undefined ? options.dispatchBatch : undefined)
62
- const dispatch =
63
- metrics && inner
64
- ? async (dispatchable: AppWebhooks.poll.Dispatchable) => {
65
- const start = Date.now()
66
- const result = await inner(dispatchable)
67
- metrics.record({
68
- chainId: dispatchable.envelope.chainId,
69
- durationMs: Date.now() - start,
70
- eventType: dispatchable.envelope.type,
71
- ok: result.ok,
72
- type: 'webhook:enqueue',
73
- })
74
- return result
75
- }
76
- : inner
77
- const dispatchBatch = innerBatch
78
- ? async (
79
- dispatchables: readonly AppWebhooks.poll.Dispatchable[],
80
- context: AppWebhooks.poll.DispatchContext,
81
- ) => {
82
- const start = Date.now()
83
- const result = await innerBatch(dispatchables, context)
84
- const durationMs = Date.now() - start
85
- for (const dispatchable of dispatchables)
86
- metrics?.record({
87
- chainId: dispatchable.envelope.chainId,
88
- durationMs,
89
- eventType: dispatchable.envelope.type,
90
- ok: result.ok,
91
- type: 'webhook:enqueue',
92
- })
93
- return result
94
- }
95
- : undefined
96
-
97
- // Resolve per tick so Workers factories build a fresh Hyperdrive-pooled
98
- // connection each run.
99
- const db = Db.get(options.db)
100
- const now = input.now ?? options.now
101
- if (prunedAt === undefined || Date.now() - prunedAt >= pruneIntervalMs) {
102
- prunedAt = Date.now()
103
- await core_Webhooks.pruneExpired(db, { now }).catch(() => {})
104
- }
105
-
106
- const start = Date.now()
107
- let cursorLagBlocks: number | undefined
108
- try {
109
- const result = await AppWebhooks.poll({
110
- batchSize: input.batchSize ?? options.batchSize,
111
- cache,
112
- chainId,
113
- client: getClient(chainId),
114
- confirmations: input.confirmations ?? options.confirmations,
115
- db,
116
- dispatch,
117
- dispatchBatch,
118
- eventTypes: input.eventTypes ?? options.eventTypes,
119
- ...(input.headBlockNumber === undefined
120
- ? {}
121
- : { headBlockNumber: input.headBlockNumber }),
122
- maxBlocks: input.maxBlocks ?? options.maxBlocks,
123
- now,
124
- signal: input.signal,
125
- ...(metrics
126
- ? {
127
- onCursorLag(blocks: number) {
128
- cursorLagBlocks = blocks
129
- },
130
- onScanCheckpoint(checkpoint: AppWebhooks.poll.ScanCheckpoint) {
131
- metrics.record({ chainId, type: 'webhook:poll-scan', ...checkpoint })
132
- // Ship the start boundary before the RPC allocation can terminate the isolate.
133
- metrics.flush()
134
- },
135
- onStage(stage: AppWebhooks.poll.Stage) {
136
- metrics.record({ chainId, type: 'webhook:poll', ...stage })
137
- },
138
- onSubscriptions(count: number) {
139
- metrics.record({ chainId, count, type: 'webhook:poll-subscriptions' })
140
- },
141
- }
142
- : {}),
143
- })
144
- metrics?.record({
145
- cursorLagBlocks,
146
- meta: { durationMs: Date.now() - start, outcome: 'ok' },
147
- result,
148
- type: 'webhook:tick',
149
- })
150
- return result
151
- } catch (error) {
152
- metrics?.record({
153
- cursorLagBlocks,
154
- meta: { chainId, durationMs: Date.now() - start, outcome: 'error' },
155
- result: undefined,
156
- type: 'webhook:tick',
157
- })
158
- throw error
159
- } finally {
160
- metrics?.flush()
161
- }
162
- },
163
- }
164
- }
165
-
166
- export declare namespace createPoller {
167
- /** A row to dispatch, paired with its owning subscription. */
168
- type Dispatchable = AppWebhooks.poll.Dispatchable
169
-
170
- /** Delivery transport: hands a matched event to a queue or delivers inline. */
171
- type Dispatch = NonNullable<AppWebhooks.poll.Options['dispatch']>
172
-
173
- /** Batch delivery transport: confirms all rows together or leaves their cursor unchanged. */
174
- type DispatchBatch = NonNullable<AppWebhooks.poll.Options['dispatchBatch']>
175
-
176
- /** Summary counters returned by {@link Poller.tick}. */
177
- type Result = AppWebhooks.poll.Result
178
-
179
- /** Knobs shared by every {@link Poller.tick}; each is overridable per tick. */
180
- type Defaults = {
181
- /** Max matching rows scanned per subscription per tick (default 200). */
182
- batchSize?: number | undefined
183
- /** Reorg-safety window: only emit blocks this far behind head (default 1). */
184
- confirmations?: number | undefined
185
- /**
186
- * Delivery transport. Defaults to inline `deliverAndRecord` (self-host); on
187
- * Cloudflare pass a Queue producer so retry/backoff/DLQ are native.
188
- */
189
- dispatch?: Dispatch | undefined
190
- /** Batch delivery transport used when the runtime can enqueue multiple rows atomically. */
191
- dispatchBatch?: DispatchBatch | undefined
192
- /** Event types to poll (default all). */
193
- eventTypes?: AppWebhooks.poll.Options['eventTypes']
194
- /** Backfill cap: most blocks an RPC scanner inspects per tick (default 1000). */
195
- maxBlocks?: number | undefined
196
- /** Clock for envelope timestamps; injectable for tests. */
197
- now?: (() => Date) | undefined
198
- }
199
-
200
- /** Options for {@link createPoller}. */
201
- type Options = Defaults & {
202
- /** Cache for token metadata shared across ticks. */
203
- cache?: Store.Store | undefined
204
- /** Database holding subscriptions + cursors, or a factory resolved per tick (Workers/Hyperdrive). */
205
- db: Db.Source
206
- /** Tempo chain id polled when a tick omits one. */
207
- defaultChainId?: Viem.ChainId | undefined
208
- /** Metrics backend; emits `webhook:tick` per tick and `webhook:enqueue` per dispatch. */
209
- metrics?: Metrics.Metrics | undefined
210
- /** RPC options for the viem clients that scan chain events. */
211
- rpc?: Viem.getClient.Rpc | undefined
212
- }
213
-
214
- /** Per-tick overrides; falls back to the {@link Options} defaults. */
215
- type TickOptions = Defaults & {
216
- /** Chain to poll this tick (default `options.defaultChainId`). */
217
- chainId?: Viem.ChainId | undefined
218
- /** Chain head to work back from; resolved from RPC when omitted. */
219
- headBlockNumber?: bigint | number | undefined
220
- /** Stops scanning and dispatch when the caller loses its execution lease. */
221
- signal?: AbortSignal | undefined
222
- }
223
-
224
- /** A chain-head-driven webhook poller. */
225
- type Poller = {
226
- /** Runs one poll tick, resolving the RPC head when not supplied. */
227
- tick(options?: TickOptions): Promise<Result>
228
- }
229
- }
@@ -224,7 +224,13 @@ export function data(options: data.Options = {}) {
224
224
  .route('/', transactionActivities({ fx: options.fx }))
225
225
  .route('/', transfers({ fx: options.fx }))
226
226
  .route('/', verifiedTokens())
227
- .route('/', webhooks({ enabled: options.webhook !== false }))
227
+ .route(
228
+ '/',
229
+ webhooks({
230
+ applicationEventTypes: webhook?.applicationEventTypes ?? [],
231
+ enabled: options.webhook !== false,
232
+ }),
233
+ )
228
234
  .route('/', zones())
229
235
  .route('/', rpc())
230
236
  .route('/', coingecko())
@@ -445,7 +451,7 @@ function webhooksBlock() {
445
451
  'tempo-event-id',
446
452
  'A unique, stable id for this event (`evt_…`). The same event may be delivered more than once, so use this id to skip duplicates.',
447
453
  ),
448
- header('tempo-event-type', 'What happened: `token:transfer`, `transaction:included`, `log:emitted`, `block:created`, or `ping`.'), // prettier-ignore
454
+ header('tempo-event-type', 'What happened: `token:transfer`, `transaction:included`, `log:emitted`, `block:created`, `funding:deposit.updated`, or `ping`.'), // prettier-ignore
449
455
  ],
450
456
  requestBody: {
451
457
  content: { 'application/json': { schema: envelope } },
@@ -35,8 +35,6 @@ const liveAccounts = {
35
35
  channel: '0x5217c502adf3c96a9551770af4caa5f5144a6984',
36
36
  // TokenCreated + Mint + Transfer.
37
37
  creator: '0x253fb3bf1e3d75ad6ee1554f68f4ea0568f7d24e',
38
- // Token burns.
39
- burner: '0x69519497b59bd64e109d993157d45f14455cc7e6',
40
38
  // KeyAuthorized + KeyRevoked + Approval (+ transfer/mint/exchange).
41
39
  keychain: '0xfe776fd28e06ecc71a157398b2c9991318f1ea2e',
42
40
  // Swaps through the stablecoin DEX (output kept, so it stays a plain `swap`).
@@ -50,27 +48,29 @@ const accounts =
50
48
  ? {
51
49
  channel: localAccounts.keychain,
52
50
  creator: localAccounts.creator,
53
- burner: localAccounts.burner,
54
51
  keychain: localAccounts.keychain,
55
52
  swapper: localAccounts.swapper,
56
53
  }
57
54
  : liveAccounts
58
55
 
59
- /** The account expected to surface each activity type. */
56
+ /**
57
+ * The account expected to surface each activity type. Burns are seeded per run
58
+ * instead: a live burner keeps accruing activity until its last burn falls off
59
+ * the first page.
60
+ */
60
61
  const coverage =
61
- runtime.mode === 'localnet'
62
+ runtime.mode === 'localnet' && localAccounts
62
63
  ? {
63
- approval: accounts.keychain,
64
- burn: accounts.burner,
65
- mint: accounts.creator,
66
- 'token-created': accounts.creator,
67
- transfer: accounts.creator,
64
+ approval: localAccounts.keychain,
65
+ burn: localAccounts.burner,
66
+ mint: localAccounts.creator,
67
+ 'token-created': localAccounts.creator,
68
+ transfer: localAccounts.creator,
68
69
  }
69
70
  : {
70
71
  'access-key-created': accounts.keychain,
71
72
  'access-key-revoked': accounts.keychain,
72
73
  approval: accounts.keychain,
73
- burn: accounts.burner,
74
74
  mint: accounts.creator,
75
75
  'session-closed': accounts.channel,
76
76
  swap: accounts.swapper,
@@ -165,6 +165,54 @@ describe('GET /addresses/:address/activities', () => {
165
165
  expect(find(body.data, type)).toBeDefined()
166
166
  })
167
167
 
168
+ // Localnet seeds its burner through the shared fixtures, so this covers the
169
+ // hosted testnet, where no address stays burn-topped for long.
170
+ test.skipIf(runtime.mode === 'localnet')(
171
+ 'classifies a seeded burn',
172
+ async () => {
173
+ const account = Tempo.accounts[11]
174
+ const wallet = Tempo.getClient({ account })
175
+ await Actions.faucet.fundSync(Tempo.client, { account, timeout: 60_000 })
176
+
177
+ // Burning needs the `issuer` role, so the account burns a token it owns.
178
+ const { token } = await Actions.token.createSync(wallet, {
179
+ currency: 'USD',
180
+ name: 'Seeded Burn USD',
181
+ symbol: 'SBUSD',
182
+ })
183
+ await sendTransactionSync(wallet, {
184
+ account,
185
+ calls: [
186
+ Actions.token.grantRoles.call(wallet, { role: 'issuer', to: account.address, token }),
187
+ Actions.token.mint.call(wallet, { amount: 1_000_000n, to: account.address, token }),
188
+ ],
189
+ })
190
+ const { receipt } = await Actions.token.burnSync(wallet, { amount: 100_000n, token })
191
+
192
+ const deadline = Date.now() + 90_000
193
+ let burn: Activities.Entry | undefined
194
+ while (Date.now() < deadline) {
195
+ const response = await client.v1.addresses[':address'].activities.$get(
196
+ { param: { address: account.address }, query: { limit: '50' } },
197
+ { headers: { 'cache-control': 'no-store', ...TestApp.auth.headers } },
198
+ )
199
+ if (response.status === 200) {
200
+ const body = await TestApp.json(response, Activities.schema.getAddressActivities.Response)
201
+ burn = find(body.data, 'burn')
202
+ if (burn?.type === 'burn' && burn.transactionHash === receipt.transactionHash) break
203
+ }
204
+ await new Promise((resolve) => setTimeout(resolve, 500))
205
+ }
206
+ expect(burn?.type).toBe('burn')
207
+ if (burn?.type !== 'burn') return
208
+ expect(burn.transactionHash).toBe(receipt.transactionHash)
209
+ expect(burn.data.sender.toLowerCase()).toBe(account.address.toLowerCase())
210
+ expect(burn.data.sourceToken.address.toLowerCase()).toBe(token.toLowerCase())
211
+ expect(burn.data.sourceAmount.baseUnits).toBe('100000')
212
+ },
213
+ 180_000,
214
+ )
215
+
168
216
  test('transfer data mirrors the `Transfer` resource', async () => {
169
217
  const transfer = find((await activities(accounts.creator)).data, 'transfer')
170
218
  expect(transfer?.type).toBe('transfer')
@@ -666,6 +714,54 @@ describe('Activities.list classification', () => {
666
714
  expect(log.data).toBe('0x1234')
667
715
  expect(log.topics).toEqual([unknownSelector, pad(viewer)])
668
716
  })
717
+
718
+ test('degrades a topic-encoded approval with a fee transfer to `unknown`', async () => {
719
+ const approvalToken = '0x4120c7ec7e9ead9dcf44b35b5134ebdbfc6e3de9' as Address.Address
720
+ const feeToken = '0x20c0000000000000000000000000000000000000' as Address.Address
721
+ const feeManager = '0xfeec000000000000000000000000000000000000'
722
+ const approvalSelector = '0x8c5be1e5ebec7d5bd14f71427d1e84f3dd0314c0f7b2291e5b200ac8c7c3b925'
723
+ const transferSelector = '0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef'
724
+ const pad = (value: string) => `0x${value.slice(2).padStart(64, '0')}`
725
+ const txHash = '0xd2439ad8016d4ffd6aebe835734ee4d118a688714a4f9aebcc11f87a2b22e896' as Hex.Hex
726
+ const common = {
727
+ blockNum: 15_781_805,
728
+ blockTimestamp: 1_776_703_284,
729
+ feePayer: viewer,
730
+ source: 'q1' as const,
731
+ txHash,
732
+ txIdx: 0,
733
+ txSender: viewer,
734
+ }
735
+ const { items } = await Activities.list(
736
+ provider([
737
+ {
738
+ ...common,
739
+ data: '0x',
740
+ logAddress: approvalToken,
741
+ logIdx: 0,
742
+ selector: approvalSelector,
743
+ topic1: pad(viewer),
744
+ topic2: pad('0x6063259f720ee125e5bff693779ab8118b0118a7'),
745
+ topic3: '0x2a9063ed52b7d417e441f378325359c9ce274f5d6c3ecf11b7d42f24b9c90b7f',
746
+ },
747
+ {
748
+ ...common,
749
+ data: `0x${(5_815).toString(16).padStart(64, '0')}`,
750
+ logAddress: feeToken,
751
+ logIdx: 1,
752
+ selector: transferSelector,
753
+ topic1: pad(viewer),
754
+ topic2: pad(feeManager),
755
+ topic3: null,
756
+ },
757
+ ]),
758
+ { address: viewer, store: store() },
759
+ )
760
+
761
+ expect(items).toHaveLength(1)
762
+ expect(items[0]?.type).toBe('unknown')
763
+ expect(items[0]?.events).toHaveLength(2)
764
+ })
669
765
  })
670
766
 
671
767
  describe('Activities.list precompile classification', () => {
@@ -2727,15 +2727,46 @@ function classifyFromLogs(
2727
2727
  viewer: Address.Address
2728
2728
  },
2729
2729
  ): RawItem[] {
2730
- const items = classifyFromLogsCore(rows, ctx)
2730
+ const decoded = decodeRows(rows)
2731
+ const items = (() => {
2732
+ try {
2733
+ return classifyFromLogsCore(rows, ctx, decoded)
2734
+ } catch {
2735
+ // Indexed logs can match a known signature without satisfying that event's expected shape.
2736
+ return [
2737
+ unknown(ctx.itemBase(Math.min(...rows.map((row) => row.logIdx))), {
2738
+ events: buildTxEvents(rows, decoded),
2739
+ signer: signerForRows(rows, { fallback: ctx.signer, viewer: ctx.viewer }),
2740
+ }),
2741
+ ]
2742
+ }
2743
+ })()
2731
2744
  // When the caller requested raw logs, attach the full per-transaction log set
2732
2745
  // (decoded when possible) to every classified item so no co-located log is
2733
2746
  // hidden behind the headline. `unknown` items already carry it.
2734
2747
  if (!ctx.includeLogs) return items
2735
- const events = buildTxEvents(rows, decodeRows(rows))
2748
+ const events = buildTxEvents(rows, decoded)
2736
2749
  return items.map((item) => (item.type === 'unknown' ? item : { ...item, events }))
2737
2750
  }
2738
2751
 
2752
+ /** Resolves an access-key signer from raw rows, falling back to the transaction signer. */
2753
+ function signerForRows(
2754
+ rows: readonly Row[],
2755
+ options: { fallback: 'self' | Address.Address; viewer: Address.Address },
2756
+ ) {
2757
+ const accessKeySpendRow = rows.find(
2758
+ (row) =>
2759
+ row.selector === accessKeySpendSelector &&
2760
+ Address.isEqual(row.logAddress, Addresses.accountKeychain),
2761
+ )
2762
+ const accessKeySigner = accessKeySpendRow?.topic2
2763
+ ? (`0x${accessKeySpendRow.topic2.slice(-40)}`.toLowerCase() as Address.Address)
2764
+ : null
2765
+ return accessKeySigner && !Address.isEqual(accessKeySigner, options.viewer)
2766
+ ? accessKeySigner
2767
+ : options.fallback
2768
+ }
2769
+
2739
2770
  /** Classifies a Q1 (log-bearing) group via `parseEventLogs`, picking one headline item. */
2740
2771
  function classifyFromLogsCore(
2741
2772
  rows: Row[],
@@ -2746,24 +2777,11 @@ function classifyFromLogsCore(
2746
2777
  signer: 'self' | Address.Address
2747
2778
  viewer: Address.Address
2748
2779
  },
2780
+ events = decodeRows(rows),
2749
2781
  ): RawItem[] {
2750
2782
  const { blockNum, fingerprintMap, itemBase, viewer } = ctx
2751
2783
 
2752
- // An access-key-signed tx emits AccessKeySpend at the accountKeychain; topic2
2753
- // is the access-key publicKey. Use it as the signer so successive access-key
2754
- // actions group together.
2755
- const accessKeySpendRow = rows.find(
2756
- (r) =>
2757
- r.selector === accessKeySpendSelector &&
2758
- Address.isEqual(r.logAddress, Addresses.accountKeychain),
2759
- )
2760
- const accessKeySigner = accessKeySpendRow?.topic2
2761
- ? (`0x${accessKeySpendRow.topic2.slice(-40)}`.toLowerCase() as Address.Address)
2762
- : null
2763
- const signer: 'self' | Address.Address =
2764
- accessKeySigner && !Address.isEqual(accessKeySigner, viewer) ? accessKeySigner : ctx.signer
2765
-
2766
- const events = decodeRows(rows)
2784
+ const signer = signerForRows(rows, { fallback: ctx.signer, viewer })
2767
2785
 
2768
2786
  const minLogIndex = Math.min(...rows.map((r) => r.logIdx))
2769
2787
 
@@ -3251,6 +3269,14 @@ function classifyFromLogsCore(
3251
3269
  const approvalEvent = finalEvents.find(
3252
3270
  (e) =>
3253
3271
  e.eventName === 'Approval' &&
3272
+ // ERC-721 uses the same selector but indexes its token ID as topic3. In
3273
+ // non-strict mode viem otherwise decodes the missing ERC-20 amount as zero.
3274
+ rows.some(
3275
+ (row) =>
3276
+ row.logIdx === e.logIndex &&
3277
+ row.topic3 === null &&
3278
+ /^0x[0-9a-fA-F]{64}$/.test(row.data ?? ''),
3279
+ ) &&
3254
3280
  !Address.isEqual((e.args as { spender: Address.Address }).spender, Addresses.zoneOutbox),
3255
3281
  )
3256
3282
  if (approvalEvent) {
@@ -4338,21 +4364,27 @@ function unknown(
4338
4364
 
4339
4365
  /** Decodes a group's raw rows into `viem/tempo` events via `parseEventLogs` (non-strict). */
4340
4366
  function decodeRows(rows: readonly Row[]) {
4341
- const viemLogs: Log[] = rows.map((r) => ({
4342
- address: r.logAddress as `0x${string}`,
4343
- blockHash: '0x0' as Hex.Hex,
4344
- blockNumber: BigInt(r.blockNum),
4345
- data: (r.data ?? '0x') as Hex.Hex,
4346
- logIndex: r.logIdx,
4347
- removed: false,
4348
- topics: [r.selector, r.topic1, r.topic2, r.topic3].filter(Boolean) as [Hex.Hex, ...Hex.Hex[]],
4349
- transactionHash: r.txHash as `0x${string}`,
4350
- transactionIndex: r.txIdx,
4351
- }))
4352
- return parseEventLogs({
4353
- abi: [...Abis.abis, ...Abis.earnRouter, ...Abis.earnVault],
4354
- logs: viemLogs,
4355
- strict: false,
4367
+ return rows.flatMap((r) => {
4368
+ const log: Log = {
4369
+ address: r.logAddress as `0x${string}`,
4370
+ blockHash: '0x0' as Hex.Hex,
4371
+ blockNumber: BigInt(r.blockNum),
4372
+ data: (r.data ?? '0x') as Hex.Hex,
4373
+ logIndex: r.logIdx,
4374
+ removed: false,
4375
+ topics: [r.selector, r.topic1, r.topic2, r.topic3].filter(Boolean) as [Hex.Hex, ...Hex.Hex[]],
4376
+ transactionHash: r.txHash as `0x${string}`,
4377
+ transactionIndex: r.txIdx,
4378
+ }
4379
+ try {
4380
+ return parseEventLogs({
4381
+ abi: [...Abis.abis, ...Abis.earnRouter, ...Abis.earnVault],
4382
+ logs: [log],
4383
+ strict: false,
4384
+ })
4385
+ } catch {
4386
+ return []
4387
+ }
4356
4388
  })
4357
4389
  }
4358
4390