/** @module-tag localnet */ import { Address, Hex, Value as core_Value } from 'ox' import { FxOracle } from 'tapimo/apps' import { createClient, encodeAbiParameters, encodeEventTopics, http } from 'viem' import { getLogs, sendTransactionSync } from 'viem/actions' import { Abis, Actions, Addresses } from 'viem/tempo' import * as TestApp from '../../../../test/App.js' import * as Runtime from '../../../../test/runtime.js' import * as Tempo from '../../../../test/Tempo.js' import * as Cursor from '../../../internal/Cursor.js' import * as Store from '../../../internal/Store.js' import * as Activities from './activities.js' import * as Transactions from './transactions.js' // Deterministic EUR-based rate set so conversion math is exact in assertions. const fixed = FxOracle.from({ name: 'fixed', rates: async () => ({ asOf: '2026-01-01T00:00:00.000Z', base: 'EUR', rates: { AUD: '1.6', USD: '1.0' }, }), }) /** * Real Tempo (Moderato) accounts, each exercising specific activity types. * Discovered by scanning indexed `logs` for the event each type classifies * from, then confirming the classified feed through the live endpoint. If the * testnet prunes this history, rediscover replacements the same way. */ const liveAccounts = { // Payment-channel close (session-closed), folded into access-key groups. channel: '0x5217c502adf3c96a9551770af4caa5f5144a6984', // TokenCreated + Mint + Transfer. creator: '0x253fb3bf1e3d75ad6ee1554f68f4ea0568f7d24e', // KeyAuthorized + KeyRevoked + Approval (+ transfer/mint/exchange). keychain: '0xfe776fd28e06ecc71a157398b2c9991318f1ea2e', // Swaps through the stablecoin DEX (output kept, so it stays a plain `swap`). swapper: '0x7f4e7fdd75e4c5beb77d0eb223823d3a5a9865c0', } as const const runtime = Runtime.get() const localAccounts = runtime.fixtures?.activityAccounts const accounts = runtime.mode === 'localnet' && localAccounts ? { channel: localAccounts.keychain, creator: localAccounts.creator, keychain: localAccounts.keychain, swapper: localAccounts.swapper, } : liveAccounts /** * The account expected to surface each activity type. Burns are seeded per run * instead: a live burner keeps accruing activity until its last burn falls off * the first page. */ const coverage = runtime.mode === 'localnet' && localAccounts ? { approval: localAccounts.keychain, burn: localAccounts.burner, mint: localAccounts.creator, 'token-created': localAccounts.creator, transfer: localAccounts.creator, } : { 'access-key-created': accounts.keychain, 'access-key-revoked': accounts.keychain, approval: accounts.keychain, mint: accounts.creator, 'session-closed': accounts.channel, swap: accounts.swapper, 'token-created': accounts.creator, transfer: accounts.creator, } const client = TestApp.client() /** * Requests an address's activity page, retrying transient upstream `502`s * (TIDX surfaces a busy ClickHouse planner as a `422` the route maps to `502`). */ async function request(address: string, query: Record = {}) { let response = await activitiesRequest(address, query) for (let attempt = 0; attempt < 2 && response.status !== 200; attempt++) response = await activitiesRequest(address, query) return response } function activitiesRequest(address: string, query: Record) { return client.v1.addresses[':address'].activities.$get( { param: { address: address as Hex.Hex }, query: { limit: '50', ...query } }, TestApp.auth, ) } /** Requests and schema-validates an address's activity page. */ async function activities(address: string, query: Record = {}) { return TestApp.json( await request(address, query), Activities.schema.getAddressActivities.Response, ) } /** Finds an entry (or grouped item) of the given type, searching inside groups. */ function find(items: readonly Activities.Entry[], type: string): Activities.Entry | undefined { for (const entry of items) { if (entry.type === type) return entry if (entry.type === 'group') { const item = entry.data.items.find((i) => i.type === type) if (item) return item } } return undefined } describe('GET /addresses/:address/activities', () => { test('parses granular token includes for both activity endpoints', () => { expect( Activities.schema.getAddressActivities.Query.parse({ include: 'token.logoUri,token.verified', }).include, ).toMatchInlineSnapshot(` [ "token.logoUri", "token.verified", ] `) expect( Activities.schema.getTransactionActivities.Query.parse({ include: 'token.logoUri,token.verified', }).include, ).toMatchInlineSnapshot(` [ "token.logoUri", "token.verified", ] `) expect( Activities.schema.getAddressActivities.Query.safeParse({ include: 'token' }).success, ).toBe(false) expect( Activities.schema.getAddressActivities.Query.parse({ include: 'zones' }).include, ).toStrictEqual(['zones']) }) test('returns a newest-first, schema-valid feed with timing', async () => { const response = await request(accounts.keychain, { limit: '10' }) expect(response.status).toBe(200) expect(response.headers.get('server-timing')?.includes('address_activities;dur=')).toBe(true) const body = await TestApp.json(response, Activities.schema.getAddressActivities.Response) expect(Array.isArray(body.data)).toBe(true) expect(body.nextCursor === null || typeof body.nextCursor === 'string').toBe(true) // Entries are ordered newest-first by timestamp (ISO 8601 sorts // lexicographically the same as chronologically). const timestamps = body.data.map((entry) => entry.timestamp) expect(timestamps.every((ts, i) => i === 0 || timestamps[i - 1]! >= ts)).toBe(true) }) test.each(Object.entries(coverage))('classifies %s activity', async (type, address) => { const body = await activities(address) expect(find(body.data, type)).toBeDefined() }) // Localnet seeds its burner through the shared fixtures, so this covers the // hosted testnet, where no address stays burn-topped for long. test.skipIf(runtime.mode === 'localnet')( 'classifies a seeded burn', async () => { const account = Tempo.accounts[11] const wallet = Tempo.getClient({ account }) await Actions.faucet.fundSync(Tempo.client, { account, timeout: 60_000 }) // Burning needs the `issuer` role, so the account burns a token it owns. const { token } = await Actions.token.createSync(wallet, { currency: 'USD', name: 'Seeded Burn USD', symbol: 'SBUSD', }) await sendTransactionSync(wallet, { account, calls: [ Actions.token.grantRoles.call(wallet, { role: 'issuer', to: account.address, token }), Actions.token.mint.call(wallet, { amount: 1_000_000n, to: account.address, token }), ], }) const { receipt } = await Actions.token.burnSync(wallet, { amount: 100_000n, token }) const deadline = Date.now() + 90_000 let burn: Activities.Entry | undefined while (Date.now() < deadline) { const response = await client.v1.addresses[':address'].activities.$get( { param: { address: account.address }, query: { limit: '50' } }, { headers: { 'cache-control': 'no-store', ...TestApp.auth.headers } }, ) if (response.status === 200) { const body = await TestApp.json(response, Activities.schema.getAddressActivities.Response) burn = find(body.data, 'burn') if (burn?.type === 'burn' && burn.transactionHash === receipt.transactionHash) break } await new Promise((resolve) => setTimeout(resolve, 500)) } expect(burn?.type).toBe('burn') if (burn?.type !== 'burn') return expect(burn.transactionHash).toBe(receipt.transactionHash) expect(burn.data.sender.toLowerCase()).toBe(account.address.toLowerCase()) expect(burn.data.sourceToken.address.toLowerCase()).toBe(token.toLowerCase()) expect(burn.data.sourceAmount.baseUnits).toBe('100000') }, 180_000, ) test('transfer data mirrors the `Transfer` resource', async () => { const transfer = find((await activities(accounts.creator)).data, 'transfer') expect(transfer?.type).toBe('transfer') if (transfer?.type !== 'transfer') return expect(['in', 'out']).toContain(transfer.data.direction) expect(transfer.data.transactionHash).toMatch(/^0x[0-9a-f]{64}$/) expect(typeof transfer.data.blockNumber).toBe('number') expect(transfer.data.sourceToken.address).toMatch(/^0x[0-9a-f]{40}$/) expect(transfer.data.sourceToken).not.toHaveProperty('amount') expect(typeof transfer.data.sourceToken.currency).toBe('string') expect(typeof transfer.data.sourceToken.decimals).toBe('number') expect(typeof transfer.data.sourceToken.name).toBe('string') expect(typeof transfer.data.sourceToken.symbol).toBe('string') expect(transfer.data.sourceAmount.baseUnits).toMatch(/^\d+$/) expect(transfer.data.sourceAmount.currency).toBe(transfer.data.sourceToken.currency) expect(transfer.data.sourceAmount.decimals).toBe(transfer.data.sourceToken.decimals) expect(transfer.data.sourceAmount.formatted).toMatch(/^\d+(\.\d+)?$/) expect(transfer.data.sourceToken.logoUri).toBeUndefined() expect(transfer.data.sourceToken.verified).toBeUndefined() }) test('includes requested token verification', async () => { const transfer = find( ( await activities(accounts.creator, { include: 'token.logoUri,token.verified', }) ).data, 'transfer', ) expect(transfer?.type).toBe('transfer') if (transfer?.type !== 'transfer') return expect(typeof transfer.data.sourceToken.verified).toBe('boolean') }) test.skipIf(runtime.mode === 'localnet')('swap data carries both sides of the swap', async () => { const swap = find((await activities(accounts.swapper)).data, 'swap') expect(swap?.type).toBe('swap') if (swap?.type !== 'swap') return expect(swap.data.sourceToken.address).toMatch(/^0x[0-9a-f]{40}$/) expect(swap.data.destinationToken.address).toMatch(/^0x[0-9a-f]{40}$/) expect(swap.data.sourceToken).not.toHaveProperty('amount') expect(swap.data.destinationToken).not.toHaveProperty('amount') expect(swap.data.sourceAmount.baseUnits).toMatch(/^\d+$/) expect(swap.data.destinationAmount.baseUnits).toMatch(/^\d+$/) }) test.skipIf(runtime.mode === 'localnet')( 'adds a structured refund amount when a session closes with a refund', async () => { const session = find((await activities(accounts.channel)).data, 'session-closed') expect(session?.type).toBe('session-closed') if (session?.type !== 'session-closed') return if (!session.data.refund) { expect(session.data.refundAmount).toBeUndefined() return } expect(session.data.refundAmount).toBeDefined() expect(session.data.refund).not.toHaveProperty('amount') expect(session.data.refundAmount?.baseUnits).toMatch(/^\d+$/) expect(session.data.refundAmount?.currency).toBe(session.data.refund.currency) expect(session.data.refundAmount?.decimals).toBe(session.data.refund.decimals) }, ) test.skipIf(runtime.mode !== 'localnet')( 'surfaces a cross-token transfer as outgoing in the sender feed', async () => { const crossToken = runtime.fixtures?.crossTokenTransfer expect(crossToken).toBeDefined() if (!crossToken) return const { data } = await activities(crossToken.sender) const transfer = data.find( (entry) => entry.type === 'transfer' && entry.transactionHash === crossToken.hash && entry.data.destinationToken?.address === crossToken.destinationToken, ) expect(transfer?.type).toBe('transfer') if (transfer?.type !== 'transfer') return expect(transfer.data.direction).toBe('out') expect(transfer.data.sender).toBe(crossToken.sender) expect(transfer.data.recipient).toBe(crossToken.recipient) expect(transfer.data.sourceToken.address).toBe(crossToken.sourceToken) expect(transfer.data.destinationToken?.address).toBe(crossToken.destinationToken) expect(transfer.data.sourceToken).not.toHaveProperty('amount') expect(transfer.data.destinationToken).not.toHaveProperty('amount') expect(transfer.data.sourceAmount.baseUnits).toMatch(/^\d+$/) expect(transfer.data.destinationAmount?.baseUnits).toMatch(/^\d+$/) }, ) test.skipIf(runtime.mode !== 'localnet')( 'surfaces a cross-token transfer as a plain incoming delivery in the recipient feed', async () => { const crossToken = runtime.fixtures?.crossTokenTransfer expect(crossToken).toBeDefined() if (!crossToken) return // The recipient feed is built only from the inbound delivery leg (the // sender's `from`/`fee_payer` rows, which carry the swap, are out of // scope for the recipient). So the recipient sees an ordinary incoming // transfer of the token they received, with no cross-token framing: the // delivered token is the `sourceToken` and there is no `destinationToken`. const { data } = await activities(crossToken.recipient) const transfer = data.find( (entry) => entry.type === 'transfer' && entry.transactionHash === crossToken.hash, ) expect(transfer?.type).toBe('transfer') if (transfer?.type !== 'transfer') return expect(transfer.data.direction).toBe('in') expect(transfer.data.sender).toBe(crossToken.sender) expect(transfer.data.recipient).toBe(crossToken.recipient) expect(transfer.data.sourceToken.address).toBe(crossToken.destinationToken) expect(transfer.data.destinationToken).toBeUndefined() }, ) test.skipIf(runtime.mode === 'localnet')( 'folds consecutive access-key items into a group when group=true', async () => { const group = find((await activities(accounts.channel, { group: 'true' })).data, 'group') expect(group?.type).toBe('group') if (group?.type !== 'group') return expect(group.data.items.length).toBeGreaterThan(1) expect(group.data.signer).toMatch(/^0x[0-9a-f]{40}$/) }, ) test.skipIf(runtime.mode !== 'localnet')('values curated activity amounts', async () => { const db = TestApp.database() await TestApp.verifiedSeed(db, runtime.chainId) const client = TestApp.client({ db, fx: { oracle: fixed } }) const hash = runtime.fixtures!.faucetTransactionHashes[0]! const response = await client.v1.transactions[':transactionHash'].activities.$get( { param: { transactionHash: hash }, query: { 'valuation.currency': 'AUD' } }, TestApp.auth, ) const body = await TestApp.json(response, Activities.schema.getTransactionActivities.Response) expect(response.status).toBe(200) // The faucet transaction moves curated USD tokens; their amounts value at // the fixed 1.6 USD -> AUD rate. const amounts = body.data.flatMap((item) => item.type === 'transfer' || item.type === 'mint' ? [item.data.sourceAmount] : [], ) expect(amounts.length).toBeGreaterThan(0) for (const amount of amounts) expect(amount.valuation).toEqual({ amount: core_Value.format((BigInt(amount.baseUnits) * 16n) / 10n, 6), currency: 'AUD', }) expect(body.meta?.valuation).toMatchInlineSnapshot(` { "asOf": "2026-01-01T00:00:00.000Z", "basis": "nominal", "source": "fixed", } `) }) test.skipIf(runtime.mode !== 'localnet')('omits activity valuations by default', async () => { const response = await client.v1.transactions[':transactionHash'].activities.$get( { param: { transactionHash: runtime.fixtures!.faucetTransactionHashes[0]! }, query: {}, }, TestApp.auth, ) const body = await TestApp.json(response, Activities.schema.getTransactionActivities.Response) expect(response.status).toBe(200) const amounts = body.data.flatMap((item) => item.type === 'transfer' || item.type === 'mint' ? [item.data.sourceAmount] : [], ) expect(amounts.length).toBeGreaterThan(0) for (const amount of amounts) expect('valuation' in amount).toBe(false) expect(response.headers.get('server-timing')).not.toContain('valuation_rates;dur=') }) test('returns every item at the root by default (no group entries)', async () => { const body = await activities(accounts.channel) expect(body.data.every((entry) => entry.type !== 'group')).toBe(true) }) test('paginates with an opaque cursor', async () => { const first = await activities(accounts.keychain, { limit: '5' }) expect(first.nextCursor).toBeTypeOf('string') if (!first.nextCursor) return const response = await request(accounts.keychain, { limit: '5', cursor: first.nextCursor }) expect(response.headers.get('cache-control')).toMatchInlineSnapshot( `"private, max-age=60, stale-while-revalidate=300"`, ) const second = await TestApp.json(response, Activities.schema.getAddressActivities.Response) const firstHashes = new Set( first.data.flatMap((entry) => entry.type === 'group' ? entry.data.items.map((item) => item.transactionHash) : [entry.transactionHash], ), ) const secondHashes = second.data.flatMap((entry) => entry.type === 'group' ? entry.data.items.map((item) => item.transactionHash) : [entry.transactionHash], ) expect(secondHashes.every((hash) => !firstHashes.has(hash))).toBe(true) }) test.skipIf(runtime.mode !== 'localnet')( 'keeps older activity behind a high-log transaction', async () => { const account = Tempo.accounts[3] const wallet = createClient({ account, chain: Tempo.chain, transport: http(Tempo.rpcUrl), }) await Actions.faucet.fundSync(Tempo.client, { account, timeout: 60_000 }) const approval_result = await Actions.token.approveSync(wallet, { amount: 1n, spender: Addresses.stablecoinDex, token: Addresses.pathUsd, }) const approval = 'receipt' in approval_result ? approval_result.receipt : approval_result const highLog = await sendTransactionSync(wallet, { account, calls: Array.from({ length: 25 }, (_, index) => Actions.token.approve.call(wallet, { amount: BigInt(index + 2), spender: Addresses.stablecoinDex, token: Addresses.pathUsd, }), ), }) // The legacy `limit * 4` joined-row window stopped inside this transaction // and never exposed the older approval to the activity paginator. expect(highLog.logs.length).toBeGreaterThan(20) const pending = new Set( [approval.transactionHash, highLog.transactionHash].map((hash) => hash.toLowerCase()), ) const deadline = Date.now() + 60_000 while (Date.now() < deadline && pending.size > 0) { const response = await client.v1.transactions.$get( { query: { limit: '200', sender: account.address } }, { headers: { 'cache-control': 'no-store', ...TestApp.auth.headers } }, ) if (response.status === 200) { const body = await TestApp.json(response, Transactions.schema.getTransactions.Response) for (const transaction of body.data) pending.delete(transaction.hash.toLowerCase()) } if (pending.size > 0) await new Promise((resolve) => setTimeout(resolve, 250)) } expect(pending).toEqual(new Set()) const body = await activities(account.address, { limit: '5' }) expect( body.data.some( (entry) => entry.type === 'approval' && entry.transactionHash === approval.transactionHash, ), ).toBe(true) }, 120_000, ) test('validates denominations on empty feeds', async () => { const client = TestApp.client({ fx: { oracle: fixed } }) const cursor = Cursor.encode([0, 0]) const emptyResponse = await client.v1.addresses[':address'].activities.$get( { param: { address: accounts.keychain }, query: { cursor, limit: '5' } }, TestApp.auth, ) const empty = await TestApp.json(emptyResponse, Activities.schema.getAddressActivities.Response) expect(emptyResponse.status).toBe(200) expect(empty.data).toMatchInlineSnapshot(`[]`) const response = await client.v1.addresses[':address'].activities.$get( { param: { address: accounts.keychain }, query: { cursor, limit: '5', 'valuation.currency': 'JPY' }, }, TestApp.auth, ) expect(response.status).toBe(400) }) }) /** Resolves a real transaction hash from an address's (non-grouped) activity feed. */ async function firstTransactionHash(address: string): Promise { const { data } = await activities(address) for (const entry of data) if (entry.type !== 'group') return entry.transactionHash return undefined } function transactionActivitiesRequest(transactionHash: string, query: Record = {}) { return client.v1.transactions[':transactionHash'].activities.$get( { param: { transactionHash: transactionHash as Hex.Hex }, query }, TestApp.auth, ) } describe('GET /transactions/:transactionHash/activities', () => { test('classifies the activity on a real transaction', async () => { // Resolve a real transaction hash from the address feed, then classify it // through the transaction-scoped endpoint. const transactionHash = await firstTransactionHash(accounts.creator) expect(transactionHash).toBeDefined() if (!transactionHash) return const response = await transactionActivitiesRequest(transactionHash) expect(response.status).toBe(200) expect(response.headers.get('cache-control')).toMatchInlineSnapshot( `"private, max-age=60, stale-while-revalidate=300"`, ) expect(response.headers.get('server-timing')?.includes('transaction_activities;dur=')).toBe( true, ) const body = await TestApp.json(response, Activities.schema.getTransactionActivities.Response) expect(body.chainId).toBe(runtime.chainId) expect(body.data.length).toBeGreaterThan(0) // Every classified item belongs to the requested transaction. expect(body.data.every((item) => item.transactionHash === transactionHash)).toBe(true) }) test('attaches the full event set when logs=true', async () => { const transactionHash = await firstTransactionHash(accounts.creator) if (!transactionHash) return const body = await TestApp.json( await transactionActivitiesRequest(transactionHash, { logs: 'true' }), Activities.schema.getTransactionActivities.Response, ) expect(body.data.some((item) => Array.isArray(item.events) && item.events.length > 0)).toBe( true, ) }) test('supports requested token verification', async () => { const address = await activities(accounts.creator) const entry = address.data.find((item) => item.type !== 'group' && 'sourceToken' in item.data) expect(entry?.type).not.toBe('group') if (!entry || entry.type === 'group') return const body = await TestApp.json( await transactionActivitiesRequest(entry.transactionHash, { include: 'token.verified', }), Activities.schema.getTransactionActivities.Response, ) const item = body.data.find((item) => 'sourceToken' in item.data) expect(item).toBeDefined() if (!item || !('sourceToken' in item.data) || !item.data.sourceToken || !item.data.sourceAmount) return expect(typeof item.data.sourceToken.verified).toBe('boolean') expect(item.data.sourceToken).not.toHaveProperty('amount') expect(item.data.sourceAmount.baseUnits).toMatch(/^\d+$/) }) test.skipIf(runtime.mode !== 'localnet')( 'classifies a seeded cross-token transfer with both source and destination tokens', async () => { const crossToken = runtime.fixtures?.crossTokenTransfer expect(crossToken).toBeDefined() if (!crossToken) return const body = await TestApp.json( await transactionActivitiesRequest(crossToken.hash), Activities.schema.getTransactionActivities.Response, ) // The swap and its onward delivery leg fold into a single cross-token // `transfer`; there is no separate `swap` item. expect(body.data.some((item) => item.type === 'swap')).toBe(false) const transfer = body.data.find((item) => item.type === 'transfer') expect(transfer?.type).toBe('transfer') if (transfer?.type !== 'transfer') return expect(transfer.data.direction).toBe('out') expect(transfer.data.recipient).toBe(crossToken.recipient) expect(transfer.data.sourceToken.address).toBe(crossToken.sourceToken) expect(transfer.data.destinationToken?.address).toBe(crossToken.destinationToken) expect(transfer.data.sourceToken).not.toHaveProperty('amount') expect(transfer.data.destinationToken).not.toHaveProperty('amount') expect(transfer.data.sourceAmount.baseUnits).toMatch(/^\d+$/) expect(transfer.data.destinationAmount?.baseUnits).toMatch(/^\d+$/) }, ) test('404s for an unknown transaction hash', async () => { const response = await transactionActivitiesRequest(`0x${'0'.repeat(64)}`) expect(response.status).toBe(404) }) test.skipIf(runtime.mode !== 'localnet')( 'infers indexed Zone activity by transaction and address', async () => { const account = Tempo.accounts[0] const [zoneCreated] = await getLogs(Tempo.client, { event: { inputs: [ { indexed: true, name: 'zoneId', type: 'uint32' }, { indexed: true, name: 'portal', type: 'address' }, { indexed: false, name: 'initialToken', type: 'address' }, { indexed: false, name: 'admin', type: 'address' }, { indexed: false, name: 'sequencer', type: 'address' }, { indexed: false, name: 'verifier', type: 'address' }, { indexed: false, name: 'genesisBlockHash', type: 'bytes32' }, { indexed: false, name: 'genesisTempoBlockHash', type: 'bytes32' }, { indexed: false, name: 'genesisTempoBlockNumber', type: 'uint64' }, ], name: 'ZoneCreated', type: 'event', }, fromBlock: 0n, strict: true, toBlock: 'latest', }) expect(zoneCreated).toBeDefined() if (!zoneCreated) return const zone = TestApp.zone({ chainId: runtime.zone.chainId, rpcUrl: runtime.zone.internalRpcUrl, sourceChainId: runtime.chainId, tidxUrl: runtime.tidxUrl, }) const zoneClient = createClient({ account, chain: zone, pollingInterval: 100, transport: http(runtime.zone.internalRpcUrl), }) const balanceBefore = await Actions.token.getBalance(zoneClient, { account: account.address, token: Addresses.pathUsd, }) const zoneDeposit = await Actions.zone.depositSync(Tempo.getClient({ account }), { amount: 1_000_000n, portalAddress: zoneCreated.args.portal, token: Addresses.pathUsd, zoneId: zoneCreated.args.zoneId, }) let balance = balanceBefore const balanceDeadline = Date.now() + 60_000 while (balance.amount <= balanceBefore.amount && Date.now() < balanceDeadline) { await new Promise((resolve) => setTimeout(resolve, 100)) balance = await Actions.token.getBalance(zoneClient, { account: account.address, token: Addresses.pathUsd, }) } expect(balance.amount).toBeGreaterThan(balanceBefore.amount) const { receipt } = await Actions.token.transferSync(zoneClient, { amount: 1n, to: Tempo.accounts[1].address, token: Addresses.pathUsd, }) const zoneReader = { ...TestApp.key, id: 'key_inferred_zone', scopes: [...TestApp.key.scopes, `zone:${runtime.zone.chainId}:read`], token: 'secret_inferred_zone', } satisfies TestApp.kvStore.Key const client = TestApp.client({ auth: { keys: [zoneReader] }, zones: [zone], }) let response = await client.v1.transactions[':transactionHash'].activities.$get( { param: { transactionHash: receipt.transactionHash }, query: { chainId: String(runtime.chainId), include: 'zones' }, }, { headers: { authorization: `Bearer ${zoneReader.token}` } }, ) const deadline = Date.now() + 60_000 while ((response.status === 404 || response.status === 502) && Date.now() < deadline) { await new Promise((resolve) => setTimeout(resolve, 500)) response = await client.v1.transactions[':transactionHash'].activities.$get( { param: { transactionHash: receipt.transactionHash }, query: { chainId: String(runtime.chainId), include: 'zones' }, }, { headers: { authorization: `Bearer ${zoneReader.token}` } }, ) } const body = await TestApp.json(response, Activities.schema.getTransactionActivities.Response) expect(response.status).toBe(200) expect(response.headers.get('cache-control')).toBe('no-store') expect(body.chainId).toBe(runtime.zone.chainId) const cached = await client.v1.transactions[':transactionHash'].activities.$get( { param: { transactionHash: receipt.transactionHash }, query: { chainId: String(runtime.chainId), include: 'zones' }, }, { headers: { authorization: `Bearer ${zoneReader.token}` } }, ) expect(cached.status).toBe(200) expect(cached.headers.get('server-timing')).not.toContain('tidx;dur=') const zoneOnlyTransaction = await client.v1.transactions[':transactionHash'].activities.$get( { param: { transactionHash: receipt.transactionHash }, query: { chainId: String(runtime.zone.chainId) }, }, { headers: { authorization: `Bearer ${zoneReader.token}` } }, ) const zoneOnlyTransactionBody = await TestApp.json( zoneOnlyTransaction, Activities.schema.getTransactionActivities.Response, ) expect(zoneOnlyTransaction.status).toBe(200) expect(zoneOnlyTransactionBody.chainId).toBe(runtime.zone.chainId) let parentTransaction = await client.v1.transactions[':transactionHash'].activities.$get( { param: { transactionHash: zoneDeposit.receipt.transactionHash }, query: { chainId: String(runtime.chainId), include: 'zones' }, }, { headers: { authorization: `Bearer ${zoneReader.token}` } }, ) const parentDeadline = Date.now() + 60_000 while ( (parentTransaction.status === 404 || parentTransaction.status === 502) && Date.now() < parentDeadline ) { await new Promise((resolve) => setTimeout(resolve, 500)) parentTransaction = await client.v1.transactions[':transactionHash'].activities.$get( { param: { transactionHash: zoneDeposit.receipt.transactionHash }, query: { chainId: String(runtime.chainId), include: 'zones' }, }, { headers: { authorization: `Bearer ${zoneReader.token}` } }, ) } const parentTransactionBody = await TestApp.json( parentTransaction, Activities.schema.getTransactionActivities.Response, ) expect(parentTransaction.status).toBe(200) expect(parentTransactionBody.chainId).toBe(runtime.chainId) const missingHash = `0x${'ff'.repeat(32)}` as Hex.Hex const getMissing = () => client.v1.transactions[':transactionHash'].activities.$get( { param: { transactionHash: missingHash }, query: { chainId: String(runtime.chainId), include: 'zones' }, }, { headers: { authorization: `Bearer ${zoneReader.token}` } }, ) const missing = [await getMissing(), await getMissing()] expect(missing.map(({ status }) => status)).toStrictEqual([404, 404]) expect( missing.every((item) => item.headers.get('server-timing')?.includes('tidx;dur=')), ).toBe(true) const transfer = body.data.find((item) => item.type === 'transfer') expect(transfer?.transactionHash).toBe(receipt.transactionHash) if (transfer?.type !== 'transfer') return expect(transfer.data.recipient).toBe(Tempo.accounts[1].address.toLowerCase()) const zoneAddressActivities = await (async () => { const deadline = Date.now() + 60_000 while (Date.now() < deadline) { const response = await client.v1.addresses[':address'].activities.$get( { param: { address: account.address }, query: { chainId: String(runtime.chainId), include: 'zones', limit: '50', }, }, { headers: { authorization: `Bearer ${zoneReader.token}` } }, ) if (response.status === 200) { const body = await TestApp.json( response, Activities.schema.getAddressActivities.Response, ) const hashes = body.data.flatMap((item) => item.type === 'group' ? item.data.items.map(({ transactionHash }) => transactionHash) : [item.transactionHash], ) if ( hashes.includes(receipt.transactionHash) && hashes.includes(zoneDeposit.receipt.transactionHash) ) return { body, response } } await new Promise((resolve) => setTimeout(resolve, 500)) } throw new Error('Timed out waiting for inferred Zone address activity') })() const addressTransfer = zoneAddressActivities.body.data.find( (item) => item.type !== 'group' && item.transactionHash === receipt.transactionHash, ) expect(zoneAddressActivities.response.headers.get('cache-control')).toBe('no-store') expect(addressTransfer?.chainId).toBe(runtime.zone.chainId) const parentActivity = zoneAddressActivities.body.data.find( (item) => item.type !== 'group' && item.transactionHash === zoneDeposit.receipt.transactionHash, ) expect(parentActivity?.chainId).toBe(runtime.chainId) const zoneOnlyActivities = await client.v1.addresses[':address'].activities.$get( { param: { address: account.address }, query: { chainId: String(runtime.zone.chainId), limit: '50' }, }, { headers: { authorization: `Bearer ${zoneReader.token}` } }, ) const zoneOnlyActivitiesBody = await TestApp.json( zoneOnlyActivities, Activities.schema.getAddressActivities.Response, ) expect(zoneOnlyActivities.status).toBe(200) expect( zoneOnlyActivitiesBody.data.some( (item) => item.type !== 'group' && item.transactionHash === receipt.transactionHash, ), ).toBe(true) expect( zoneOnlyActivitiesBody.data.some( (item) => item.type !== 'group' && item.transactionHash === zoneDeposit.receipt.transactionHash, ), ).toBe(false) }, 180_000, ) }) describe('Activities.list classification', () => { const viewer = '0x1111111111111111111111111111111111111111' as Address.Address /** A memory store pre-seeded so the MPP directory fetch never hits the network. */ function store() { const store = Store.memory() void store.put('mpp-services:v1', JSON.stringify({})) return store } /** A {@link Activities.Provider} that returns a fixed set of rows once. */ function provider(rows: Activities.Row[]): Activities.Provider { return { cursorForGroup: (group) => `${group.blockNum}:${group.txIdx}`, fetchRows: async () => ({ rows }), } } function pagedProvider(rows: Activities.Row[]): Activities.Provider { return { cursorForGroup: (group) => `${group.blockNum}:${group.txIdx}`, fetchRows: async ({ cursor, limit }) => { const [blockNum, txIdx] = cursor?.split(':').map(Number) ?? [] return { rows: rows .filter( (row) => blockNum === undefined || row.blockNum < blockNum || (row.blockNum === blockNum && row.txIdx < txIdx!), ) .slice(0, limit + 1), } }, } } function unknownRow(options: { blockNum: number blockTimestamp: number hash: string }): Activities.Row { return { blockNum: options.blockNum, blockTimestamp: options.blockTimestamp, data: '0x', feePayer: null, logAddress: '0x2222222222222222222222222222222222222222', logIdx: 0, selector: null, source: 'q1', topic1: null, topic2: null, topic3: null, txHash: `0x${options.hash.repeat(64)}` as Hex.Hex, txIdx: 0, txSender: viewer, } } test('classifies every transfer row of a receipt-less (Q2) transaction', async () => { const sender = '0x9999999999999999999999999999999999999999' as Address.Address const token = '0x2222222222222222222222222222222222222222' as Address.Address const q2Row = (options: { amount: bigint; logIdx: number }): Activities.Row => ({ blockNum: 100, blockTimestamp: 1_700_000_000, data: null, feePayer: null, logAddress: token, logIdx: options.logIdx, selector: null, source: 'q2', topic1: null, topic2: null, topic3: null, transferAmount: options.amount, transferFrom: sender, transferTo: viewer, transferToken: token, txHash: `0x${'e'.repeat(64)}` as Hex.Hex, txIdx: 0, txSender: sender, }) const { items } = await Activities.list( provider([q2Row({ amount: 10n, logIdx: 0 }), q2Row({ amount: 20n, logIdx: 1 })]), { address: viewer, store: store() }, ) expect(items.map((item) => item.type)).toEqual(['transfer', 'transfer']) expect(new Set(items.map((item) => item.id)).size).toBe(2) }) test('merges chain pages and retains each provider cursor', async () => { const providers = [ { chainId: 10, provider: pagedProvider([ unknownRow({ blockNum: 30, blockTimestamp: 300, hash: 'a' }), unknownRow({ blockNum: 10, blockTimestamp: 100, hash: 'c' }), ]), }, { chainId: 20, provider: pagedProvider([unknownRow({ blockNum: 20, blockTimestamp: 200, hash: 'b' })]), }, ] const first = await Activities.listChains({ address: viewer, limit: 2, providers, store: store(), }) const second = await Activities.listChains({ address: viewer, cursor: first.nextCursor!, limit: 2, providers, store: store(), }) expect(first.items.map((item) => [item.chainId, item.timestamp])).toStrictEqual([ [10, '1970-01-01T00:05:00.000Z'], [20, '1970-01-01T00:03:20.000Z'], ]) expect(first.nextCursor).not.toBeNull() expect(second.items.map((item) => item.chainId)).toStrictEqual([10]) expect(second.nextCursor).toBeNull() }) test('retains unused chain buffers while filling a grouped page', async () => { const signer = '0x3333333333333333333333333333333333333333' as Address.Address const calls = new Map() const providers = [ { chainId: 10, hashes: ['1', '3', '5'], timestamps: [600, 400, 200] }, { chainId: 20, hashes: ['2', '4', '6'], timestamps: [500, 300, 100] }, ].map(({ chainId, hashes, timestamps }) => { const source = pagedProvider( timestamps.map((blockTimestamp, index) => ({ ...unknownRow({ blockNum: blockTimestamp, blockTimestamp, hash: hashes[index]! }), feePayer: viewer, txSender: signer, })), ) return { chainId, provider: { cursorForGroup: (group) => source.cursorForGroup(group), fetchRows: async (options) => { calls.set(chainId, (calls.get(chainId) ?? 0) + 1) return source.fetchRows(options) }, } satisfies Activities.Provider, } }) const result = await Activities.listChains({ address: viewer, group: true, limit: 2, providers, store: store(), }) expect(result.items.map((item) => item.timestamp)).toStrictEqual([ '1970-01-01T00:10:00.000Z', '1970-01-01T00:08:20.000Z', '1970-01-01T00:06:40.000Z', '1970-01-01T00:05:00.000Z', '1970-01-01T00:03:20.000Z', '1970-01-01T00:01:40.000Z', ]) expect([...calls]).toStrictEqual([ [10, 2], [20, 2], ]) expect(result.nextCursor).toBeNull() }) test('does not fold access-key activity across chains', async () => { const signer = '0x3333333333333333333333333333333333333333' as Address.Address const providers = [10, 20].map((chainId) => ({ chainId, provider: pagedProvider([ { ...unknownRow({ blockNum: chainId, blockTimestamp: 300, hash: String(chainId / 10) }), feePayer: viewer, txSender: signer, }, ]), })) const result = await Activities.listChains({ address: viewer, providers, store: store(), }) const grouped = Activities.groupByAccessKey( result.items.map((item) => Activities.schema.Item.parse(item)), ) expect(grouped.map((item) => [item.chainId, item.type])).toStrictEqual([ [10, 'unknown'], [20, 'unknown'], ]) }) test('classifies an unrecognized Q1 tx as `unknown` with decoded events', async () => { // A gas-fee Transfer to the fee manager decodes as a known `Transfer` // event, but the classifier excludes fee-manager transfers, so the tx has // no classifiable activity and falls through to `unknown`. const token = '0x2222222222222222222222222222222222222222' as Address.Address const feeManager = '0xfeec000000000000000000000000000000000000' const transferSelector = '0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef' const pad = (a: string) => `0x${a.slice(2).padStart(64, '0')}` const { items } = await Activities.list( provider([ { blockNum: 100, blockTimestamp: 1_700_000_000, data: `0x${(5).toString(16).padStart(64, '0')}`, feePayer: null, logAddress: token, logIdx: 0, selector: transferSelector, source: 'q1', topic1: pad(viewer), topic2: pad(feeManager), topic3: null, txHash: `0x${'a'.repeat(64)}` as Hex.Hex, txIdx: 0, txSender: viewer, }, ]), { address: viewer, store: store() }, ) expect(items).toHaveLength(1) const item = items[0]! expect(item.type).toBe('unknown') if (item.type !== 'unknown') return expect(item.data.signer).toBe('self') expect(item.transactionHash).toBe(`0x${'a'.repeat(64)}`) expect(item.events).toHaveLength(1) const event = item.events![0]! expect(event.eventName).toBe('Transfer') expect(event.args!['amount']).toBe('5') // Addresses are normalized to lowercase (viem decodes them checksummed). expect(event.args!['from']).toBe(viewer) expect(event.args!['to']).toBe(feeManager) }) test('captures undecoded logs on an `unknown` tx so no log is dropped', async () => { // A log whose selector is not in any known Tempo ABI cannot decode, so it // would be invisible without the raw `logs` capture. const contract = '0x3333333333333333333333333333333333333333' as Address.Address const unknownSelector = `0x${'b'.repeat(64)}` const pad = (a: string) => `0x${a.slice(2).padStart(64, '0')}` const { items } = await Activities.list( provider([ { blockNum: 100, blockTimestamp: 1_700_000_000, data: '0x1234', feePayer: null, logAddress: contract, logIdx: 2, selector: unknownSelector, source: 'q1', topic1: pad(viewer), topic2: null, topic3: null, txHash: `0x${'c'.repeat(64)}` as Hex.Hex, txIdx: 0, txSender: viewer, }, ]), { address: viewer, store: store() }, ) expect(items).toHaveLength(1) const item = items[0]! expect(item.type).toBe('unknown') if (item.type !== 'unknown') return // The undecoded log is surfaced as a raw event entry (no `eventName`/`args`). expect(item.events).toHaveLength(1) const log = item.events![0]! expect(log.eventName).toBeUndefined() expect(log.args).toBeUndefined() expect(log.address).toBe(contract) expect(log.logIndex).toBe(2) expect(log.data).toBe('0x1234') expect(log.topics).toEqual([unknownSelector, pad(viewer)]) }) test('degrades a topic-encoded approval with a fee transfer to `unknown`', async () => { const approvalToken = '0x4120c7ec7e9ead9dcf44b35b5134ebdbfc6e3de9' as Address.Address const feeToken = '0x20c0000000000000000000000000000000000000' as Address.Address const feeManager = '0xfeec000000000000000000000000000000000000' const approvalSelector = '0x8c5be1e5ebec7d5bd14f71427d1e84f3dd0314c0f7b2291e5b200ac8c7c3b925' const transferSelector = '0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef' const pad = (value: string) => `0x${value.slice(2).padStart(64, '0')}` const txHash = '0xd2439ad8016d4ffd6aebe835734ee4d118a688714a4f9aebcc11f87a2b22e896' as Hex.Hex const common = { blockNum: 15_781_805, blockTimestamp: 1_776_703_284, feePayer: viewer, source: 'q1' as const, txHash, txIdx: 0, txSender: viewer, } const { items } = await Activities.list( provider([ { ...common, data: '0x', logAddress: approvalToken, logIdx: 0, selector: approvalSelector, topic1: pad(viewer), topic2: pad('0x6063259f720ee125e5bff693779ab8118b0118a7'), topic3: '0x2a9063ed52b7d417e441f378325359c9ce274f5d6c3ecf11b7d42f24b9c90b7f', }, { ...common, data: `0x${(5_815).toString(16).padStart(64, '0')}`, logAddress: feeToken, logIdx: 1, selector: transferSelector, topic1: pad(viewer), topic2: pad(feeManager), topic3: null, }, ]), { address: viewer, store: store() }, ) expect(items).toHaveLength(1) expect(items[0]?.type).toBe('unknown') expect(items[0]?.events).toHaveLength(2) }) }) describe('Activities.list precompile classification', () => { const viewer = '0x1111111111111111111111111111111111111111' as Address.Address const tokenA = '0x2222222222222222222222222222222222222222' as Address.Address const stablecoinDex = '0xdec0000000000000000000000000000000000000' as Address.Address const feeManager = '0xfeec000000000000000000000000000000000000' as Address.Address const accountKeychain = '0xaaaaaaaa00000000000000000000000000000000' as Address.Address const channelReserve = '0x4d50500000000000000000000000000000000000' as Address.Address const tip20Factory = '0x20fc000000000000000000000000000000000000' as Address.Address const txHash = `0x${'b'.repeat(64)}` as Hex.Hex function store() { const store = Store.memory() void store.put('mpp-services:v1', JSON.stringify({})) return store } function provider(rows: Activities.Row[]): Activities.Provider { return { cursorForGroup: (group) => `${group.blockNum}:${group.txIdx}`, fetchRows: async () => ({ rows }), } } /** Builds a Q1 log row for `eventName` by ABI-encoding its indexed topics and data. */ function logRow(options: { address: Address.Address args: Record eventName: string logIdx: number }): Activities.Row { type Param = { indexed?: boolean; name?: string; type: string } // Standard ERC-4626 events, mirrored from the classifier's decode ABI. const erc4626Events = [ { inputs: [ { indexed: true, name: 'sender', type: 'address' }, { indexed: true, name: 'owner', type: 'address' }, { indexed: false, name: 'assets', type: 'uint256' }, { indexed: false, name: 'shares', type: 'uint256' }, ], name: 'Deposit', type: 'event', }, { inputs: [ { indexed: true, name: 'sender', type: 'address' }, { indexed: true, name: 'receiver', type: 'address' }, { indexed: true, name: 'owner', type: 'address' }, { indexed: false, name: 'assets', type: 'uint256' }, { indexed: false, name: 'shares', type: 'uint256' }, ], name: 'Withdraw', type: 'event', }, ] as const const abi = [...Abis.abis, ...Abis.earnRouter, ...Abis.earnVault, ...erc4626Events] const event = abi.find((a) => a.type === 'event' && a.name === options.eventName) as { inputs: readonly Param[] } const topics = encodeEventTopics({ abi, eventName: options.eventName as never, args: options.args as never, }) as (string | null)[] const dataInputs = event.inputs.filter((i) => !i.indexed) const data = encodeAbiParameters( dataInputs, dataInputs.map((i) => options.args[i.name!]), ) return { blockNum: 100, blockTimestamp: 1_700_000_000, data, feePayer: null, logAddress: options.address, logIdx: options.logIdx, selector: topics[0] ?? null, source: 'q1', topic1: topics[1] ?? null, topic2: topics[2] ?? null, topic3: topics[3] ?? null, txHash, txIdx: 0, txSender: viewer, } } /** A Q1 TIP-20 `Transfer` log row, used to back channel token recovery. */ function transferRow(options: { from: Address.Address logIdx: number to: Address.Address token: Address.Address value: bigint }): Activities.Row { return logRow({ address: options.token, args: { amount: options.value, from: options.from, to: options.to }, eventName: 'Transfer', logIdx: options.logIdx, }) } async function classify(rows: Activities.Row[]) { const { items } = await Activities.list(provider(rows), { address: viewer, store: store() }) return items } test('classifies every Earn vault and private-router event', async () => { const requestId = `0x${'c'.repeat(64)}` as Hex.Hex const cases = [ ['Deposited', { assets: 100n, caller: viewer, earnShares: 90n, receiver: viewer }], [ 'VenueSharesDeposited', { caller: viewer, earnShares: 90n, receivedEngineShares: 95n, receiver: viewer, requestedVenueShares: 100n, }, ], ['Redeemed', { assets: 100n, caller: viewer, earnShares: 90n, receiver: viewer }], ['WithdrewExact', { assets: 100n, caller: viewer, earnSharesBurned: 90n, receiver: viewer }], ['RedeemRequested', { earnShares: 90n, receiver: viewer, requestId, requester: viewer }], [ 'RedeemFinalized', { asset: tokenA, assets: 100n, earnShares: 90n, receiver: viewer, requestId }, ], ['RedeemCancelled', { earnShares: 90n, receiver: viewer, requestId }], [ 'EarnDeposit', { actionId: requestId, earnShares: 90n, earnVault: tokenA, inputAmount: 100n, inputToken: tokenA, vaultAssets: 100n, zoneDepositHash: requestId, }, ], [ 'EarnRedeem', { actionId: requestId, earnShares: 90n, earnVault: tokenA, outputAmount: 100n, outputToken: tokenA, vaultAssets: 100n, zoneDepositHash: requestId, }, ], ] as const const types = [] for (const [eventName, args] of cases) { const items = await classify([logRow({ address: tokenA, args, eventName, logIdx: 0 })]) types.push(items[0]?.type) } expect(types).toMatchInlineSnapshot(` [ "assets-deposited", "shares-deposited", "shares-redeemed", "assets-withdrawn", "shares-redemption-requested", "shares-redemption-finalized", "shares-redemption-cancelled", "private-assets-deposited", "private-shares-redeemed", ] `) }) test('prefers a private Earn event over its underlying vault event', async () => { const actionId = `0x${'c'.repeat(64)}` as Hex.Hex const items = await classify([ logRow({ address: tokenA, args: { assets: 100n, caller: viewer, earnShares: 90n, receiver: viewer }, eventName: 'Deposited', logIdx: 0, }), logRow({ address: tokenA, args: { actionId, earnShares: 90n, earnVault: tokenA, inputAmount: 100n, inputToken: tokenA, vaultAssets: 100n, zoneDepositHash: actionId, }, eventName: 'EarnDeposit', logIdx: 1, }), ]) expect(items.map((item) => item.type)).toMatchInlineSnapshot(` [ "private-assets-deposited", ] `) }) test('classifies ChannelOpened as `channel-opened` with the deposit token', async () => { const items = await classify([ logRow({ address: channelReserve, args: { authorizedSigner: viewer, channelId: `0x${'c'.repeat(64)}`, deposit: 1000n, expiringNonceHash: `0x${'0'.repeat(64)}`, operator: viewer, payee: tokenA, payer: viewer, salt: `0x${'0'.repeat(64)}`, token: tokenA, }, eventName: 'ChannelOpened', logIdx: 0, }), ]) expect(items).toHaveLength(1) expect(items[0]!.type).toBe('channel-opened') if (items[0]!.type !== 'channel-opened') return expect(items[0]!.data.sourceToken.amount).toBe('1000') expect(items[0]!.data.payer).toBe(viewer) }) test('classifies TopUp as `channel-funded`, recovering token from the paired transfer', async () => { const items = await classify([ transferRow({ from: viewer, logIdx: 0, to: channelReserve, token: tokenA, value: 500n }), logRow({ address: channelReserve, args: { additionalDeposit: 500n, channelId: `0x${'c'.repeat(64)}`, newDeposit: 1500n, payee: tokenA, payer: viewer, }, eventName: 'TopUp', logIdx: 1, }), ]) expect(items).toHaveLength(1) expect(items[0]!.type).toBe('channel-funded') if (items[0]!.type !== 'channel-funded') return expect(items[0]!.data.sourceToken.address).toBe(tokenA) expect(items[0]!.data.sourceToken.amount).toBe('500') }) test('classifies Settled as `channel-settled`', async () => { const payee = '0x3333333333333333333333333333333333333333' as Address.Address const items = await classify([ transferRow({ from: channelReserve, logIdx: 0, to: payee, token: tokenA, value: 250n }), logRow({ address: channelReserve, args: { channelId: `0x${'c'.repeat(64)}`, cumulativeAmount: 250n, deltaPaid: 250n, newSettled: 250n, payee, payer: viewer, }, eventName: 'Settled', logIdx: 1, }), ]) expect(items[0]!.type).toBe('channel-settled') if (items[0]!.type !== 'channel-settled') return expect(items[0]!.data.sourceToken.amount).toBe('250') expect(items[0]!.data.payee).toBe(payee) }) test('classifies ChannelClosed as `channel-closed` with payout and refund', async () => { const payee = '0x3333333333333333333333333333333333333333' as Address.Address const items = await classify([ transferRow({ from: channelReserve, logIdx: 0, to: payee, token: tokenA, value: 600n }), transferRow({ from: channelReserve, logIdx: 1, to: viewer, token: tokenA, value: 400n }), logRow({ address: channelReserve, args: { channelId: `0x${'c'.repeat(64)}`, payee, payer: viewer, refundedToPayer: 400n, settledToPayee: 600n, }, eventName: 'ChannelClosed', logIdx: 2, }), ]) expect(items[0]!.type).toBe('channel-closed') if (items[0]!.type !== 'channel-closed') return expect(items[0]!.data.sourceToken?.amount).toBe('600') expect(items[0]!.data.refund?.amount).toBe('400') }) test('classifies CloseRequestCancelled as `channel-close-cancelled`', async () => { const payee = '0x3333333333333333333333333333333333333333' as Address.Address const items = await classify([ logRow({ address: channelReserve, args: { channelId: `0x${'c'.repeat(64)}`, payee, payer: viewer }, eventName: 'CloseRequestCancelled', logIdx: 0, }), ]) expect(items[0]!.type).toBe('channel-close-cancelled') if (items[0]!.type !== 'channel-close-cancelled') return expect(items[0]!.data.channelId).toBe(`0x${'c'.repeat(64)}`) expect(items[0]!.data.payee).toBe(payee) expect(items[0]!.data.payer).toBe(viewer) }) test('classifies OrderPlaced as `order-placed`', async () => { const items = await classify([ transferRow({ from: viewer, logIdx: 0, to: stablecoinDex, token: tokenA, value: 1000n }), logRow({ address: stablecoinDex, args: { amount: 1000n, flipTick: 0, isBid: true, isFlipOrder: false, maker: viewer, orderId: 42n, tick: 5, token: tokenA, }, eventName: 'OrderPlaced', logIdx: 1, }), ]) expect(items[0]!.type).toBe('order-placed') if (items[0]!.type !== 'order-placed') return expect(items[0]!.data.orderId).toBe('42') expect(items[0]!.data.side).toBe('bid') expect(items[0]!.data.tick).toBe(5) }) test('folds a swap plus onward delivery into one cross-token `transfer`', async () => { const tokenB = '0x5555555555555555555555555555555555555555' as Address.Address const recipient = '0x7777777777777777777777777777777777777777' as Address.Address const items = await classify([ // Source token spent into the DEX. transferRow({ from: viewer, logIdx: 0, to: stablecoinDex, token: tokenA, value: 1_000n }), // Destination token received back from the DEX. transferRow({ from: stablecoinDex, logIdx: 1, to: viewer, token: tokenB, value: 990n }), // Destination token forwarded to a third-party recipient in the same tx. transferRow({ from: viewer, logIdx: 2, to: recipient, token: tokenB, value: 990n }), ]) // The delivery leg folds into the swap, so there is no separate `swap`. expect(items.some((item) => item.type === 'swap')).toBe(false) const transfer = items.find((item) => item.type === 'transfer') expect(transfer?.type).toBe('transfer') if (transfer?.type !== 'transfer') return expect(transfer.data.direction).toBe('out') expect(transfer.data.sender).toBe(viewer) expect(transfer.data.recipient).toBe(recipient) expect(transfer.data.sourceToken).toEqual({ address: tokenA, amount: '1000' }) expect(transfer.data.destinationToken).toEqual({ address: tokenB, amount: '990' }) }) test('keeps a swap whose output stays with the swapper as a `swap`', async () => { const tokenB = '0x5555555555555555555555555555555555555555' as Address.Address const items = await classify([ transferRow({ from: viewer, logIdx: 0, to: stablecoinDex, token: tokenA, value: 1_000n }), transferRow({ from: stablecoinDex, logIdx: 1, to: viewer, token: tokenB, value: 990n }), ]) const swap = items.find((item) => item.type === 'swap') expect(swap?.type).toBe('swap') if (swap?.type !== 'swap') return expect(swap.data.sourceToken).toEqual({ address: tokenA, amount: '1000' }) expect(swap.data.destinationToken).toEqual({ address: tokenB, amount: '990' }) }) test('classifies OrderCancelled as `order-cancelled`', async () => { const items = await classify([ logRow({ address: stablecoinDex, args: { orderId: 42n }, eventName: 'OrderCancelled', logIdx: 0, }), ]) expect(items[0]!.type).toBe('order-cancelled') if (items[0]!.type !== 'order-cancelled') return expect(items[0]!.data.orderId).toBe('42') }) test('classifies FeesDistributed as `fees-distributed`', async () => { const validator = '0xcccccccc00000000000000000000000000000000' as Address.Address const items = await classify([ transferRow({ from: feeManager, logIdx: 0, to: validator, token: tokenA, value: 77n }), logRow({ address: feeManager, args: { amount: 77n, token: tokenA, validator }, eventName: 'FeesDistributed', logIdx: 1, }), ]) expect(items[0]!.type).toBe('fees-distributed') if (items[0]!.type !== 'fees-distributed') return expect(items[0]!.data.sourceToken.amount).toBe('77') expect(items[0]!.data.validator).toBe(validator) }) test('classifies RebalanceSwap as `fee-rebalance-swap`', async () => { const tokenB = '0x4444444444444444444444444444444444444444' as Address.Address const items = await classify([ logRow({ address: feeManager, args: { amountIn: 10n, amountOut: 9n, swapper: viewer, userToken: tokenB, validatorToken: tokenA, }, eventName: 'RebalanceSwap', logIdx: 0, }), ]) expect(items[0]!.type).toBe('fee-rebalance-swap') if (items[0]!.type !== 'fee-rebalance-swap') return expect(items[0]!.data.sourceToken.address).toBe(tokenA) expect(items[0]!.data.destinationToken.address).toBe(tokenB) }) test('classifies RewardDistributed as `reward-distributed` on the token contract', async () => { const items = await classify([ transferRow({ from: viewer, logIdx: 0, to: tokenA, token: tokenA, value: 30n }), logRow({ address: tokenA, args: { amount: 30n, funder: viewer }, eventName: 'RewardDistributed', logIdx: 1, }), ]) expect(items[0]!.type).toBe('reward-distributed') if (items[0]!.type !== 'reward-distributed') return expect(items[0]!.data.sourceToken.address).toBe(tokenA) expect(items[0]!.data.sourceToken.amount).toBe('30') }) test('classifies RewardRecipientSet as `reward-recipient-set`', async () => { const recipient = '0x5555555555555555555555555555555555555555' as Address.Address const items = await classify([ logRow({ address: tokenA, args: { holder: viewer, recipient }, eventName: 'RewardRecipientSet', logIdx: 0, }), ]) expect(items[0]!.type).toBe('reward-recipient-set') if (items[0]!.type !== 'reward-recipient-set') return expect(items[0]!.data.recipient).toBe(recipient) }) test('classifies SpendingLimitUpdated as `spending-limit-updated`', async () => { const publicKey = '0x6666666666666666666666666666666666666666' as Address.Address const items = await classify([ logRow({ address: accountKeychain, args: { account: viewer, newLimit: 999n, publicKey, token: tokenA }, eventName: 'SpendingLimitUpdated', logIdx: 0, }), ]) expect(items[0]!.type).toBe('spending-limit-updated') if (items[0]!.type !== 'spending-limit-updated') return expect(items[0]!.data.publicKey).toBe(publicKey) expect(items[0]!.data.sourceToken.amount).toBe('999') }) test('classifies BurnBlocked as `burn-blocked` before plain burn', async () => { const items = await classify([ transferRow({ from: viewer, logIdx: 0, to: '0x0000000000000000000000000000000000000000' as Address.Address, token: tokenA, value: 15n, }), logRow({ address: tokenA, args: { amount: 15n, from: viewer }, eventName: 'BurnBlocked', logIdx: 1, }), ]) expect(items[0]!.type).toBe('burn-blocked') if (items[0]!.type !== 'burn-blocked') return expect(items[0]!.data.sourceToken.amount).toBe('15') }) test('classifies OrderFilled as `order-filled`', async () => { const maker = '0x4444444444444444444444444444444444444444' as Address.Address const items = await classify([ logRow({ address: stablecoinDex, args: { amountFilled: 250n, maker, orderId: 7n, partialFill: true, taker: viewer }, eventName: 'OrderFilled', logIdx: 0, }), ]) expect(items[0]!.type).toBe('order-filled') if (items[0]!.type !== 'order-filled') return expect(items[0]!.data.orderId).toBe('7') expect(items[0]!.data.amountFilled).toBe('250') expect(items[0]!.data.partialFill).toBe(true) }) test('classifies PairCreated as `pair-created`', async () => { const quote = '0x5555555555555555555555555555555555555555' as Address.Address const items = await classify([ logRow({ address: stablecoinDex, args: { base: tokenA, key: `0x${'a'.repeat(64)}`, quote }, eventName: 'PairCreated', logIdx: 0, }), ]) expect(items[0]!.type).toBe('pair-created') if (items[0]!.type !== 'pair-created') return expect(items[0]!.data.base).toBe(tokenA) expect(items[0]!.data.quote).toBe(quote) }) test('classifies PauseStateUpdate as `token-pause-set`', async () => { const items = await classify([ logRow({ address: tokenA, args: { isPaused: true, updater: viewer }, eventName: 'PauseStateUpdate', logIdx: 0, }), ]) expect(items[0]!.type).toBe('token-pause-set') if (items[0]!.type !== 'token-pause-set') return expect(items[0]!.data.paused).toBe(true) expect(items[0]!.data.updater).toBe(viewer) }) test('classifies LogoURIUpdated as `token-logo-set`', async () => { const items = await classify([ logRow({ address: tokenA, args: { newLogoURI: 'https://example.com/logo.svg', updater: viewer }, eventName: 'LogoURIUpdated', logIdx: 0, }), ]) expect(items[0]!.type).toBe('token-logo-set') if (items[0]!.type !== 'token-logo-set') return expect(items[0]!.data.logoUri).toBe('https://example.com/logo.svg') }) test('classifies RoleMembershipUpdated as `role-membership-set`', async () => { const account = '0x6666666666666666666666666666666666666666' as Address.Address const items = await classify([ logRow({ address: tokenA, args: { account, hasRole: true, role: `0x${'1'.repeat(64)}`, sender: viewer }, eventName: 'RoleMembershipUpdated', logIdx: 0, }), ]) expect(items[0]!.type).toBe('role-membership-set') if (items[0]!.type !== 'role-membership-set') return expect(items[0]!.data.account).toBe(account) expect(items[0]!.data.granted).toBe(true) }) test('classifies TokenCreated before a co-located RoleMembershipUpdated', async () => { const items = await classify([ logRow({ address: tokenA, args: { account: viewer, hasRole: true, role: `0x${'1'.repeat(64)}`, sender: viewer }, eventName: 'RoleMembershipUpdated', logIdx: 0, }), logRow({ address: tip20Factory, args: { admin: viewer, currency: 'USD', name: 'Test', quoteToken: tokenA, salt: `0x${'0'.repeat(64)}`, symbol: 'TST', token: tokenA, }, eventName: 'TokenCreated', logIdx: 1, }), ]) expect(items[0]!.type).toBe('token-created') }) test('classifies PolicyCreated before the WhitelistUpdated it bundles', async () => { const account = '0x7777777777777777777777777777777777777777' as Address.Address const items = await classify([ logRow({ address: tokenA, args: { account, allowed: true, policyId: 9n, updater: viewer }, eventName: 'WhitelistUpdated', logIdx: 0, }), logRow({ address: tokenA, args: { policyId: 9n, policyType: 1, updater: viewer }, eventName: 'PolicyCreated', logIdx: 1, }), ]) expect(items[0]!.type).toBe('policy-created') if (items[0]!.type !== 'policy-created') return expect(items[0]!.data.policyId).toBe('9') expect(items[0]!.data.policyType).toBe(1) }) test('classifies WhitelistUpdated as `whitelist-updated`', async () => { const account = '0x7777777777777777777777777777777777777777' as Address.Address const items = await classify([ logRow({ address: tokenA, args: { account, allowed: false, policyId: 9n, updater: viewer }, eventName: 'WhitelistUpdated', logIdx: 0, }), ]) expect(items[0]!.type).toBe('whitelist-updated') if (items[0]!.type !== 'whitelist-updated') return expect(items[0]!.data.allowed).toBe(false) }) test('classifies MasterRegistered as `master-registered`', async () => { const master = '0x8888888888888888888888888888888888888888' as Address.Address const items = await classify([ logRow({ address: accountKeychain, args: { masterAddress: master, masterId: '0xdeadbeef' }, eventName: 'MasterRegistered', logIdx: 0, }), ]) expect(items[0]!.type).toBe('master-registered') if (items[0]!.type !== 'master-registered') return expect(items[0]!.data.masterId).toBe('0xdeadbeef') expect(items[0]!.data.masterAddress).toBe(master) }) test('classifies NonceIncremented as `nonce-incremented`', async () => { const items = await classify([ logRow({ address: accountKeychain, args: { account: viewer, newNonce: 5n, nonceKey: 0n }, eventName: 'NonceIncremented', logIdx: 0, }), ]) expect(items[0]!.type).toBe('nonce-incremented') if (items[0]!.type !== 'nonce-incremented') return expect(items[0]!.data.nonce).toBe('5') }) test('classifies ValidatorRotated before the ValidatorAdded it bundles', async () => { const validator = '0x9999999999999999999999999999999999999999' as Address.Address const feeRecipient = '0xabababababababababababababababababababab' as Address.Address const items = await classify([ logRow({ address: accountKeychain, args: { egress: 'https://v.example.com:2', feeRecipient, index: 2n, ingress: 'https://v.example.com:1', publicKey: `0x${'2'.repeat(64)}`, validatorAddress: validator, }, eventName: 'ValidatorAdded', logIdx: 0, }), logRow({ address: accountKeychain, args: { caller: viewer, deactivatedIndex: 1n, egress: 'https://v.example.com:2', index: 2n, ingress: 'https://v.example.com:1', newPublicKey: `0x${'3'.repeat(64)}`, oldPublicKey: `0x${'2'.repeat(64)}`, validatorAddress: validator, }, eventName: 'ValidatorRotated', logIdx: 1, }), ]) expect(items[0]!.type).toBe('validator-rotated') if (items[0]!.type !== 'validator-rotated') return expect(items[0]!.data.index).toBe('2') expect(items[0]!.data.deactivatedIndex).toBe('1') }) test('classifies OwnershipTransferred as `ownership-transferred`', async () => { const newOwner = '0xcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcd' as Address.Address const items = await classify([ logRow({ address: accountKeychain, args: { newOwner, oldOwner: viewer }, eventName: 'OwnershipTransferred', logIdx: 0, }), ]) expect(items[0]!.type).toBe('ownership-transferred') if (items[0]!.type !== 'ownership-transferred') return expect(items[0]!.data.newOwner).toBe(newOwner) }) test('classifies Initialized as `initialized`', async () => { const items = await classify([ logRow({ address: accountKeychain, args: { height: 42n }, eventName: 'Initialized', logIdx: 0, }), ]) expect(items[0]!.type).toBe('initialized') if (items[0]!.type !== 'initialized') return expect(items[0]!.data.height).toBe('42') }) test('keeps duplicate identical payments as separate activities', async () => { const recipient = '0x7777777777777777777777777777777777777777' as Address.Address const items = await classify([ transferRow({ from: viewer, logIdx: 0, to: recipient, token: tokenA, value: 10n }), transferRow({ from: viewer, logIdx: 1, to: recipient, token: tokenA, value: 10n }), ]) expect(items.map((item) => item.type)).toEqual(['transfer', 'transfer']) expect(new Set(items.map((item) => item.id)).size).toBe(2) }) test('folds zero-address transfer duplicates into explicit mint and burn events', async () => { const zero = '0x0000000000000000000000000000000000000000' as Address.Address const minted = await classify([ transferRow({ from: zero, logIdx: 0, to: viewer, token: tokenA, value: 10n }), logRow({ address: tokenA, args: { amount: 10n, to: viewer }, eventName: 'Mint', logIdx: 1 }), ]) const burned = await classify([ transferRow({ from: viewer, logIdx: 0, to: zero, token: tokenA, value: 10n }), logRow({ address: tokenA, args: { amount: 10n, from: viewer }, eventName: 'Burn', logIdx: 1, }), ]) expect(minted.map((item) => item.type)).toEqual(['mint']) expect(burned.map((item) => item.type)).toEqual(['burn']) }) test('classifies a sender-initiated mint to another recipient', async () => { const recipient = '0x7777777777777777777777777777777777777777' as Address.Address const items = await classify([ logRow({ address: tokenA, args: { amount: 10n, to: recipient }, eventName: 'Mint', logIdx: 0, }), ]) expect(items).toHaveLength(1) expect(items[0]?.type === 'mint' && items[0].data.recipient).toBe(recipient) }) test('keeps a payment batched after a share-burn withdrawal', async () => { const asset = '0x5555555555555555555555555555555555555555' as Address.Address const recipient = '0x7777777777777777777777777777777777777777' as Address.Address const zero = '0x0000000000000000000000000000000000000000' as Address.Address const items = await classify([ transferRow({ from: viewer, logIdx: 0, to: zero, token: tokenA, value: 100n }), transferRow({ from: tokenA, logIdx: 1, to: viewer, token: asset, value: 100n }), transferRow({ from: viewer, logIdx: 2, to: recipient, token: asset, value: 100n }), ]) expect(items.map((item) => item.type)).toEqual(['burn', 'transfer', 'transfer']) const payment = items[2] expect(payment?.type).toBe('transfer') if (payment?.type !== 'transfer') return expect(payment.data.direction).toBe('out') expect(payment.data.recipient).toBe(recipient) }) test('folds vault token legs without hiding an independent payment', async () => { const asset = '0x4444444444444444444444444444444444444444' as Address.Address const recipient = '0x5555555555555555555555555555555555555555' as Address.Address const zero = '0x0000000000000000000000000000000000000000' as Address.Address const items = await classify([ transferRow({ from: viewer, logIdx: 0, to: tokenA, token: asset, value: 100n }), transferRow({ from: zero, logIdx: 1, to: viewer, token: tokenA, value: 90n }), logRow({ address: tokenA, args: { assets: 100n, caller: viewer, earnShares: 90n, receiver: viewer }, eventName: 'Deposited', logIdx: 2, }), transferRow({ from: viewer, logIdx: 3, to: recipient, token: asset, value: 10n }), ]) expect(items.map((item) => item.type)).toEqual(['assets-deposited', 'transfer']) expect(items[1]?.type === 'transfer' && items[1].data.recipient).toBe(recipient) }) test('keeps an independent vault deposit beside a private Earn deposit', async () => { const actionId = `0x${'c'.repeat(64)}` as Hex.Hex const vaultB = '0x4444444444444444444444444444444444444444' as Address.Address const items = await classify([ logRow({ address: tokenA, args: { assets: 100n, caller: viewer, earnShares: 90n, receiver: viewer }, eventName: 'Deposited', logIdx: 0, }), logRow({ address: tokenA, args: { actionId, earnShares: 90n, earnVault: tokenA, inputAmount: 100n, inputToken: tokenA, vaultAssets: 100n, zoneDepositHash: actionId, }, eventName: 'EarnDeposit', logIdx: 1, }), logRow({ address: vaultB, args: { assets: 50n, caller: viewer, earnShares: 45n, receiver: viewer }, eventName: 'Deposited', logIdx: 2, }), ]) expect(items.map((item) => item.type)).toEqual(['private-assets-deposited', 'assets-deposited']) expect(items[1]?.type === 'assets-deposited' && items[1].data.vault).toBe(vaultB) }) test('keeps two identical swaps through the same route distinct', async () => { const tokenB = '0x5555555555555555555555555555555555555555' as Address.Address const items = await classify([ transferRow({ from: viewer, logIdx: 0, to: stablecoinDex, token: tokenA, value: 1_000n }), transferRow({ from: stablecoinDex, logIdx: 1, to: viewer, token: tokenB, value: 990n }), transferRow({ from: viewer, logIdx: 2, to: stablecoinDex, token: tokenA, value: 1_000n }), transferRow({ from: stablecoinDex, logIdx: 3, to: viewer, token: tokenB, value: 990n }), ]) expect(items.map((item) => item.type)).toEqual(['swap', 'swap']) expect(new Set(items.map((item) => item.id)).size).toBe(2) }) test('keeps an independent approval beside a swap', async () => { const tokenB = '0x5555555555555555555555555555555555555555' as Address.Address const tokenC = '0x6666666666666666666666666666666666666666' as Address.Address const spender = '0x7777777777777777777777777777777777777777' as Address.Address const items = await classify([ logRow({ address: tokenA, args: { amount: 1_000n, owner: viewer, spender }, eventName: 'Approval', logIdx: 0, }), transferRow({ from: viewer, logIdx: 1, to: stablecoinDex, token: tokenA, value: 1_000n }), transferRow({ from: stablecoinDex, logIdx: 2, to: viewer, token: tokenB, value: 990n }), logRow({ address: tokenC, args: { amount: 7n, owner: viewer, spender }, eventName: 'Approval', logIdx: 3, }), ]) expect(items.map((item) => item.type)).toEqual(['swap', 'approval']) expect(items[1]?.type === 'approval' && items[1].data.sourceToken.address).toBe(tokenC) }) test('classifies a swap routed through a per-route account', async () => { const router = '0xb92fe925dc43a0ecde6c8b1a2709c170ec4fff4f' as Address.Address const executor = '0xccc88a9d1b4ed6b0eaba998850414b24f1c315be' as Address.Address const pool = '0x8888888888888888888888888888888888888888' as Address.Address const tokenB = '0x5555555555555555555555555555555555555555' as Address.Address // Route evidence: an undecoded event the router emits mid-swap. const routerLog: Activities.Row = { ...transferRow({ from: viewer, logIdx: 2, to: router, token: tokenA, value: 0n }), data: '0x', logAddress: router, selector: `0x${'9'.repeat(64)}`, topic1: null, topic2: null, } const items = await classify([ logRow({ address: tokenA, args: { amount: 1_105n, owner: viewer, spender: executor }, eventName: 'Approval', logIdx: 0, }), transferRow({ from: viewer, logIdx: 1, to: router, token: tokenA, value: 1_105n }), routerLog, transferRow({ from: router, logIdx: 3, to: pool, token: tokenA, value: 1_105n }), transferRow({ from: pool, logIdx: 4, to: router, token: tokenB, value: 1_104n }), transferRow({ from: router, logIdx: 5, to: viewer, token: tokenB, value: 1_104n }), ]) expect(items.map((item) => item.type)).toEqual(['swap']) expect(items[0]?.type).toBe('swap') if (items[0]?.type !== 'swap') return expect(items[0].data.sourceToken).toEqual({ address: tokenA, amount: '1105' }) expect(items[0].data.destinationToken).toEqual({ address: tokenB, amount: '1104' }) }) test('keeps payments through a passive counterparty as transfers', async () => { const merchant = '0x7777777777777777777777777777777777777777' as Address.Address const other = '0x9999999999999999999999999999999999999999' as Address.Address const tokenB = '0x5555555555555555555555555555555555555555' as Address.Address const items = await classify([ transferRow({ from: viewer, logIdx: 0, to: merchant, token: tokenA, value: 100n }), transferRow({ from: merchant, logIdx: 1, to: other, token: tokenA, value: 30n }), transferRow({ from: merchant, logIdx: 2, to: viewer, token: tokenB, value: 50n }), ]) expect(items.map((item) => item.type)).toEqual(['transfer', 'transfer']) if (items[0]?.type !== 'transfer' || items[1]?.type !== 'transfer') return expect(items[0].data.direction).toBe('out') expect(items[1].data.direction).toBe('in') }) test('keeps interleaved DEX swaps by different swappers distinct', async () => { const other = '0x9999999999999999999999999999999999999999' as Address.Address const tokenB = '0x5555555555555555555555555555555555555555' as Address.Address const items = await classify([ transferRow({ from: viewer, logIdx: 0, to: stablecoinDex, token: tokenA, value: 1_000n }), transferRow({ from: other, logIdx: 1, to: stablecoinDex, token: tokenA, value: 500n }), transferRow({ from: stablecoinDex, logIdx: 2, to: viewer, token: tokenB, value: 990n }), transferRow({ from: stablecoinDex, logIdx: 3, to: other, token: tokenB, value: 495n }), ]) expect(items.map((item) => item.type)).toEqual(['swap', 'swap']) }) test('keeps a share-token payment beside a vault deposit of equal amounts', async () => { const asset = '0x4444444444444444444444444444444444444444' as Address.Address const recipient = '0x7777777777777777777777777777777777777777' as Address.Address const zero = '0x0000000000000000000000000000000000000000' as Address.Address const items = await classify([ transferRow({ from: viewer, logIdx: 0, to: recipient, token: tokenA, value: 90n }), transferRow({ from: viewer, logIdx: 1, to: tokenA, token: asset, value: 100n }), transferRow({ from: zero, logIdx: 2, to: viewer, token: tokenA, value: 90n }), logRow({ address: tokenA, args: { assets: 100n, caller: viewer, earnShares: 90n, receiver: viewer }, eventName: 'Deposited', logIdx: 3, }), ]) expect(items.map((item) => item.type)).toEqual(['transfer', 'assets-deposited']) expect(items[0]?.type === 'transfer' && items[0].data.recipient).toBe(recipient) }) test('keeps an inbound payment beside a refundless session close', async () => { const sender = '0x9999999999999999999999999999999999999999' as Address.Address const payment = transferRow({ from: sender, logIdx: 0, to: viewer, token: tokenA, value: 100n }) const items = await classify([ payment, { ...payment, data: '0x', logAddress: channelReserve, logIdx: 1, selector: '0xf5a36fc00a96cbb9cf1f8f59299165e1d8ffffe94396d82904b4da524d16bbce', topic1: null, topic2: null, }, ]) expect(items.map((item) => item.type)).toEqual(['transfer', 'session-closed']) expect(items[1]?.type === 'session-closed' && items[1].data.refund).toBeUndefined() }) test('folds the router funding leg into a private Earn deposit', async () => { const actionId = `0x${'c'.repeat(64)}` as Hex.Hex const router = '0x9999999999999999999999999999999999999999' as Address.Address const asset = '0x4444444444444444444444444444444444444444' as Address.Address const items = await classify([ transferRow({ from: viewer, logIdx: 0, to: router, token: asset, value: 100n }), logRow({ address: router, args: { actionId, earnShares: 90n, earnVault: tokenA, inputAmount: 100n, inputToken: asset, vaultAssets: 100n, zoneDepositHash: actionId, }, eventName: 'EarnDeposit', logIdx: 1, }), ]) expect(items.map((item) => item.type)).toEqual(['private-assets-deposited']) }) test('keeps a mint whose paired event decodes without a recipient', async () => { const recipient = '0x7777777777777777777777777777777777777777' as Address.Address const zero = '0x0000000000000000000000000000000000000000' as Address.Address const malformedMint = logRow({ address: tokenA, args: { amount: 10n, to: viewer }, eventName: 'Mint', logIdx: 2, }) const items = await classify([ transferRow({ from: viewer, logIdx: 0, to: recipient, token: tokenA, value: 5n }), transferRow({ from: zero, logIdx: 1, to: viewer, token: tokenA, value: 10n }), // The indexed recipient topic is stripped, so the Mint decodes without // `to` and must not fold away the zero-address transfer. { ...malformedMint, topic1: null }, ]) expect(items.map((item) => item.type)).toEqual(['transfer', 'mint']) }) test('emits an external vault withdrawal paid out to the viewer', async () => { const other = '0x9999999999999999999999999999999999999999' as Address.Address const asset = '0x4444444444444444444444444444444444444444' as Address.Address const items = await classify([ transferRow({ from: tokenA, logIdx: 0, to: viewer, token: asset, value: 100n }), logRow({ address: tokenA, args: { assets: 100n, owner: other, receiver: other, sender: other, shares: 90n }, eventName: 'Withdraw', logIdx: 1, }), ]) expect(items.map((item) => item.type)).toEqual(['assets-withdrawn']) }) test('classifies a route delivery to a third party as a cross-token transfer', async () => { const router = '0xb92fe925dc43a0ecde6c8b1a2709c170ec4fff4f' as Address.Address const recipient = '0x7bc2d22a73706758505e222ed9d13a1a7f251490' as Address.Address const tokenB = '0x5555555555555555555555555555555555555555' as Address.Address const routerLog: Activities.Row = { ...transferRow({ from: viewer, logIdx: 1, to: router, token: tokenA, value: 0n }), data: '0x', logAddress: router, selector: `0x${'9'.repeat(64)}`, topic1: null, topic2: null, } const items = await classify([ transferRow({ from: viewer, logIdx: 0, to: router, token: tokenA, value: 38_129n }), routerLog, transferRow({ from: router, logIdx: 2, to: recipient, token: tokenB, value: 38_127n }), ]) expect(items.map((item) => item.type)).toEqual(['transfer']) const payment = items[0] if (payment?.type !== 'transfer') return expect(payment.data.recipient).toBe(recipient) expect(payment.data.sourceToken).toEqual({ address: tokenA, amount: '38129' }) expect(payment.data.destinationToken).toEqual({ address: tokenB, amount: '38127' }) }) test('folds the exact allowance granted to a vault into its deposit', async () => { const asset = '0x4444444444444444444444444444444444444444' as Address.Address const zero = '0x0000000000000000000000000000000000000000' as Address.Address const items = await classify([ logRow({ address: asset, args: { amount: 100n, owner: viewer, spender: tokenA }, eventName: 'Approval', logIdx: 0, }), transferRow({ from: viewer, logIdx: 1, to: tokenA, token: asset, value: 100n }), transferRow({ from: zero, logIdx: 2, to: viewer, token: tokenA, value: 90n }), logRow({ address: tokenA, args: { assets: 100n, caller: viewer, earnShares: 90n, receiver: viewer }, eventName: 'Deposited', logIdx: 3, }), ]) expect(items.map((item) => item.type)).toEqual(['assets-deposited']) }) test('keeps a payment and a later refund in another token as transfers', async () => { const merchant = '0x7777777777777777777777777777777777777777' as Address.Address const tokenB = '0x5555555555555555555555555555555555555555' as Address.Address const items = await classify([ transferRow({ from: viewer, logIdx: 0, to: merchant, token: tokenA, value: 100n }), transferRow({ from: merchant, logIdx: 1, to: viewer, token: tokenB, value: 90n }), ]) expect(items.map((item) => item.type)).toEqual(['transfer', 'transfer']) }) test('folds an auto-flip order placement into its swap', async () => { const tokenB = '0x5555555555555555555555555555555555555555' as Address.Address const items = await classify([ transferRow({ from: viewer, logIdx: 0, to: stablecoinDex, token: tokenA, value: 1_000n }), transferRow({ from: stablecoinDex, logIdx: 1, to: viewer, token: tokenB, value: 990n }), logRow({ address: stablecoinDex, args: { amount: 25n, flipTick: 6, isBid: false, isFlipOrder: true, maker: viewer, orderId: 43n, tick: 5, token: tokenA, }, eventName: 'OrderPlaced', logIdx: 2, }), ]) expect(items.map((item) => item.type)).toEqual(['swap']) }) test('folds both fee legs into a RebalanceSwap', async () => { const tokenB = '0x4444444444444444444444444444444444444444' as Address.Address const items = await classify([ transferRow({ from: viewer, logIdx: 0, to: feeManager, token: tokenA, value: 10n }), logRow({ address: feeManager, args: { amountIn: 10n, amountOut: 9n, swapper: viewer, userToken: tokenB, validatorToken: tokenA, }, eventName: 'RebalanceSwap', logIdx: 1, }), transferRow({ from: feeManager, logIdx: 2, to: viewer, token: tokenB, value: 9n }), ]) expect(items.map((item) => item.type)).toEqual(['fee-rebalance-swap']) }) test('folds a CloseRequested refund into session-closed', async () => { const refund = transferRow({ from: channelReserve, logIdx: 0, to: viewer, token: tokenA, value: 25n, }) const items = await classify([ refund, { ...refund, data: '0x', logAddress: channelReserve, logIdx: 1, selector: '0xf5a36fc00a96cbb9cf1f8f59299165e1d8ffffe94396d82904b4da524d16bbce', topic1: null, topic2: null, }, ]) expect(items.map((item) => item.type)).toEqual(['session-closed']) expect(items[0]?.type === 'session-closed' && items[0].data.refund?.amount).toBe('25') }) test('keeps batched channel settlements paired with their own payout legs', async () => { const payee = '0x7777777777777777777777777777777777777777' as Address.Address const tokenB = '0x5555555555555555555555555555555555555555' as Address.Address const channelA = `0x${'a'.repeat(64)}` as Hex.Hex const channelB = `0x${'b'.repeat(64)}` as Hex.Hex const items = await classify([ transferRow({ from: channelReserve, logIdx: 0, to: payee, token: tokenA, value: 250n }), logRow({ address: channelReserve, args: { channelId: channelA, cumulativeAmount: 250n, deltaPaid: 250n, newSettled: 250n, payee, payer: viewer, }, eventName: 'Settled', logIdx: 1, }), transferRow({ from: channelReserve, logIdx: 2, to: payee, token: tokenB, value: 300n }), logRow({ address: channelReserve, args: { channelId: channelB, cumulativeAmount: 300n, deltaPaid: 300n, newSettled: 300n, payee, payer: viewer, }, eventName: 'Settled', logIdx: 3, }), ]) expect(items.map((item) => item.type)).toEqual(['channel-settled', 'channel-settled']) if (items[0]?.type !== 'channel-settled' || items[1]?.type !== 'channel-settled') return expect(items[0].data.sourceToken.address).toBe(tokenA) expect(items[1].data.sourceToken.address).toBe(tokenB) }) test('keeps a role grant made after token creation', async () => { const otherAccount = '0x7777777777777777777777777777777777777777' as Address.Address const items = await classify([ logRow({ address: tokenA, args: { account: viewer, hasRole: true, role: `0x${'1'.repeat(64)}`, sender: viewer }, eventName: 'RoleMembershipUpdated', logIdx: 0, }), logRow({ address: tip20Factory, args: { admin: viewer, currency: 'USD', name: 'Test', quoteToken: tokenA, salt: `0x${'0'.repeat(64)}`, symbol: 'TST', token: tokenA, }, eventName: 'TokenCreated', logIdx: 1, }), logRow({ address: tokenA, args: { account: otherAccount, hasRole: true, role: `0x${'2'.repeat(64)}`, sender: viewer, }, eventName: 'RoleMembershipUpdated', logIdx: 2, }), ]) expect(items.map((item) => item.type)).toEqual(['token-created', 'role-membership-set']) expect(items[1]?.type === 'role-membership-set' && items[1].data.account).toBe(otherAccount) }) test('keeps a whitelist update made after its policy was created', async () => { const account = '0x7777777777777777777777777777777777777777' as Address.Address const items = await classify([ logRow({ address: tokenA, args: { account, allowed: true, policyId: 9n, updater: viewer }, eventName: 'WhitelistUpdated', logIdx: 0, }), logRow({ address: tokenA, args: { policyId: 9n, policyType: 1, updater: viewer }, eventName: 'PolicyCreated', logIdx: 1, }), logRow({ address: tokenA, args: { account, allowed: false, policyId: 9n, updater: viewer }, eventName: 'WhitelistUpdated', logIdx: 2, }), ]) expect(items.map((item) => item.type)).toEqual(['policy-created', 'whitelist-updated']) }) test('keeps an unrelated validator addition beside a rotation', async () => { const validator = '0x9999999999999999999999999999999999999999' as Address.Address const feeRecipient = '0xabababababababababababababababababababab' as Address.Address const items = await classify([ logRow({ address: accountKeychain, args: { index: 1n, validatorAddress: viewer }, eventName: 'ValidatorDeactivated', logIdx: 0, }), logRow({ address: accountKeychain, args: { egress: 'https://v.example.com:2', feeRecipient, index: 2n, ingress: 'https://v.example.com:1', publicKey: `0x${'2'.repeat(64)}`, validatorAddress: validator, }, eventName: 'ValidatorAdded', logIdx: 1, }), logRow({ address: accountKeychain, args: { caller: viewer, deactivatedIndex: 1n, egress: 'https://v.example.com:2', index: 2n, ingress: 'https://v.example.com:1', newPublicKey: `0x${'2'.repeat(64)}`, oldPublicKey: `0x${'1'.repeat(64)}`, validatorAddress: validator, }, eventName: 'ValidatorRotated', logIdx: 2, }), logRow({ address: accountKeychain, args: { egress: 'https://v.example.com:4', feeRecipient, index: 3n, ingress: 'https://v.example.com:3', publicKey: `0x${'4'.repeat(64)}`, validatorAddress: feeRecipient, }, eventName: 'ValidatorAdded', logIdx: 3, }), ]) expect(items.map((item) => item.type)).toEqual(['validator-rotated', 'validator-added']) }) test('keeps an access-key authorization beside a vault deposit', async () => { const publicKey = '0x9999999999999999999999999999999999999999' as Address.Address const items = await classify([ logRow({ address: tokenA, args: { assets: 100n, caller: viewer, earnShares: 90n, receiver: viewer }, eventName: 'Deposited', logIdx: 0, }), logRow({ address: accountKeychain, args: { account: viewer, expiry: 1_800_000_000n, publicKey, signatureType: 1 }, eventName: 'KeyAuthorized', logIdx: 1, }), ]) expect(items.map((item) => item.type)).toEqual(['assets-deposited', 'access-key-created']) }) test('keeps both directions of a receipt-less transaction', async () => { const other = '0x9999999999999999999999999999999999999999' as Address.Address const q2 = transferRow({ from: other, logIdx: 0, to: viewer, token: tokenA, value: 10n }) const q3 = transferRow({ from: viewer, logIdx: 1, to: other, token: tokenA, value: 5n }) const merged = Activities.mergeAndSort([], [q2], [q3]) expect(merged.map((row) => row.logIdx)).toEqual([0, 1]) }) test('keeps duplicate payments when one carries a memo', async () => { const recipient = '0x7777777777777777777777777777777777777777' as Address.Address const items = await classify([ transferRow({ from: viewer, logIdx: 0, to: recipient, token: tokenA, value: 10n }), logRow({ address: tokenA, args: { amount: 10n, from: viewer, memo: `0x${'1'.repeat(64)}`, to: recipient }, eventName: 'TransferWithMemo', logIdx: 1, }), transferRow({ from: viewer, logIdx: 2, to: recipient, token: tokenA, value: 10n }), ]) expect(items.map((item) => item.type)).toEqual(['transfer', 'transfer']) }) test('keeps a settlement payout beside a refundless session close', async () => { const payee = viewer const closeRow = { ...transferRow({ from: channelReserve, logIdx: 0, to: viewer, token: tokenA, value: 0n }), data: '0x', logAddress: channelReserve, logIdx: 0, selector: '0xf5a36fc00a96cbb9cf1f8f59299165e1d8ffffe94396d82904b4da524d16bbce', topic1: null, topic2: null, } const items = await classify([ closeRow, transferRow({ from: channelReserve, logIdx: 1, to: payee, token: tokenA, value: 250n }), logRow({ address: channelReserve, args: { channelId: `0x${'c'.repeat(64)}`, cumulativeAmount: 250n, deltaPaid: 250n, newSettled: 250n, payee, payer: viewer, }, eventName: 'Settled', logIdx: 2, }), ]) expect(items.map((item) => item.type)).toEqual(['session-closed', 'channel-settled']) if (items[0]?.type !== 'session-closed' || items[1]?.type !== 'channel-settled') return expect(items[0].data.refund).toBeUndefined() expect(items[1].data.sourceToken.address).toBe(tokenA) }) test('keeps an order placement beside a swap in one batch', async () => { const tokenB = '0x5555555555555555555555555555555555555555' as Address.Address const tokenC = '0x6666666666666666666666666666666666666666' as Address.Address const items = await classify([ transferRow({ from: viewer, logIdx: 0, to: stablecoinDex, token: tokenA, value: 500n }), logRow({ address: stablecoinDex, args: { amount: 500n, flipTick: 0, isBid: true, isFlipOrder: false, maker: viewer, orderId: 7n, tick: 3, token: tokenA, }, eventName: 'OrderPlaced', logIdx: 1, }), transferRow({ from: viewer, logIdx: 2, to: stablecoinDex, token: tokenB, value: 1_000n }), transferRow({ from: stablecoinDex, logIdx: 3, to: viewer, token: tokenC, value: 990n }), ]) expect(items.map((item) => item.type)).toEqual(['order-placed', 'swap']) if (items[1]?.type !== 'swap') return expect(items[1].data.sourceToken).toEqual({ address: tokenB, amount: '1000' }) }) test("keeps the viewer's own fill beside a third-party swap", async () => { const other = '0x9999999999999999999999999999999999999999' as Address.Address const tokenB = '0x5555555555555555555555555555555555555555' as Address.Address const items = await classify([ transferRow({ from: other, logIdx: 0, to: stablecoinDex, token: tokenA, value: 1_000n }), logRow({ address: stablecoinDex, args: { amountFilled: 1_000n, maker: viewer, orderId: 7n, partialFill: false, taker: other, }, eventName: 'OrderFilled', logIdx: 1, }), transferRow({ from: stablecoinDex, logIdx: 2, to: other, token: tokenB, value: 990n }), ]) expect(items.map((item) => item.type)).toEqual(['swap', 'order-filled']) }) test('folds the amount-matched vault event into a private Earn deposit', async () => { const actionId = `0x${'c'.repeat(64)}` as Hex.Hex const items = await classify([ logRow({ address: tokenA, args: { assets: 50n, caller: viewer, earnShares: 45n, receiver: viewer }, eventName: 'Deposited', logIdx: 0, }), logRow({ address: tokenA, args: { assets: 100n, caller: viewer, earnShares: 90n, receiver: viewer }, eventName: 'Deposited', logIdx: 1, }), logRow({ address: tokenA, args: { actionId, earnShares: 90n, earnVault: tokenA, inputAmount: 100n, inputToken: tokenA, vaultAssets: 100n, zoneDepositHash: actionId, }, eventName: 'EarnDeposit', logIdx: 2, }), ]) expect(items.map((item) => item.type)).toEqual(['assets-deposited', 'private-assets-deposited']) expect(items[0]?.type === 'assets-deposited' && items[0].data.assets).toBe('50') }) test('surfaces a fold-only vault operation when nothing else classified', async () => { const other = '0x9999999999999999999999999999999999999999' as Address.Address const asset = '0x4444444444444444444444444444444444444444' as Address.Address const zero = '0x0000000000000000000000000000000000000000' as Address.Address const items = await classify([ transferRow({ from: other, logIdx: 0, to: zero, token: tokenA, value: 90n }), transferRow({ from: tokenA, logIdx: 1, to: other, token: asset, value: 100n }), logRow({ address: tokenA, args: { assets: 100n, owner: other, receiver: other, sender: other, shares: 90n }, eventName: 'Withdraw', logIdx: 2, }), ]) expect(items.map((item) => item.type)).toEqual(['assets-withdrawn']) }) test('releases claimed legs when an anchor is partially malformed', async () => { const asset = '0x4444444444444444444444444444444444444444' as Address.Address const corrupted = logRow({ address: tokenA, args: { assets: 100n, caller: viewer, earnShares: 90n, receiver: viewer }, eventName: 'Deposited', logIdx: 1, }) const items = await classify([ transferRow({ from: viewer, logIdx: 0, to: tokenA, token: asset, value: 100n }), // Valid amounts with the indexed parties stripped: the builder throws // after claiming the asset leg, which must be released. { ...corrupted, topic1: null, topic2: null }, ]) expect(items.map((item) => item.type)).toEqual(['transfer']) }) test('keeps a cross-token payment beside a same-amount memo payment', async () => { const recipient = '0x7777777777777777777777777777777777777777' as Address.Address const tokenB = '0x5555555555555555555555555555555555555555' as Address.Address const items = await classify([ transferRow({ from: viewer, logIdx: 0, to: recipient, token: tokenB, value: 10n }), logRow({ address: tokenA, args: { amount: 10n, from: viewer, memo: `0x${'1'.repeat(64)}`, to: recipient }, eventName: 'TransferWithMemo', logIdx: 1, }), ]) expect(items.map((item) => item.type)).toEqual(['transfer', 'transfer']) }) test('keeps chained swaps through two routes distinct', async () => { const routeA = '0xb92fe925dc43a0ecde6c8b1a2709c170ec4fff4f' as Address.Address const routeB = '0xcafe000000000000000000000000000000000001' as Address.Address const tokenB = '0x5555555555555555555555555555555555555555' as Address.Address const tokenC = '0x6666666666666666666666666666666666666666' as Address.Address const routeLog = (route: Address.Address, logIdx: number): Activities.Row => ({ ...transferRow({ from: viewer, logIdx, to: route, token: tokenA, value: 0n }), data: '0x', logAddress: route, selector: `0x${'9'.repeat(64)}`, topic1: null, topic2: null, }) const items = await classify([ transferRow({ from: viewer, logIdx: 0, to: routeA, token: tokenA, value: 1_000n }), routeLog(routeA, 1), transferRow({ from: routeA, logIdx: 2, to: viewer, token: tokenB, value: 990n }), transferRow({ from: viewer, logIdx: 3, to: routeB, token: tokenB, value: 990n }), routeLog(routeB, 4), transferRow({ from: routeB, logIdx: 5, to: viewer, token: tokenC, value: 980n }), ]) expect(items.map((item) => item.type)).toEqual(['swap', 'swap']) }) test('keeps an approval to an unrelated spender beside a swap', async () => { const spender = '0x7777777777777777777777777777777777777777' as Address.Address const tokenB = '0x5555555555555555555555555555555555555555' as Address.Address const gap: Activities.Row = { ...transferRow({ from: viewer, logIdx: 1, to: spender, token: tokenA, value: 0n }), data: '0x', logAddress: accountKeychain, selector: `0x${'8'.repeat(64)}`, topic1: null, topic2: null, } const items = await classify([ logRow({ address: tokenA, args: { amount: 1_000n, owner: viewer, spender }, eventName: 'Approval', logIdx: 0, }), gap, transferRow({ from: viewer, logIdx: 2, to: stablecoinDex, token: tokenA, value: 1_000n }), transferRow({ from: stablecoinDex, logIdx: 3, to: viewer, token: tokenB, value: 990n }), ]) expect(items.map((item) => item.type)).toEqual(['approval', 'swap']) }) test('keeps a settlement leg away from a zero-value channel close', async () => { const payee = viewer const channelA = `0x${'a'.repeat(64)}` as Hex.Hex const channelB = `0x${'b'.repeat(64)}` as Hex.Hex const items = await classify([ logRow({ address: channelReserve, args: { channelId: channelA, payee, payer: viewer, refundedToPayer: 0n, settledToPayee: 0n, }, eventName: 'ChannelClosed', logIdx: 0, }), transferRow({ from: channelReserve, logIdx: 1, to: payee, token: tokenA, value: 250n }), logRow({ address: channelReserve, args: { channelId: channelB, cumulativeAmount: 250n, deltaPaid: 250n, newSettled: 250n, payee, payer: viewer, }, eventName: 'Settled', logIdx: 2, }), ]) expect(items.map((item) => item.type)).toEqual(['channel-closed', 'channel-settled']) if (items[1]?.type !== 'channel-settled') return expect(items[1].data.sourceToken.address).toBe(tokenA) }) test('keeps an inbound fee-manager payment beside a fee distribution', async () => { const validator = '0xcccccccc00000000000000000000000000000000' as Address.Address const items = await classify([ transferRow({ from: feeManager, logIdx: 0, to: viewer, token: tokenA, value: 77n }), transferRow({ from: feeManager, logIdx: 1, to: validator, token: tokenA, value: 77n }), logRow({ address: feeManager, args: { amount: 77n, token: tokenA, validator }, eventName: 'FeesDistributed', logIdx: 2, }), ]) expect(items.map((item) => item.type)).toEqual(['transfer', 'fees-distributed']) }) test('keeps a same-amount payment beside a reward funding', async () => { const other = '0x7777777777777777777777777777777777777777' as Address.Address const items = await classify([ transferRow({ from: viewer, logIdx: 0, to: other, token: tokenA, value: 77n }), transferRow({ from: viewer, logIdx: 1, to: tokenA, token: tokenA, value: 77n }), logRow({ address: tokenA, args: { amount: 77n, funder: viewer }, eventName: 'RewardDistributed', logIdx: 2, }), ]) expect(items.map((item) => item.type)).toEqual(['transfer', 'reward-distributed']) }) test('pairs an order with its own escrow after a same-token swap', async () => { const tokenB = '0x5555555555555555555555555555555555555555' as Address.Address const items = await classify([ transferRow({ from: viewer, logIdx: 0, to: stablecoinDex, token: tokenA, value: 500n }), transferRow({ from: stablecoinDex, logIdx: 1, to: viewer, token: tokenB, value: 495n }), transferRow({ from: viewer, logIdx: 2, to: stablecoinDex, token: tokenA, value: 500n }), logRow({ address: stablecoinDex, args: { amount: 500n, flipTick: 0, isBid: true, isFlipOrder: false, maker: viewer, orderId: 7n, tick: 3, token: tokenA, }, eventName: 'OrderPlaced', logIdx: 3, }), ]) expect(items.map((item) => item.type)).toEqual(['swap', 'order-placed']) }) test('pairs interleaved DEX swaps with their own outputs', async () => { const other = '0x9999999999999999999999999999999999999999' as Address.Address const tokenB = '0x5555555555555555555555555555555555555555' as Address.Address const items = await classify([ transferRow({ from: other, logIdx: 0, to: stablecoinDex, token: tokenA, value: 1_000n }), transferRow({ from: viewer, logIdx: 1, to: stablecoinDex, token: tokenA, value: 500n }), transferRow({ from: stablecoinDex, logIdx: 2, to: viewer, token: tokenB, value: 495n }), transferRow({ from: stablecoinDex, logIdx: 3, to: other, token: tokenB, value: 990n }), ]) expect(items.map((item) => item.type)).toEqual(['swap', 'swap']) if (items[0]?.type !== 'swap' || items[1]?.type !== 'swap') return expect(items[0].data.destinationToken.amount).toBe('990') expect(items[1].data.destinationToken.amount).toBe('495') }) test('keeps an unrelated-token approval to the vault beside a deposit', async () => { const asset = '0x4444444444444444444444444444444444444444' as Address.Address const tokenC = '0x6666666666666666666666666666666666666666' as Address.Address const zero = '0x0000000000000000000000000000000000000000' as Address.Address const items = await classify([ logRow({ address: tokenC, args: { amount: 100n, owner: viewer, spender: tokenA }, eventName: 'Approval', logIdx: 0, }), transferRow({ from: viewer, logIdx: 1, to: tokenA, token: asset, value: 100n }), transferRow({ from: zero, logIdx: 2, to: viewer, token: tokenA, value: 90n }), logRow({ address: tokenA, args: { assets: 100n, caller: viewer, earnShares: 90n, receiver: viewer }, eventName: 'Deposited', logIdx: 3, }), ]) expect(items.map((item) => item.type)).toEqual(['approval', 'assets-deposited']) }) test('folds the nearest preceding vault event into a private Earn deposit', async () => { const actionId = `0x${'c'.repeat(64)}` as Hex.Hex const items = await classify([ logRow({ address: tokenA, args: { assets: 100n, caller: viewer, earnShares: 90n, receiver: viewer }, eventName: 'Deposited', logIdx: 0, }), logRow({ address: tokenA, args: { assets: 100n, caller: viewer, earnShares: 90n, receiver: viewer }, eventName: 'Deposited', logIdx: 1, }), logRow({ address: tokenA, args: { actionId, earnShares: 90n, earnVault: tokenA, inputAmount: 100n, inputToken: tokenA, vaultAssets: 100n, zoneDepositHash: actionId, }, eventName: 'EarnDeposit', logIdx: 2, }), ]) expect(items.map((item) => item.type)).toEqual(['assets-deposited', 'private-assets-deposited']) expect(items[0]?.id.endsWith('-0')).toBe(true) }) test('keeps earlier activities when a later event is malformed', async () => { const recipient = '0x7777777777777777777777777777777777777777' as Address.Address const corrupted = logRow({ address: tokenA, args: { assets: 100n, caller: viewer, earnShares: 90n, receiver: viewer }, eventName: 'Deposited', logIdx: 1, }) const items = await classify([ transferRow({ from: viewer, logIdx: 0, to: recipient, token: tokenA, value: 10n }), // Valid topics with truncated data: the event decodes but its builder // cannot read the amounts, which must not discard the whole transaction. { ...corrupted, data: '0x' }, ]) expect(items.map((item) => item.type)).toEqual(['transfer']) }) }) /** * Replays real Tempo mainnet receipts (raw logs, topics, and data) through the * classifier, covering batch transactions whose activities the old headline * classifier collapsed or misread. */ describe('Activities.list receipt replays', () => { const viewer = '0x03ef854275a0f914a0f34db6756a59f20c821989' as Address.Address const txHash = `0x${'d'.repeat(64)}` as Hex.Hex function store() { const store = Store.memory() void store.put('mpp-services:v1', JSON.stringify({})) return store } type ReceiptLog = { address: string data: string logIdx: number topics: readonly string[] } /** Maps a raw receipt log to a Q1 row, viewer as the transaction sender. */ function receiptRow(log: ReceiptLog): Activities.Row { return { blockNum: 100, blockTimestamp: 1_700_000_000, data: log.data, feePayer: null, logAddress: log.address as Address.Address, logIdx: log.logIdx, selector: log.topics[0] ?? null, source: 'q1', topic1: log.topics[1] ?? null, topic2: log.topics[2] ?? null, topic3: log.topics[3] ?? null, txHash, txIdx: 0, txSender: viewer, } } async function classify(logs: readonly ReceiptLog[]) { const provider: Activities.Provider = { cursorForGroup: (group) => `${group.blockNum}:${group.txIdx}`, fetchRows: async () => ({ rows: logs.map(receiptRow) }), } const { items } = await Activities.list(provider, { address: viewer, store: store() }) return items } const routerSwapLogs = [ { address: '0x20c000000000000000000000b9537d11c60e8b50', data: '0x000000000000000000000000000000000000000000000000000000000010dc68', logIdx: 0, topics: [ '0x8c5be1e5ebec7d5bd14f71427d1e84f3dd0314c0f7b2291e5b200ac8c7c3b925', '0x00000000000000000000000003ef854275a0f914a0f34db6756a59f20c821989', '0x000000000000000000000000ccc88a9d1b4ed6b0eaba998850414b24f1c315be', ], }, { address: '0x20c000000000000000000000b9537d11c60e8b50', data: '0x000000000000000000000000000000000000000000000000000000000010dc68', logIdx: 1, topics: [ '0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef', '0x00000000000000000000000003ef854275a0f914a0f34db6756a59f20c821989', '0x000000000000000000000000b92fe925dc43a0ecde6c8b1a2709c170ec4fff4f', ], }, { address: '0xccc88a9d1b4ed6b0eaba998850414b24f1c315be', data: '0x00000000000000000000000003ef854275a0f914a0f34db6756a59f20c821989000000000000000000000000b92fe925dc43a0ecde6c8b1a2709c170ec4fff4f00000000000000000000000020c000000000000000000000b9537d11c60e8b50000000000000000000000000000000000000000000000000000000000010dc6800000000000000000000000000000000000000000000000000000000000000a000000000000000000000000000000000000000000000000000000000000000217eb7a8dfb83149821d35b62fbd7880e2fe6e29f04133156c1adb203c95d7e2a40000000000000000000000000000000000000000000000000000000000000000', logIdx: 2, topics: ['0xafbab204e8271965231d37baed9b1abca8725b7409c70314455f68bc89142b91'], }, { address: '0x20c000000000000000000000b9537d11c60e8b50', data: '0x000000000000000000000000000000000000000000000000000000000010dc68', logIdx: 3, topics: [ '0x8c5be1e5ebec7d5bd14f71427d1e84f3dd0314c0f7b2291e5b200ac8c7c3b925', '0x000000000000000000000000b92fe925dc43a0ecde6c8b1a2709c170ec4fff4f', '0x0000000000000000000000000000000000001ff3684f28c67538d4d072c22734', ], }, { address: '0xb92fe925dc43a0ecde6c8b1a2709c170ec4fff4f', data: '0x00000000000000000000000020c000000000000000000000b9537d11c60e8b50000000000000000000000000000000000000000000000000000000000000006000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000044095ea7b30000000000000000000000000000000000001ff3684f28c67538d4d072c22734000000000000000000000000000000000000000000000000000000000010dc6800000000000000000000000000000000000000000000000000000000', logIdx: 4, topics: ['0x93485dcd31a905e3ffd7b012abe3438fa8fa77f98ddc9f50e879d3fa7ccdc324'], }, { address: '0x20c000000000000000000000b9537d11c60e8b50', data: '0x00000000000000000000000000000000000000000000000000000000000003ab', logIdx: 5, topics: [ '0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef', '0x000000000000000000000000b92fe925dc43a0ecde6c8b1a2709c170ec4fff4f', '0x000000000000000000000000f70da97812cb96acdf810712aa562db8dfa3dbef', ], }, { address: '0xb92fe925dc43a0ecde6c8b1a2709c170ec4fff4f', data: '0x000000000000000000000000b92fe925dc43a0ecde6c8b1a2709c170ec4fff4f000000000000000000000000f70da97812cb96acdf810712aa562db8dfa3dbef00000000000000000000000020c000000000000000000000b9537d11c60e8b5000000000000000000000000000000000000000000000000000000000000003ab00000000000000000000000000000000000000000000000000000000000000a000000000000000000000000000000000000000000000000000000000000000217eb7a8dfb83149821d35b62fbd7880e2fe6e29f04133156c1adb203c95d7e2a40100000000000000000000000000000000000000000000000000000000000000', logIdx: 6, topics: ['0xafbab204e8271965231d37baed9b1abca8725b7409c70314455f68bc89142b91'], }, { address: '0xb92fe925dc43a0ecde6c8b1a2709c170ec4fff4f', data: '0x000000000000000000000000b92fe925dc43a0ecde6c8b1a2709c170ec4fff4f0000000000000000000000000000000000000000000000000000000000000060000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001a49bb43718000000000000000000000000000000000000000000000000000000000000008000000000000000000000000000000000000000000000000000000000000000c000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000000000000140000000000000000000000000000000000000000000000000000000000000000100000000000000000000000020c000000000000000000000b9537d11c60e8b500000000000000000000000000000000000000000000000000000000000000001000000000000000000000000f70da97812cb96acdf810712aa562db8dfa3dbef000000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000000003ab00000000000000000000000000000000000000000000000000000000000000217eb7a8dfb83149821d35b62fbd7880e2fe6e29f04133156c1adb203c95d7e2a4010000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000', logIdx: 7, topics: ['0x93485dcd31a905e3ffd7b012abe3438fa8fa77f98ddc9f50e879d3fa7ccdc324'], }, { address: '0x33620f62c5b9b2086dd6b62f4a297a9f30347029', data: '0x000000000000000000000000000000000000000000000000000000000010d8dcffffffffffffffffffffffffffffffffffffffffffffffffffffffffffef27430000000000000000000000000000000000000000fffbc94d34a6b108b32b149a00000000000000000000000000000000000000000000000000001d36428c070cfffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffe0000000000000000000000000000000000000000000000000000000000000064', logIdx: 8, topics: [ '0x40e9cecb9f5f1f1c5b9c97dec2917b7ee92e57ba5563708daca94dd84ad7112f', '0xbd170b4ccb7f5da2565e00d5bbfa4777187b20ffb824412766463c162a47d78a', '0x000000000000000000000000e59f2f6ace8b9f985900f14462fbaa40385ce441', ], }, { address: '0x20c0000000000000000000000000000000000000', data: '0x000000000000000000000000000000000000000000000000000000000010d8dc', logIdx: 9, topics: [ '0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef', '0x00000000000000000000000033620f62c5b9b2086dd6b62f4a297a9f30347029', '0x000000000000000000000000e59f2f6ace8b9f985900f14462fbaa40385ce441', ], }, { address: '0x20c000000000000000000000b9537d11c60e8b50', data: '0x000000000000000000000000000000000000000000000000000000000010d8bd', logIdx: 10, topics: [ '0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef', '0x000000000000000000000000b92fe925dc43a0ecde6c8b1a2709c170ec4fff4f', '0x00000000000000000000000033620f62c5b9b2086dd6b62f4a297a9f30347029', ], }, { address: '0x20c0000000000000000000000000000000000000', data: '0x000000000000000000000000000000000000000000000000000000000010d8dc', logIdx: 11, topics: [ '0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef', '0x000000000000000000000000e59f2f6ace8b9f985900f14462fbaa40385ce441', '0x000000000000000000000000b92fe925dc43a0ecde6c8b1a2709c170ec4fff4f', ], }, { address: '0xb92fe925dc43a0ecde6c8b1a2709c170ec4fff4f', data: '0x0000000000000000000000000000000000001ff3684f28c67538d4d072c227340000000000000000000000000000000000000000000000000000000000000060000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000004e42213bc0b000000000000000000000000e59f2f6ace8b9f985900f14462fbaa40385ce44100000000000000000000000020c000000000000000000000b9537d11c60e8b50000000000000000000000000000000000000000000000000000000000010d8bd000000000000000000000000e59f2f6ace8b9f985900f14462fbaa40385ce44100000000000000000000000000000000000000000000000000000000000000a000000000000000000000000000000000000000000000000000000000000004041fff991f000000000000000000000000b92fe925dc43a0ecde6c8b1a2709c170ec4fff4f00000000000000000000000020c0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000010c34a00000000000000000000000000000000000000000000000000000000000000a0d70e27de7eb4ff3a242a1d170af43c00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000004000000000000000000000000000000000000000000000000000000000000002800000000000000000000000000000000000000000000000000000000000000204931997d3000000000000000000000000e59f2f6ace8b9f985900f14462fbaa40385ce44100000000000000000000000020c000000000000000000000b9537d11c60e8b50000000000000000000000000000000000000000000000000000000000010d8bd0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000006a862ef800000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000ffffffffffffffc5000000000000000000000000000000000000000000000000000000000000016000000000000000000000000000000000000000000000000000000000000001e0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000482710fffd8963efd1fc6a506488495d951d5263988d250120c00000000000000000000000000000000000000000640000010000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000008434ee90ca000000000000000000000000f5c4f3dc02c3fb9279495a8fef7b0741da95615700000000000000000000000020c0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000010d98b0000000000000000000000000000000000000000000000000000000000002710000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000', logIdx: 12, topics: ['0x93485dcd31a905e3ffd7b012abe3438fa8fa77f98ddc9f50e879d3fa7ccdc324'], }, { address: '0x20c0000000000000000000000000000000000000', data: '0x000000000000000000000000000000000000000000000000000000000010d8dc', logIdx: 13, topics: [ '0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef', '0x000000000000000000000000b92fe925dc43a0ecde6c8b1a2709c170ec4fff4f', '0x00000000000000000000000003ef854275a0f914a0f34db6756a59f20c821989', ], }, { address: '0xb92fe925dc43a0ecde6c8b1a2709c170ec4fff4f', data: '0x000000000000000000000000b92fe925dc43a0ecde6c8b1a2709c170ec4fff4f00000000000000000000000003ef854275a0f914a0f34db6756a59f20c82198900000000000000000000000020c0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000010d8dc00000000000000000000000000000000000000000000000000000000000000a000000000000000000000000000000000000000000000000000000000000000217eb7a8dfb83149821d35b62fbd7880e2fe6e29f04133156c1adb203c95d7e2a40000000000000000000000000000000000000000000000000000000000000000', logIdx: 14, topics: ['0xafbab204e8271965231d37baed9b1abca8725b7409c70314455f68bc89142b91'], }, { address: '0xb92fe925dc43a0ecde6c8b1a2709c170ec4fff4f', data: '0x000000000000000000000000b92fe925dc43a0ecde6c8b1a2709c170ec4fff4f0000000000000000000000000000000000000000000000000000000000000060000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001a49bb43718000000000000000000000000000000000000000000000000000000000000008000000000000000000000000000000000000000000000000000000000000000c000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000000000000140000000000000000000000000000000000000000000000000000000000000000100000000000000000000000020c0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000100000000000000000000000003ef854275a0f914a0f34db6756a59f20c8219890000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000217eb7a8dfb83149821d35b62fbd7880e2fe6e29f04133156c1adb203c95d7e2a4000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000', logIdx: 15, topics: ['0x93485dcd31a905e3ffd7b012abe3438fa8fa77f98ddc9f50e879d3fa7ccdc324'], }, { address: '0x20c0000000000000000000000000000000000000', data: '0x00000000000000000000000000000000000000000000000000000000000000ab', logIdx: 16, topics: [ '0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef', '0x00000000000000000000000058aa7ce42e1d13b2919e2ac7e006c4fbc171442c', '0x000000000000000000000000feec000000000000000000000000000000000000', ], }, ] const vaultWithdrawLogs = [ { address: '0x0ec811f25e8fc247fe98faf32b90a48683d62f05', data: '0x00000000000000000000000000000000000000000000000000000000156963bb00000000000000000000000000000000000000000000000000000000156963bb00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000', logIdx: 0, topics: ['0x4dec04e750ca11537cabcd8a9eab06494de08da3735bc8871cd41250e190bc04'], }, { address: '0x9a044ae05e5e6290dcf56afd69548565e957a626', data: '0x000000000000000000000000000000000000000000000000000014bc099ff380000000000000000000000000000000000000000000000000000014bc09a0344000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000', logIdx: 1, topics: ['0x4dec04e750ca11537cabcd8a9eab06494de08da3735bc8871cd41250e190bc04'], }, { address: '0x9a044ae05e5e6290dcf56afd69548565e957a626', data: '0x00000000000000000000000000000000000000000000000000b1bcfa85011524', logIdx: 2, topics: [ '0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef', '0x000000000000000000000000004ad9220bce494c1f1892ee4965972fde188d51', '0x0000000000000000000000000000000000000000000000000000000000000000', ], }, { address: '0x20c0000000000000000000000000000000000000', data: '0x00000000000000000000000000000000000000000000000000000000000186a0', logIdx: 3, topics: [ '0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef', '0x0000000000000000000000009a044ae05e5e6290dcf56afd69548565e957a626', '0x000000000000000000000000004ad9220bce494c1f1892ee4965972fde188d51', ], }, { address: '0x9a044ae05e5e6290dcf56afd69548565e957a626', data: '0x00000000000000000000000000000000000000000000000000000000000186a000000000000000000000000000000000000000000000000000b1bcfa85011524', logIdx: 4, topics: [ '0xfbde797d201c681b91056529119e0b02407c7bb96a4a2c75c01fc9667232c8db', '0x000000000000000000000000004ad9220bce494c1f1892ee4965972fde188d51', '0x000000000000000000000000004ad9220bce494c1f1892ee4965972fde188d51', '0x000000000000000000000000004ad9220bce494c1f1892ee4965972fde188d51', ], }, { address: '0x20c0000000000000000000000000000000000000', data: '0x00000000000000000000000000000000000000000000000000000000000186a0', logIdx: 5, topics: [ '0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef', '0x000000000000000000000000004ad9220bce494c1f1892ee4965972fde188d51', '0x0000000000000000000000000ec811f25e8fc247fe98faf32b90a48683d62f05', ], }, { address: '0x0ec811f25e8fc247fe98faf32b90a48683d62f05', data: '0x00000000000000000000000000000000000000000000000000000000000186a00000000000000000000000000000000000000000000000000000000000000060fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffe79600000000000000000000000000000000000000000000000000000000000000001cad5f244418b485269ca822a1e213ac83a6fed12d0c16e0741eb94e4545b05e8', logIdx: 6, topics: [ '0xd602b36fb24934aef1bc2a658de029b486fa4c664a6e45de1f48e3fd1be25dd9', '0x00000000000000000000000003ef854275a0f914a0f34db6756a59f20c821989', '0x000000000000000000000000004ad9220bce494c1f1892ee4965972fde188d51', ], }, { address: '0x0ec811f25e8fc247fe98faf32b90a48683d62f05', data: '0x00000000000000000000000000000000000000000000000001634373939b29b4', logIdx: 7, topics: [ '0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef', '0x00000000000000000000000003ef854275a0f914a0f34db6756a59f20c821989', '0x0000000000000000000000000000000000000000000000000000000000000000', ], }, { address: '0x20c0000000000000000000000000000000000000', data: '0x00000000000000000000000000000000000000000000000000000000000186a0', logIdx: 8, topics: [ '0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef', '0x0000000000000000000000000ec811f25e8fc247fe98faf32b90a48683d62f05', '0x00000000000000000000000003ef854275a0f914a0f34db6756a59f20c821989', ], }, { address: '0x0ec811f25e8fc247fe98faf32b90a48683d62f05', data: '0x00000000000000000000000000000000000000000000000000000000000186a000000000000000000000000000000000000000000000000001634373939b29b4', logIdx: 9, topics: [ '0xfbde797d201c681b91056529119e0b02407c7bb96a4a2c75c01fc9667232c8db', '0x00000000000000000000000003ef854275a0f914a0f34db6756a59f20c821989', '0x00000000000000000000000003ef854275a0f914a0f34db6756a59f20c821989', '0x00000000000000000000000003ef854275a0f914a0f34db6756a59f20c821989', ], }, { address: '0x20c0000000000000000000000000000000000000', data: '0x00000000000000000000000000000000000000000000000000000000000186a0', logIdx: 10, topics: [ '0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef', '0x00000000000000000000000003ef854275a0f914a0f34db6756a59f20c821989', '0x00000000000000000000000076a21038ea44d8942c0765175f05d34fcfb838ab', ], }, { address: '0x20c0000000000000000000000000000000000000', data: '0x0000000000000000000000000000000000000000000000000000000000000096', logIdx: 11, topics: [ '0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef', '0x00000000000000000000000058aa7ce42e1d13b2919e2ac7e006c4fbc171442c', '0x000000000000000000000000feec000000000000000000000000000000000000', ], }, ] test('classifies a router swap instead of a lonely approval', async () => { // 0xd8be4bfb370d4395c45fb587686b0bd83b01228871896926e92d66d15f25838b // (chain 4217): 1.105 USDC.e swapped to pathUSD through a route account. const items = await classify(routerSwapLogs) expect(items.map((item) => item.type)).toEqual(['swap']) const swap = items[0] if (swap?.type !== 'swap') return expect(swap.data.sourceToken).toEqual({ address: '0x20c000000000000000000000b9537d11c60e8b50', amount: '1105000', }) expect(swap.data.destinationToken).toEqual({ address: '0x20c0000000000000000000000000000000000000', amount: '1104092', }) }) test('classifies a batched vault withdrawal and payment separately', async () => { // 0xf438783016504c10273a74631730fef8bfd5209a655c3e1ea7a87848d6406a7c // (chain 4217): ERC-4626 withdraw of 0.10 pathUSD, then a 0.10 pathUSD // payment. The old classifier reported a single `burn`. const items = await classify(vaultWithdrawLogs) expect(items.map((item) => item.type)).toEqual(['assets-withdrawn', 'transfer']) const withdrawal = items[0] if (withdrawal?.type !== 'assets-withdrawn') return expect(withdrawal.data.vault).toBe('0x0ec811f25e8fc247fe98faf32b90a48683d62f05') expect(withdrawal.data.assets).toBe('100000') expect(withdrawal.data.receiver).toBe(viewer) const payment = items[1] if (payment?.type !== 'transfer') return expect(payment.data.direction).toBe('out') expect(payment.data.recipient).toBe('0x76a21038ea44d8942c0765175f05d34fcfb838ab') expect(payment.data.sourceToken).toEqual({ address: '0x20c0000000000000000000000000000000000000', amount: '100000', }) }) })