// TODO(tidx-v1): Restore `queryOutbound` from commit `de87725` after TIDX v1 ships. Remove the receipt-side merge and bounded follow-up lookup. import { Hono, type Context } from 'hono' import { Address, Hex } from 'ox' import { type Log, parseEventLogs, zeroAddress } from 'viem' import { token as viem_Token } from 'viem/actions' import { Abis, Addresses } from 'viem/tempo' import * as z from 'zod/mini' import type * as App from '../../../App.js' import * as Auth from '../../../internal/Auth.js' import * as Cache from '../../../internal/Cache.js' import * as Cursor from '../../../internal/Cursor.js' import * as Mpp from '../../../internal/Mpp.js' import * as OpenApi from '../../../internal/OpenApi.js' import * as Response from '../../../internal/Response.js' import * as Schema from '../../../internal/Schema.js' import * as Store from '../../../internal/Store.js' import * as Tidx from '../../../internal/Tidx.js' import * as Timing from '../../../internal/Timing.js' import * as Ttl from '../../../internal/Ttl.js' import * as Value from '../../../internal/Value.js' import * as VerifiedTokens from '../../../internal/VerifiedTokens.js' import * as FxOracle from '../FxOracle.js' import * as ZoneSelection from '../ZoneSelection.js' import * as Tokens from './tokens.js' import * as Transfers from './transfers.js' import * as Valuation from './valuation.js' /** Example transaction hash surfaced in OpenAPI docs. */ const exampleHash = '0x4845ae2098724ab26a5d89370dce5124d044be6a5acc164afc348662de67d474' const includeFields = ['token.logoUri', 'token.verified', 'zones'] as const // TIP-20 tokens live at deterministic `0x20c0…` addresses. Other contracts // can emit the same `Transfer` signature but do not expose TIP-20 metadata. const tip20AddressPrefix = '0x20c0' /** Zod schemas owned by the activity handlers. */ export namespace schema { /** A signer: the literal `self`, or the access-key address that signed. */ const Signer = z .union([z.literal('self'), Schema.Address]) .check( z.describe('Who signed the activity: `self`, or the access-key address used to sign it.'), ) /** A token with required RPC metadata and optional curated fields. */ export const Token = OpenApi.component( Schema.describe(Tokens.schema.TokenReference, 'A token referenced by an activity item.'), 'ActivityToken', ) const ValuedAmount = Schema.describe( z.extend(Schema.TokenAmount, { valuation: z .optional(z.nullable(Valuation.schema.Value)) .check( z.describe( 'The amount’s nominal value in the requested `valuation.currency`. `null` when the token is ' + 'unverified, its display currency has no rate, or rates were unavailable.', ), ), }), 'A token amount carrying its nominal value in the requested denomination.', ) const valuedAmount = OpenApi.component(ValuedAmount, 'ActivityValuedAmount') const DestinationAmount = valuedAmount const RefundAmount = valuedAmount const SourceAmount = valuedAmount /** Page-level resources: valuation rate provenance. */ export const Meta = OpenApi.component( z .object({ valuation: z .optional(Valuation.schema.Pricing) .check( z.describe( 'Rate provenance for amount valuations. Present when conversion rates were ' + 'consulted; absent when every value was identity-valued or rates were unavailable.', ), ), }) .check(z.describe('Page-level resources attached to this response.')), 'ActivityMeta', ) /** A token transfer with subject-relative direction and signer fields. */ export const Transfer = z .object({ attribution: z .optional(z.string()) .check( z.describe( 'MPP service name matched from the transfer memo, when Tempo recognizes the fingerprint.', ), z.meta({ examples: ['Acme Payments'] }), ), blockNumber: z .number() .check( z.int(), z.nonnegative(), z.describe('Block number where this transfer was included.'), z.meta({ examples: [12345678] }), ), destinationAmount: z .optional(DestinationAmount) .check( z.describe( 'Amount delivered to the recipient for a cross-token transfer. Omitted with `destinationToken` for a same-token transfer.', ), ), destinationToken: z .optional(Token) .check( z.describe( 'Token delivered to the recipient when it differs from the source token. Omitted for a same-token transfer.', ), ), direction: z .enum(['in', 'out']) .check( z.describe( 'The address’s side of the transfer: `in` means received, and `out` means sent.', ), ), memo: z .optional(z.string()) .check(z.describe('Text memo attached to the transfer, when one was provided.'), z.meta({ examples: ['Invoice #1234'] })), // prettier-ignore recipient: Schema.Address.check(z.describe('Address that received the transfer.')), sender: Schema.Address.check(z.describe('Address that sent the transfer.')), signer: z .optional(Signer) .check( z.describe( 'For outbound transfers, who signed it: `self` or the access-key address. Omitted for inbound transfers.', ), ), sourceAmount: SourceAmount, sourceToken: Token.check(z.describe('Token that moved in the transfer.')), timestamp: z .iso.datetime() .check(z.describe('Block timestamp for the transfer.'), z.meta({ examples: ['2024-01-01T00:00:00Z'] })), // prettier-ignore transactionHash: Schema.Hash.check( z.describe('Transaction hash that contains this transfer.'), ), }) .check( z.refine( (data) => (data.destinationToken === undefined) === (data.destinationAmount === undefined), { error: '`destinationAmount` and `destinationToken` must be returned together.', path: ['destinationAmount'], }, ), z.describe('One token transfer involving the requested address.'), ) const activityTransfer = OpenApi.component(Transfer, 'ActivityTransfer') /** * One transaction log, decoded into a known Tempo event when possible. The * raw fields (`address`/`data`/`logIndex`/`topics`) are always present so no * log is hidden; the decoded fields (`args`/`eventName`/`title`) are present * only when the log matched a known `viem/tempo` ABI. */ const event = z .object({ address: Schema.Address.check(z.describe('Contract address that emitted the log.')), args: z .optional(z.record(z.string(), z.unknown())) .check( z.describe( 'Decoded event arguments keyed by parameter name, present when the log matched a known ABI. Integer values are decimal strings.', ), ), data: Schema.Hex.check(z.describe('Unindexed log data as a hex string (`0x` when empty).')), eventName: z .optional(z.string()) .check(z.describe('Event name decoded from the `viem/tempo` ABIs, present when the log matched a known ABI.'), z.meta({ examples: ['Transfer'] })), // prettier-ignore logIndex: z .number() .check(z.int(), z.nonnegative(), z.describe('Log index for this log within the block.'), z.meta({ examples: [4] })), // prettier-ignore title: z .optional(z.string()) .check(z.describe('Human-readable label for the decoded event, present when the log matched a known ABI.'), z.meta({ examples: ['Token transferred'] })), // prettier-ignore topics: z .array(Schema.Hash) .check(z.describe('Raw log topics; the first topic is the event signature hash.')), }) .check( z.describe( 'One transaction log. Decoded fields (`eventName`/`args`/`title`) are present when it matched a known Tempo ABI; otherwise only the raw fields are set.', ), ) const activityEvent = OpenApi.component(event, 'ActivityEvent') /** * Fields shared by every activity item. `transactionHash`/`timestamp` identify and sort * the entry; `data` carries the type-specific payload — shaped like the * matching API resource where one exists, plus any feed-only fields (e.g. a * transfer's `direction`/`memo`/`attribution`/`signer`) the resource lacks. */ const base = { chainId: z .optional(Schema.ChainId) .check( z.describe('Chain ID containing this activity. Returned when `include=zones`.'), z.meta({ examples: [421700001] }), ), perspective: z .enum(['incoming', 'outgoing']) .check( z.describe( 'Your role in the transaction: `outgoing` when you sent or paid for it, `incoming` when it happened to you (someone else initiated it). For a `transfer`, `data.direction` is the finer-grained token-flow view.', ), ), events: z .optional(z.array(activityEvent)) .check( z.describe( 'Every log in the transaction, decoded when possible, so nothing is hidden. Returned only when the request sets `logs=true`, except on `unknown` items where it is always present.', ), ), id: z.string().check( z.describe( 'Stable activity ID built from the transaction hash and log index (`${transactionHash}-${logIndex}`).', ), z.meta({ examples: ['0x515801d7f9a5ac705e793e85904c9c69b3f1694b465cc8fb6ba3f0298dc82665-3'], }), ), logIndex: z .number() .check( z.int(), z.nonnegative(), z.describe('Log index for this activity within the block.'), z.meta({ examples: [3] }), ), timestamp: z .iso.datetime() .check(z.describe('Block timestamp as an ISO 8601 string.'), z.meta({ examples: ['2024-01-01T00:00:00Z'] })), // prettier-ignore title: z .string() .check( z.describe( 'Human-readable label for the activity `type`, suitable for display in a feed. Past-tense, sentence case (e.g. `Token transferred`, `Tokens swapped`, `Burn blocked`).', ), z.meta({ examples: ['Token transferred'] }), ), transactionHash: Schema.Hash.check(z.describe('Transaction hash for this activity.')), } const itemSchemas = [ z .object({ ...base, data: z.object({ assets: Schema.DecimalString, caller: Schema.Address, receiver: Schema.Address, shares: Schema.DecimalString, signer: Signer, status: z.literal('completed'), vault: Schema.Address, }), type: z.literal('assets-deposited'), }) .check(z.meta({ title: 'Assets deposited' })), z .object({ ...base, data: z.object({ caller: Schema.Address, receivedEngineShares: Schema.DecimalString, receiver: Schema.Address, requestedVenueShares: Schema.DecimalString, shares: Schema.DecimalString, signer: Signer, status: z.literal('completed'), vault: Schema.Address, }), type: z.literal('shares-deposited'), }) .check(z.meta({ title: 'Shares deposited' })), z .object({ ...base, data: z.object({ assets: Schema.DecimalString, caller: Schema.Address, receiver: Schema.Address, shares: Schema.DecimalString, signer: Signer, status: z.literal('completed'), vault: Schema.Address, }), type: z.literal('shares-redeemed'), }) .check(z.meta({ title: 'Shares redeemed' })), z .object({ ...base, data: z.object({ assets: Schema.DecimalString, caller: Schema.Address, sharesBurned: Schema.DecimalString, receiver: Schema.Address, signer: Signer, status: z.literal('completed'), vault: Schema.Address, }), type: z.literal('assets-withdrawn'), }) .check(z.meta({ title: 'Assets withdrawn' })), z .object({ ...base, data: z.object({ receiver: Schema.Address, requestId: Schema.Hash, requester: Schema.Address, shares: Schema.DecimalString, signer: Signer, status: z.literal('pending'), vault: Schema.Address, }), type: z.literal('shares-redemption-requested'), }) .check(z.meta({ title: 'Share redemption requested' })), z .object({ ...base, data: z.object({ asset: Schema.Address, assets: Schema.DecimalString, receiver: Schema.Address, requestId: Schema.Hash, shares: Schema.DecimalString, signer: Signer, status: z.literal('completed'), vault: Schema.Address, }), type: z.literal('shares-redemption-finalized'), }) .check(z.meta({ title: 'Share redemption finalized' })), z .object({ ...base, data: z.object({ receiver: Schema.Address, requestId: Schema.Hash, shares: Schema.DecimalString, signer: Signer, status: z.literal('cancelled'), vault: Schema.Address, }), type: z.literal('shares-redemption-cancelled'), }) .check(z.meta({ title: 'Share redemption cancelled' })), z .object({ ...base, data: z.object({ actionId: Schema.Hash, assets: Schema.DecimalString, inputAmount: Schema.DecimalString, inputToken: Schema.Address, shares: Schema.DecimalString, signer: Signer, status: z.literal('completed'), vault: Schema.Address, zoneDepositHash: Schema.Hash, }), type: z.literal('private-assets-deposited'), }) .check(z.meta({ title: 'Private assets deposited' })), z .object({ ...base, data: z.object({ actionId: Schema.Hash, assets: Schema.DecimalString, outputAmount: Schema.DecimalString, outputToken: Schema.Address, shares: Schema.DecimalString, signer: Signer, status: z.literal('completed'), vault: Schema.Address, zoneDepositHash: Schema.Hash, }), type: z.literal('private-shares-redeemed'), }) .check(z.meta({ title: 'Private shares redeemed' })), z .object({ ...base, data: activityTransfer.check( z.describe('Transfer details with feed-relative direction and signer fields.'), ), type: z .literal('transfer') .check(z.describe('Activity type that tells you which `data` shape this entry uses.')), }) .check(z.meta({ title: 'Token transferred' })), z .object({ ...base, data: z .object({ recipient: Schema.Address.check(z.describe('Address that received the transfer.')), sourceAmount: SourceAmount, sourceToken: Token.check(z.describe('Token that was minted.')), }) .check(z.describe('A token mint activity.')), type: z .literal('mint') .check(z.describe('Activity type that tells you which `data` shape this entry uses.')), }) .check(z.meta({ title: 'Token minted' })), z .object({ ...base, data: z .object({ sender: Schema.Address.check(z.describe('Address whose token balance was burned.')), signer: Signer, sourceAmount: SourceAmount, sourceToken: Token.check(z.describe('Token that was burned.')), }) .check(z.describe('A token burn activity.')), type: z .literal('burn') .check(z.describe('Activity type that tells you which `data` shape this entry uses.')), }) .check(z.meta({ title: 'Token burned' })), z .object({ ...base, data: z .object({ destinationAmount: DestinationAmount, destinationToken: Token.check(z.describe('Token the address received in the swap.')), signer: Signer, sourceAmount: SourceAmount, sourceToken: Token.check(z.describe('Token the address sent into the swap.')), }) .check( z.describe( 'Swap inferred from token transfers through the DEX precompile. This feed entry is a summary, so per-fill details, route, and rate are not included.', ), ), type: z .literal('swap') .check(z.describe('Activity type that tells you which `data` shape this entry uses.')), }) .check(z.meta({ title: 'Tokens swapped' })), z .object({ ...base, data: z .object({ signer: Signer, sourceAmount: SourceAmount, sourceToken: Token.check(z.describe('Token approved for spending.')), spender: Schema.Address.check( z.describe('Address allowed to spend the approved amount.'), ), }) .check(z.describe('A token allowance approval activity.')), type: z .literal('approval') .check(z.describe('Activity type that tells you which `data` shape this entry uses.')), }) .check(z.meta({ title: 'Allowance approved' })), z .object({ ...base, data: z .object({ refund: z .optional(Token) .check(z.describe('Token refunded to the address when the session closed, if any.')), refundAmount: z .optional(RefundAmount) .check(z.describe('Amount refunded to the address when the session closed, if any.')), signer: Signer, }) .check( z.refine((data) => (data.refund === undefined) === (data.refundAmount === undefined), { error: '`refundAmount` and `refund` must be returned together.', path: ['refundAmount'], }), z.describe('A payment-channel session close activity.'), ), type: z .literal('session-closed') .check(z.describe('Activity type that tells you which `data` shape this entry uses.')), }) .check(z.meta({ title: 'Session closed' })), z .object({ ...base, data: z .object({ account: Schema.Address.check( z.describe('Account this access key was authorized for.'), ), expiry: z .number() .check(z.int(), z.describe('Access-key expiration time as a Unix timestamp.'), z.meta({ examples: [1735689600] })), // prettier-ignore publicKey: Schema.Address.check( z.describe('Public key for the authorized access key.'), ), signatureType: z .number() .check( z.int(), z.describe('Signature type used by the access key.'), z.meta({ examples: [0] }), ), }) .check(z.describe('An access key was created for the account.')), type: z .literal('access-key-created') .check(z.describe('Activity type that tells you which `data` shape this entry uses.')), }) .check(z.meta({ title: 'Access key created' })), z .object({ ...base, data: z .object({ account: Schema.Address.check(z.describe('Account this access key was revoked from.')), publicKey: Schema.Address.check(z.describe('Public key for the revoked access key.')), }) .check(z.describe('An access key was revoked from the account.')), type: z .literal('access-key-revoked') .check(z.describe('Activity type that tells you which `data` shape this entry uses.')), }) .check(z.meta({ title: 'Access key revoked' })), z .object({ ...base, data: z .object({ address: Schema.Address.check(z.describe('Contract address of the new TIP-20 token.')), currency: z.string().check(z.describe('Display currency for the token, such as USD.'), z.meta({ examples: ['USD'] })), // prettier-ignore name: z.string().check(z.describe('Human-readable token name.'), z.meta({ examples: ['Tempo USD'] })), // prettier-ignore symbol: z .string() .check(z.describe('Token ticker symbol.'), z.meta({ examples: ['TUSD'] })), }) .check(z.describe('A new TIP-20 token was created.')), type: z .literal('token-created') .check(z.describe('Activity type that tells you which `data` shape this entry uses.')), }) .check(z.meta({ title: 'Token created' })), z .object({ ...base, data: z .object({ sender: Schema.Address.check( z.describe('Address whose blocked balance was burned by policy.'), ), signer: Signer, sourceAmount: SourceAmount, sourceToken: Token.check(z.describe('Token that was burned.')), }) .check( z.describe( 'A compliance burn of funds the receive policy blocked, distinct from a normal burn.', ), ), type: z .literal('burn-blocked') .check(z.describe('Activity type that tells you which `data` shape this entry uses.')), }) .check(z.meta({ title: 'Burn blocked' })), z .object({ ...base, data: z .object({ channelId: Schema.Hex.check(z.describe('Identifier of the payment channel.')), payee: Schema.Address.check( z.describe('Address that receives payments from the channel.'), ), payer: Schema.Address.check(z.describe('Address that funds the channel.')), signer: Signer, sourceAmount: SourceAmount, sourceToken: Token.check(z.describe('Token locked into the channel.')), }) .check(z.describe('A payment channel was opened and funded.')), type: z .literal('channel-opened') .check(z.describe('Activity type that tells you which `data` shape this entry uses.')), }) .check(z.meta({ title: 'Channel opened' })), z .object({ ...base, data: z .object({ channelId: Schema.Hex.check(z.describe('Identifier of the payment channel.')), signer: Signer, sourceAmount: SourceAmount, sourceToken: Token.check(z.describe('Token added to the channel deposit.')), }) .check(z.describe('A payment channel deposit was topped up.')), type: z .literal('channel-funded') .check(z.describe('Activity type that tells you which `data` shape this entry uses.')), }) .check(z.meta({ title: 'Channel funded' })), z .object({ ...base, data: z .object({ channelId: Schema.Hex.check(z.describe('Identifier of the payment channel.')), payee: Schema.Address.check(z.describe('Address that received the settled payment.')), payer: Schema.Address.check(z.describe('Address that funded the channel.')), signer: Signer, sourceAmount: SourceAmount, sourceToken: Token.check(z.describe('Token paid out in this settlement.')), }) .check(z.describe('An incremental payment-channel settlement to the payee.')), type: z .literal('channel-settled') .check(z.describe('Activity type that tells you which `data` shape this entry uses.')), }) .check(z.meta({ title: 'Channel settled' })), z .object({ ...base, data: z .object({ channelId: Schema.Hex.check(z.describe('Identifier of the payment channel.')), payee: Schema.Address.check( z.describe('Address that receives payments from the channel.'), ), payer: Schema.Address.check(z.describe('Address that funded the channel.')), refund: z .optional(Token) .check(z.describe('Token refunded to the payer on close, if any.')), refundAmount: z .optional(RefundAmount) .check(z.describe('Amount refunded to the payer on close, if any.')), signer: Signer, sourceAmount: z .optional(SourceAmount) .check(z.describe('Final amount paid to the payee on close, if any.')), sourceToken: z .optional(Token) .check(z.describe('Token paid out to the payee on close, if any.')), }) .check( z.refine((data) => (data.refund === undefined) === (data.refundAmount === undefined), { error: '`refundAmount` and `refund` must be returned together.', path: ['refundAmount'], }), z.refine( (data) => (data.sourceToken === undefined) === (data.sourceAmount === undefined), { error: '`sourceAmount` and `sourceToken` must be returned together.', path: ['sourceAmount'], }, ), z.describe( 'A payment channel was closed, paying out the payee and refunding the payer.', ), ), type: z .literal('channel-closed') .check(z.describe('Activity type that tells you which `data` shape this entry uses.')), }) .check(z.meta({ title: 'Channel closed' })), z .object({ ...base, data: z .object({ channelId: Schema.Hex.check(z.describe('Identifier of the payment channel.')), payee: Schema.Address.check( z.describe('Address that receives payments from the channel.'), ), payer: Schema.Address.check(z.describe('Address that funds the channel.')), signer: Signer, }) .check(z.describe('A pending payment-channel close request was cancelled.')), type: z .literal('channel-close-cancelled') .check(z.describe('Activity type that tells you which `data` shape this entry uses.')), }) .check(z.meta({ title: 'Channel close cancelled' })), z .object({ ...base, data: z .object({ orderId: Schema.DecimalString.check(z.describe('Identifier of the DEX order.')), side: z .enum(['bid', 'ask']) .check(z.describe('Order side: `bid` buys the base token, `ask` sells it.'), z.meta({ examples: ['bid'] })), // prettier-ignore signer: Signer, sourceAmount: SourceAmount, sourceToken: Token.check(z.describe('Token escrowed for the order.')), tick: z .number() .check( z.int(), z.describe('Price tick the order was placed at.'), z.meta({ examples: [0] }), ), }) .check(z.describe('A limit order was placed on the stablecoin DEX.')), type: z .literal('order-placed') .check(z.describe('Activity type that tells you which `data` shape this entry uses.')), }) .check(z.meta({ title: 'Order placed' })), z .object({ ...base, data: z .object({ orderId: Schema.DecimalString.check( z.describe('Identifier of the cancelled DEX order.'), ), signer: Signer, }) .check(z.describe('A DEX order was cancelled.')), type: z .literal('order-cancelled') .check(z.describe('Activity type that tells you which `data` shape this entry uses.')), }) .check(z.meta({ title: 'Order cancelled' })), z .object({ ...base, data: z .object({ signer: Signer, sourceAmount: SourceAmount, sourceToken: Token.check(z.describe('Token used for the distributed fees.')), validator: Schema.Address.check( z.describe('Validator that received the distributed fees.'), ), }) .check(z.describe('Accrued validator fees were distributed.')), type: z .literal('fees-distributed') .check(z.describe('Activity type that tells you which `data` shape this entry uses.')), }) .check(z.meta({ title: 'Fees distributed' })), z .object({ ...base, data: z .object({ destinationAmount: DestinationAmount, destinationToken: Token.check(z.describe('Token received from the fee AMM.')), signer: Signer, sourceAmount: SourceAmount, sourceToken: Token.check(z.describe('Token sent into the fee AMM.')), swapper: Schema.Address.check(z.describe('Address that performed the rebalance swap.')), }) .check(z.describe('A fee-AMM rebalance swap between a validator and user token.')), type: z .literal('fee-rebalance-swap') .check(z.describe('Activity type that tells you which `data` shape this entry uses.')), }) .check(z.meta({ title: 'Fees rebalanced' })), z .object({ ...base, data: z .object({ funder: Schema.Address.check(z.describe('Address that funded the reward pool.')), signer: Signer, sourceAmount: SourceAmount, sourceToken: Token.check(z.describe('Token added to the reward pool.')), }) .check(z.describe('A token reward pool was funded.')), type: z .literal('reward-distributed') .check(z.describe('Activity type that tells you which `data` shape this entry uses.')), }) .check(z.meta({ title: 'Reward distributed' })), z .object({ ...base, data: z .object({ holder: Schema.Address.check(z.describe('Holder whose rewards were redirected.')), recipient: Schema.Address.check( z.describe('Address that now receives the holder’s rewards.'), ), signer: Signer, }) .check(z.describe('A holder redirected where their token rewards are paid.')), type: z .literal('reward-recipient-set') .check(z.describe('Activity type that tells you which `data` shape this entry uses.')), }) .check(z.meta({ title: 'Reward recipient set' })), z .object({ ...base, data: z .object({ account: Schema.Address.check(z.describe('Account the access key belongs to.')), publicKey: Schema.Address.check( z.describe('Public key of the access key whose limit changed.'), ), signer: Signer, sourceAmount: SourceAmount, sourceToken: Token.check(z.describe('Token whose spending limit was updated.')), }) .check(z.describe('An access key’s per-token spending limit was updated.')), type: z .literal('spending-limit-updated') .check(z.describe('Activity type that tells you which `data` shape this entry uses.')), }) .check(z.meta({ title: 'Spending limit updated' })), z .object({ ...base, data: z .object({ amountFilled: Schema.DecimalString.check( z.describe('Amount filled, in the token’s smallest unit.'), ), maker: Schema.Address.check(z.describe('Maker whose resting order was filled.')), orderId: Schema.DecimalString.check(z.describe('Identifier of the filled DEX order.')), partialFill: z .boolean() .check( z.describe('Whether the order was only partially filled.'), z.meta({ examples: [true] }), ), signer: Signer, taker: Schema.Address.check(z.describe('Taker that filled the order.')), }) .check(z.describe('A resting DEX order was filled by a taker.')), type: z .literal('order-filled') .check(z.describe('Activity type that tells you which `data` shape this entry uses.')), }) .check(z.meta({ title: 'Order filled' })), z .object({ ...base, data: z .object({ flipTick: z .number() .check( z.int(), z.describe('Price tick the flipped order was placed at.'), z.meta({ examples: [0] }), ), maker: Schema.Address.check(z.describe('Maker the order belongs to.')), orderId: Schema.DecimalString.check(z.describe('Identifier of the DEX order.')), side: z .enum(['bid', 'ask']) .check(z.describe('Side of the flipped order: `bid` buys the base token, `ask` sells it.'), z.meta({ examples: ['bid'] })), // prettier-ignore signer: Signer, sourceAmount: SourceAmount, sourceToken: Token.check(z.describe('Token the flipped order rests with.')), tick: z .number() .check( z.int(), z.describe('Price tick the original order filled at.'), z.meta({ examples: [0] }), ), }) .check(z.describe('A flip order was rotated to the opposite side after filling.')), type: z .literal('order-flipped') .check(z.describe('Activity type that tells you which `data` shape this entry uses.')), }) .check(z.meta({ title: 'Order flipped' })), z .object({ ...base, data: z .object({ base: Schema.Address.check(z.describe('Base token of the new trading pair.')), key: Schema.Hex.check(z.describe('Identifier of the trading pair.')), quote: Schema.Address.check(z.describe('Quote token of the new trading pair.')), signer: Signer, }) .check(z.describe('A new trading pair was created on the stablecoin DEX.')), type: z .literal('pair-created') .check(z.describe('Activity type that tells you which `data` shape this entry uses.')), }) .check(z.meta({ title: 'Trading pair created' })), z .object({ ...base, data: z .object({ paused: z .boolean() .check(z.describe('Whether the token is now paused.'), z.meta({ examples: [true] })), signer: Signer, updater: Schema.Address.check(z.describe('Address that changed the pause state.')), }) .check(z.describe('A TIP-20 token was paused or unpaused.')), type: z .literal('token-pause-set') .check(z.describe('Activity type that tells you which `data` shape this entry uses.')), }) .check(z.meta({ title: 'Token pause set' })), z .object({ ...base, data: z .object({ signer: Signer, supplyCap: Schema.DecimalString.check( z.describe('New maximum total supply, in the token’s smallest unit.'), ), updater: Schema.Address.check(z.describe('Address that changed the supply cap.')), }) .check(z.describe('A TIP-20 token’s supply cap was changed.')), type: z .literal('token-supply-cap-set') .check(z.describe('Activity type that tells you which `data` shape this entry uses.')), }) .check(z.meta({ title: 'Token supply cap set' })), z .object({ ...base, data: z .object({ policyId: Schema.DecimalString.check( z.describe('Identifier of the transfer policy now linked to the token.'), ), signer: Signer, updater: Schema.Address.check(z.describe('Address that linked the transfer policy.')), }) .check(z.describe('A TIP-20 token’s transfer policy was changed.')), type: z .literal('token-transfer-policy-set') .check(z.describe('Activity type that tells you which `data` shape this entry uses.')), }) .check(z.meta({ title: 'Token transfer policy set' })), z .object({ ...base, data: z .object({ quoteToken: Schema.Address.check(z.describe('Token now used as the quote token.')), signer: Signer, updater: Schema.Address.check(z.describe('Address that changed the quote token.')), }) .check(z.describe('A TIP-20 token’s quote token was changed.')), type: z .literal('token-quote-token-set') .check(z.describe('Activity type that tells you which `data` shape this entry uses.')), }) .check(z.meta({ title: 'Token quote token set' })), z .object({ ...base, data: z .object({ nextQuoteToken: Schema.Address.check( z.describe('Token queued to become the next quote token.'), ), signer: Signer, updater: Schema.Address.check(z.describe('Address that queued the next quote token.')), }) .check(z.describe('A TIP-20 token’s next quote token was queued.')), type: z .literal('token-next-quote-token-set') .check(z.describe('Activity type that tells you which `data` shape this entry uses.')), }) .check(z.meta({ title: 'Token next quote token set' })), z .object({ ...base, data: z .object({ logoUri: z.string().check( z.describe('New logo URL for the token.'), z.meta({ examples: [Tokens.tokenExample.logoUri], }), ), signer: Signer, updater: Schema.Address.check(z.describe('Address that changed the logo.')), }) .check(z.describe('A TIP-20 token’s logo URI was changed.')), type: z .literal('token-logo-set') .check(z.describe('Activity type that tells you which `data` shape this entry uses.')), }) .check(z.meta({ title: 'Token logo set' })), z .object({ ...base, data: z .object({ account: Schema.Address.check(z.describe('Account whose role membership changed.')), granted: z .boolean() .check( z.describe('Whether the role was granted (`true`) or revoked (`false`).'), z.meta({ examples: [true] }), ), role: Schema.Hex.check(z.describe('Identifier of the role (a `bytes32` role hash).')), sender: Schema.Address.check(z.describe('Address that made the role change.')), signer: Signer, }) .check(z.describe('A role was granted to or revoked from an account.')), type: z .literal('role-membership-set') .check(z.describe('Activity type that tells you which `data` shape this entry uses.')), }) .check(z.meta({ title: 'Role membership set' })), z .object({ ...base, data: z .object({ newAdminRole: Schema.Hex.check(z.describe('Identifier of the new admin role.')), role: Schema.Hex.check(z.describe('Identifier of the role whose admin changed.')), sender: Schema.Address.check(z.describe('Address that changed the admin role.')), signer: Signer, }) .check(z.describe('A role’s admin role was changed.')), type: z .literal('role-admin-set') .check(z.describe('Activity type that tells you which `data` shape this entry uses.')), }) .check(z.meta({ title: 'Role admin set' })), z .object({ ...base, data: z .object({ signer: Signer, token: Schema.Address.check(z.describe('Token the user will pay fees in.')), user: Schema.Address.check(z.describe('Address whose fee token was set.')), }) .check(z.describe('An account’s fee-payment token was set.')), type: z .literal('fee-user-token-set') .check(z.describe('Activity type that tells you which `data` shape this entry uses.')), }) .check(z.meta({ title: 'Fee user token set' })), z .object({ ...base, data: z .object({ signer: Signer, token: Schema.Address.check(z.describe('Token the validator will be paid fees in.')), validator: Schema.Address.check(z.describe('Validator whose payout token was set.')), }) .check(z.describe('A validator’s fee-payout token was set.')), type: z .literal('fee-validator-token-set') .check(z.describe('Activity type that tells you which `data` shape this entry uses.')), }) .check(z.meta({ title: 'Fee validator token set' })), z .object({ ...base, data: z .object({ policyId: Schema.DecimalString.check(z.describe('Identifier of the created policy.')), policyType: z .number() .check(z.int(), z.describe('Numeric policy type.'), z.meta({ examples: [0] })), signer: Signer, updater: Schema.Address.check(z.describe('Address that created the policy.')), }) .check(z.describe('A TIP-403 compliance policy was created.')), type: z .literal('policy-created') .check(z.describe('Activity type that tells you which `data` shape this entry uses.')), }) .check(z.meta({ title: 'Policy created' })), z .object({ ...base, data: z .object({ admin: Schema.Address.check(z.describe('New admin of the policy.')), policyId: Schema.DecimalString.check(z.describe('Identifier of the policy.')), signer: Signer, updater: Schema.Address.check(z.describe('Address that changed the policy admin.')), }) .check(z.describe('A TIP-403 policy’s admin was changed.')), type: z .literal('policy-admin-set') .check(z.describe('Activity type that tells you which `data` shape this entry uses.')), }) .check(z.meta({ title: 'Policy admin set' })), z .object({ ...base, data: z .object({ account: Schema.Address.check(z.describe('Account whose whitelist status changed.')), allowed: z .boolean() .check( z.describe('Whether the account is now whitelisted.'), z.meta({ examples: [true] }), ), policyId: Schema.DecimalString.check(z.describe('Identifier of the policy.')), signer: Signer, updater: Schema.Address.check(z.describe('Address that changed the whitelist.')), }) .check(z.describe('An account’s whitelist status on a policy was changed.')), type: z .literal('whitelist-updated') .check(z.describe('Activity type that tells you which `data` shape this entry uses.')), }) .check(z.meta({ title: 'Whitelist updated' })), z .object({ ...base, data: z .object({ account: Schema.Address.check(z.describe('Account whose blacklist status changed.')), policyId: Schema.DecimalString.check(z.describe('Identifier of the policy.')), restricted: z .boolean() .check( z.describe('Whether the account is now blacklisted.'), z.meta({ examples: [true] }), ), signer: Signer, updater: Schema.Address.check(z.describe('Address that changed the blacklist.')), }) .check(z.describe('An account’s blacklist status on a policy was changed.')), type: z .literal('blacklist-updated') .check(z.describe('Activity type that tells you which `data` shape this entry uses.')), }) .check(z.meta({ title: 'Blacklist updated' })), z .object({ ...base, data: z .object({ creator: Schema.Address.check(z.describe('Address that created the compound policy.')), mintRecipientPolicyId: Schema.DecimalString.check( z.describe('Sub-policy applied to mint recipients.'), ), policyId: Schema.DecimalString.check( z.describe('Identifier of the created compound policy.'), ), recipientPolicyId: Schema.DecimalString.check( z.describe('Sub-policy applied to recipients.'), ), senderPolicyId: Schema.DecimalString.check( z.describe('Sub-policy applied to senders.'), ), signer: Signer, }) .check(z.describe('A TIP-403 compound policy was created from sub-policies.')), type: z .literal('compound-policy-created') .check(z.describe('Activity type that tells you which `data` shape this entry uses.')), }) .check(z.meta({ title: 'Compound policy created' })), z .object({ ...base, data: z .object({ masterAddress: Schema.Address.check( z.describe('Address of the registered master contract.'), ), masterId: Schema.Hex.check( z.describe('Identifier of the master contract (a `bytes4` selector).'), ), signer: Signer, }) .check(z.describe('A master contract was registered in the address registry.')), type: z .literal('master-registered') .check(z.describe('Activity type that tells you which `data` shape this entry uses.')), }) .check(z.meta({ title: 'Master registered' })), z .object({ ...base, data: z .object({ account: Schema.Address.check(z.describe('Account whose nonce advanced.')), nonce: Schema.DecimalString.check(z.describe('New nonce value.')), nonceKey: Schema.DecimalString.check(z.describe('Nonce key that was advanced.')), signer: Signer, }) .check(z.describe('An account or access-key nonce was advanced.')), type: z .literal('nonce-incremented') .check(z.describe('Activity type that tells you which `data` shape this entry uses.')), }) .check(z.meta({ title: 'Nonce incremented' })), z .object({ ...base, data: z .object({ account: Schema.Address.check(z.describe('Account the witness belongs to.')), signer: Signer, witness: Schema.Hex.check( z.describe('Witness commitment recorded for the key authorization.'), ), }) .check(z.describe('A key-authorization witness was recorded.')), type: z .literal('key-authorization-witness') .check(z.describe('Activity type that tells you which `data` shape this entry uses.')), }) .check(z.meta({ title: 'Key authorization witnessed' })), z .object({ ...base, data: z .object({ account: Schema.Address.check(z.describe('Account the witness belonged to.')), signer: Signer, witness: Schema.Hex.check(z.describe('Witness commitment that was consumed.')), }) .check(z.describe('A key-authorization witness was consumed.')), type: z .literal('key-authorization-witness-burned') .check(z.describe('Activity type that tells you which `data` shape this entry uses.')), }) .check(z.meta({ title: 'Key authorization witness burned' })), z .object({ ...base, data: z .object({ egress: z .string() .check( z.describe('Egress network endpoint.'), z.meta({ examples: ['https://validator.example.com:8443'] }), ), feeRecipient: Schema.Address.check(z.describe('Validator fee recipient address.')), index: Schema.DecimalString.check(z.describe('Validator index.')), ingress: z .string() .check( z.describe('Ingress network endpoint.'), z.meta({ examples: ['https://validator.example.com:8443'] }), ), publicKey: Schema.Hex.check(z.describe('Validator public key.')), signer: Signer, validator: Schema.Address.check(z.describe('Validator address.')), }) .check(z.describe('A validator was added to the validator set.')), type: z .literal('validator-added') .check(z.describe('Activity type that tells you which `data` shape this entry uses.')), }) .check(z.meta({ title: 'Validator added' })), z .object({ ...base, data: z .object({ index: Schema.DecimalString.check(z.describe('Validator index.')), signer: Signer, validator: Schema.Address.check(z.describe('Validator address.')), }) .check(z.describe('A validator was deactivated.')), type: z .literal('validator-deactivated') .check(z.describe('Activity type that tells you which `data` shape this entry uses.')), }) .check(z.meta({ title: 'Validator deactivated' })), z .object({ ...base, data: z .object({ caller: Schema.Address.check(z.describe('Address that rotated the validator.')), deactivatedIndex: Schema.DecimalString.check( z.describe('Index of the deactivated validator entry.'), ), egress: z .string() .check( z.describe('Egress network endpoint.'), z.meta({ examples: ['https://validator.example.com:8443'] }), ), index: Schema.DecimalString.check(z.describe('New validator index.')), ingress: z .string() .check( z.describe('Ingress network endpoint.'), z.meta({ examples: ['https://validator.example.com:8443'] }), ), newPublicKey: Schema.Hex.check(z.describe('New validator public key.')), oldPublicKey: Schema.Hex.check(z.describe('Previous validator public key.')), signer: Signer, validator: Schema.Address.check(z.describe('Validator address.')), }) .check(z.describe('A validator’s keys and endpoints were rotated.')), type: z .literal('validator-rotated') .check(z.describe('Activity type that tells you which `data` shape this entry uses.')), }) .check(z.meta({ title: 'Validator rotated' })), z .object({ ...base, data: z .object({ caller: Schema.Address.check(z.describe('Address that changed the fee recipient.')), feeRecipient: Schema.Address.check(z.describe('New validator fee recipient.')), index: Schema.DecimalString.check(z.describe('Validator index.')), signer: Signer, }) .check(z.describe('A validator’s fee recipient was changed.')), type: z .literal('validator-fee-recipient-set') .check(z.describe('Activity type that tells you which `data` shape this entry uses.')), }) .check(z.meta({ title: 'Validator fee recipient set' })), z .object({ ...base, data: z .object({ caller: Schema.Address.check(z.describe('Address that changed the endpoints.')), egress: z .string() .check( z.describe('Egress network endpoint.'), z.meta({ examples: ['https://validator.example.com:8443'] }), ), index: Schema.DecimalString.check(z.describe('Validator index.')), ingress: z .string() .check( z.describe('Ingress network endpoint.'), z.meta({ examples: ['https://validator.example.com:8443'] }), ), signer: Signer, }) .check(z.describe('A validator’s network endpoints were changed.')), type: z .literal('validator-ip-set') .check(z.describe('Activity type that tells you which `data` shape this entry uses.')), }) .check(z.meta({ title: 'Validator IP set' })), z .object({ ...base, data: z .object({ caller: Schema.Address.check(z.describe('Address that transferred ownership.')), index: Schema.DecimalString.check(z.describe('Validator index.')), newAddress: Schema.Address.check(z.describe('New validator owner address.')), oldAddress: Schema.Address.check(z.describe('Previous validator owner address.')), signer: Signer, }) .check(z.describe('A validator’s owner address was changed.')), type: z .literal('validator-ownership-transferred') .check(z.describe('Activity type that tells you which `data` shape this entry uses.')), }) .check(z.meta({ title: 'Validator ownership transferred' })), z .object({ ...base, data: z .object({ newOwner: Schema.Address.check(z.describe('New contract owner.')), oldOwner: Schema.Address.check(z.describe('Previous contract owner.')), signer: Signer, }) .check(z.describe('Contract ownership was transferred.')), type: z .literal('ownership-transferred') .check(z.describe('Activity type that tells you which `data` shape this entry uses.')), }) .check(z.meta({ title: 'Ownership transferred' })), z .object({ ...base, data: z .object({ index: Schema.DecimalString.check(z.describe('Validator index.')), publicKey: Schema.Hex.check(z.describe('Validator public key.')), signer: Signer, validator: Schema.Address.check(z.describe('Validator address.')), }) .check(z.describe('A validator was migrated.')), type: z .literal('validator-migrated') .check(z.describe('Activity type that tells you which `data` shape this entry uses.')), }) .check(z.meta({ title: 'Validator migrated' })), z .object({ ...base, data: z .object({ index: Schema.DecimalString.check(z.describe('Validator index.')), publicKey: Schema.Hex.check(z.describe('Validator public key.')), signer: Signer, validator: Schema.Address.check(z.describe('Validator address.')), }) .check(z.describe('A validator migration was skipped.')), type: z .literal('validator-migration-skipped') .check(z.describe('Activity type that tells you which `data` shape this entry uses.')), }) .check(z.meta({ title: 'Validator migration skipped' })), z .object({ ...base, data: z .object({ nextEpoch: Schema.DecimalString.check( z.describe('Epoch the rotation is scheduled for.'), ), previousEpoch: Schema.DecimalString.check(z.describe('Previous rotation epoch.')), signer: Signer, }) .check(z.describe('A network-identity rotation epoch was scheduled.')), type: z .literal('network-identity-rotation-epoch-set') .check(z.describe('Activity type that tells you which `data` shape this entry uses.')), }) .check(z.meta({ title: 'Network identity rotation epoch set' })), z .object({ ...base, data: z .object({ height: Schema.DecimalString.check(z.describe('Block height at initialization.')), signer: Signer, }) .check(z.describe('A contract was initialized.')), type: z .literal('initialized') .check(z.describe('Activity type that tells you which `data` shape this entry uses.')), }) .check(z.meta({ title: 'Initialized' })), z .object({ ...base, data: z .object({ signer: Signer }) .check( z.describe( 'A transaction the address sent or paid for that Tempo could not classify into a known activity type. The decoded and raw logs are surfaced in the top-level `events` array.', ), ), type: z .literal('unknown') .check(z.describe('Activity type that tells you which `data` shape this entry uses.')), }) .check(z.meta({ title: 'Unknown' })), ] as const /** The classified activity item variants (discriminated by `type`). */ export const items = itemSchemas.map((item) => { const [type] = item.shape.type.def.values if (typeof type !== 'string') throw new Error('Activity item schemas require a string type.') const name = `ActivityItem${type .replace(/[^A-Za-z0-9]+(.)/g, (_, character: string) => character.toUpperCase()) .replace(/^(.)/, (character) => character.toUpperCase())}` return OpenApi.component(item, name) }) as unknown as typeof itemSchemas /** A single classified activity item. */ export const Item = OpenApi.component( z.union(items).check(z.describe('One classified activity item in the address feed.')), 'ActivityItem', ) /** A folded access-key group: consecutive same-signer items on one UTC day. */ export const Group = z .object({ chainId: z .optional(Schema.ChainId) .check( z.describe('Chain ID containing this activity group. Returned when `include=zones`.'), z.meta({ examples: [421700001] }), ), data: z .object({ items: z.array(Item).check(z.describe('Activity items included in this group.')), signer: Schema.Address.check( z.describe('Access-key address that signed every item in the group.'), ), }) .check(z.describe('Grouped activity entries folded together for readability.')), perspective: z .enum(['incoming', 'outgoing']) .check( z.describe( 'The shared perspective of the items in this group (see an item’s `perspective`).', ), ), id: z.string().check( z.describe('Stable group ID derived from the activity IDs inside the group.'), z.meta({ examples: [ '0x515801d7f9a5ac705e793e85904c9c69b3f1694b465cc8fb6ba3f0298dc82665-3..0x515801d7f9a5ac705e793e85904c9c69b3f1694b465cc8fb6ba3f0298dc82665-7:5', ], }), ), timestamp: z .iso.datetime() .check(z.describe('Timestamp of the first item in the group, as an ISO 8601 string.'), z.meta({ examples: ['2024-01-01T00:00:00Z'] })), // prettier-ignore title: z .literal('Group') .check(z.describe('Human-readable label for this entry, suitable for display in a feed.')), type: z.literal('group'), }) .check( z.meta({ title: 'Group' }), z.describe('A group of consecutive activity items signed by the same access key.'), ) const activityGroup = OpenApi.component(Group, 'ActivityGroup') /** A single activity item, or a folded access-key group. */ export const Entry = OpenApi.component( z .union([Item, activityGroup]) .check( z.describe('Either one activity item, or a grouped set of access-key activity items.'), ), 'ActivityEntry', ) /** Optional activity resources and selection modes. */ export const Include = z .enum(includeFields) .check(z.describe('Additional activity resources and selection modes to include.')) /** Parses comma-separated optional activity resources and selection modes. */ export const includeQuery = Schema.includeQuery( Include, 'Comma-separated activity options. `zones` combines the selected parent chain with every readable Zone.', ) /** Schemas for the getAddressActivities operation. */ export namespace getAddressActivities { /** Path parameters for address activity requests. */ export const Params = z .object({ address: Schema.Address.check( z.describe('Account address whose activity you want to list.'), ), }) .check(z.describe('Path parameters for the address activity request.')) /** Query parameters for address activity requests. */ export const Query = z .strictObject({ chainId: Schema.ChainIdQuery, cursor: Schema.Cursor, // Folding is opt-in: by default every item is returned at the root. group: z ._default(Schema.booleanQuery(), false) .check( z.describe( 'Set to `true` to fold consecutive items signed by the same access key on the same UTC day into `group` entries. Defaults to `false`, which returns every item separately.', ), z.meta({ examples: [true] }), ), include: includeQuery, limit: Schema.Limit, // Raw-log capture is opt-in: it can add a large `events` array to every // item, so the default feed stays lean. logs: z ._default(Schema.booleanQuery(), false) .check( z.describe( 'Set to `true` to attach each item’s full transaction log set (decoded when possible) as a top-level `events` array, so no co-located log is hidden. Defaults to `false`. `unknown` items always include it.', ), z.meta({ examples: [true] }), ), 'valuation.currency': Schema.Denomination, }) .check( z.describe( 'Query parameters for the address activity request. This feed uses cursor pagination only because activity classification can fold a variable number of rows into each entry, so positional `page` pagination would not be stable.', ), ) /** * Page of classified activity. Mirrors the wallet activity feed: a * newest-first list of `data` entries (items, or access-key groups when * `group=true`) plus the root `nextCursor` pagination field shared with * other list endpoints. */ export const Response = OpenApi.component( z .object({ data: z.array(Entry).check(z.describe('Activity feed entries for the address.')), meta: z .optional(schema.Meta) .check(z.describe('Page-level resources, such as valuation rate provenance.')), nextCursor: Schema.NextCursor, }) .check(z.describe('A page of human-readable activity for the address.')), 'AddressActivityList', ) } /** Schemas for the getTransactionActivities operation. */ export namespace getTransactionActivities { /** Path parameters for transaction activity requests. */ export const Params = z .object({ transactionHash: Schema.hash(exampleHash).check( z.describe( 'A 32-byte hash, `0x`-prefixed and returned in lowercase, that identifies the transaction whose activity you want to list.', ), ), }) .check(z.describe('Path parameters for the transaction activity request.')) /** Query parameters for transaction activity requests. */ export const Query = z .strictObject({ chainId: Schema.ChainIdQuery, include: includeQuery, // Raw-log capture is opt-in: it can add a large `events` array to every // item, so the default response stays lean. logs: z ._default(Schema.booleanQuery(), false) .check( z.describe( 'Set to `true` to attach each item’s full transaction log set (decoded when possible) as a top-level `events` array, so no co-located log is hidden. Defaults to `false`. `unknown` items always include it.', ), z.meta({ examples: [true] }), ), 'valuation.currency': Schema.Denomination, }) .check(z.describe('Query parameters for the transaction activity request.')) /** * The classified activity for one transaction: a list of `data` items, * one per independent activity, describing what the transaction did * onchain. Unpaginated (a single transaction is finite), so there is no * `nextCursor`. */ export const Response = OpenApi.component( z .object({ chainId: Schema.ChainId.check( z.describe('Chain ID containing the transaction.'), z.meta({ examples: [4217] }), ), data: z.array(Item).check(z.describe('Activity items classified from the transaction.')), meta: z .optional(schema.Meta) .check(z.describe('Page-level resources, such as valuation rate provenance.')), }) .check(z.describe('The human-readable activity that happened on a transaction.')), 'TransactionActivityList', ) } } /** A token in the public resource shape (derived from {@link schema.Token}). */ export type Token = z.output /** A single classified activity item (derived from {@link schema.Item}). */ export type Item = z.output /** A single activity item, or a folded access-key group (derived from {@link schema.Entry}). */ export type Entry = z.output type AmountKey = 'destinationAmount' | 'refundAmount' | 'sourceAmount' type TokenKey = 'destinationToken' | 'refund' | 'sourceToken' /** Token fields available before RPC metadata resolution. */ type UnenrichedToken = { address: Token['address'] amount: z.output } type UnenrichedData = { [key in keyof data as key extends AmountKey ? never : key]: key extends TokenKey ? UnenrichedToken | Extract : data[key] } /** A classified item whose token stubs have not been enriched with structured amounts. */ type UnenrichedItem = { [type in Item['type']]: Omit, 'data'> & { data: UnenrichedData['data']> } }[Item['type']] /** * A classified item before {@link list} stamps its display {@link Item.title}. * Internal classifiers build these; `list` derives `title` from `type` via * {@link titleByType}, so each branch never repeats the human label. */ type RawItem = { [type in UnenrichedItem['type']]: Omit, 'title'> }[UnenrichedItem['type']] /** * Human-readable label for each activity {@link Item.type}, mirroring the per-variant * OpenAPI schema titles. Used to stamp {@link Item.title} during {@link list}. */ const titleByType: Record = { 'access-key-created': 'Access key created', 'access-key-revoked': 'Access key revoked', approval: 'Allowance approved', 'assets-deposited': 'Assets deposited', 'assets-withdrawn': 'Assets withdrawn', 'blacklist-updated': 'Blacklist updated', burn: 'Token burned', 'burn-blocked': 'Burn blocked', 'channel-close-cancelled': 'Channel close cancelled', 'channel-closed': 'Channel closed', 'channel-funded': 'Channel funded', 'channel-opened': 'Channel opened', 'channel-settled': 'Channel settled', 'compound-policy-created': 'Compound policy created', 'fee-rebalance-swap': 'Fees rebalanced', 'fee-user-token-set': 'Fee user token set', 'fee-validator-token-set': 'Fee validator token set', 'fees-distributed': 'Fees distributed', initialized: 'Initialized', 'key-authorization-witness': 'Key authorization witnessed', 'key-authorization-witness-burned': 'Key authorization witness burned', 'master-registered': 'Master registered', mint: 'Token minted', 'network-identity-rotation-epoch-set': 'Network identity rotation epoch set', 'nonce-incremented': 'Nonce incremented', 'order-cancelled': 'Order cancelled', 'order-filled': 'Order filled', 'order-flipped': 'Order flipped', 'order-placed': 'Order placed', 'ownership-transferred': 'Ownership transferred', 'pair-created': 'Trading pair created', 'policy-admin-set': 'Policy admin set', 'policy-created': 'Policy created', 'private-assets-deposited': 'Private assets deposited', 'private-shares-redeemed': 'Private shares redeemed', 'reward-distributed': 'Reward distributed', 'reward-recipient-set': 'Reward recipient set', 'role-admin-set': 'Role admin set', 'role-membership-set': 'Role membership set', 'session-closed': 'Session closed', 'shares-deposited': 'Shares deposited', 'shares-redeemed': 'Shares redeemed', 'shares-redemption-cancelled': 'Share redemption cancelled', 'shares-redemption-finalized': 'Share redemption finalized', 'shares-redemption-requested': 'Share redemption requested', 'spending-limit-updated': 'Spending limit updated', swap: 'Tokens swapped', 'token-created': 'Token created', 'token-logo-set': 'Token logo set', 'token-next-quote-token-set': 'Token next quote token set', 'token-pause-set': 'Token pause set', 'token-quote-token-set': 'Token quote token set', 'token-supply-cap-set': 'Token supply cap set', 'token-transfer-policy-set': 'Token transfer policy set', transfer: 'Token transferred', unknown: 'Unknown', 'validator-added': 'Validator added', 'validator-deactivated': 'Validator deactivated', 'validator-fee-recipient-set': 'Validator fee recipient set', 'validator-ip-set': 'Validator IP set', 'validator-migrated': 'Validator migrated', 'validator-migration-skipped': 'Validator migration skipped', 'validator-ownership-transferred': 'Validator ownership transferred', 'validator-rotated': 'Validator rotated', 'whitelist-updated': 'Whitelist updated', } /** Stamps the display {@link Item.title} onto a classified item from {@link titleByType}. */ function withTitle(item: item): item & { title: string } { return { ...item, title: titleByType[item.type] } } /** * Human-readable label for each `viem/tempo` ABI event name, used to title the * entries of an item's raw `events` capture. Keyed by event name (not activity * type), so co-located logs read naturally even when they are not the headline. */ const titleByEventName: Record = { AccessKeySpend: 'Access key spent', Approval: 'Allowance approved', BlacklistUpdated: 'Blacklist updated', Burn: 'Token burned', BurnBlocked: 'Burn blocked', Deposit: 'Assets deposited', Deposited: 'Assets deposited', EarnDeposit: 'Private assets deposited', EarnRedeem: 'Private shares redeemed', RedeemCancelled: 'Share redemption cancelled', RedeemFinalized: 'Share redemption finalized', RedeemRequested: 'Share redemption requested', Redeemed: 'Shares redeemed', VenueSharesDeposited: 'Shares deposited', Withdraw: 'Assets withdrawn', WithdrewExact: 'Assets withdrawn', ChannelClosed: 'Channel closed', ChannelOpened: 'Channel opened', CloseRequestCancelled: 'Channel close cancelled', CloseRequested: 'Channel close requested', CompoundPolicyCreated: 'Compound policy created', FeeRecipientUpdated: 'Validator fee recipient set', FeesDistributed: 'Fees distributed', Initialized: 'Initialized', IpAddressesUpdated: 'Validator IP set', KeyAuthorizationWitness: 'Key authorization witnessed', KeyAuthorizationWitnessBurned: 'Key authorization witness burned', KeyAuthorized: 'Access key created', KeyRevoked: 'Access key revoked', LogoURIUpdated: 'Token logo set', MasterRegistered: 'Master registered', Mint: 'Token minted', NetworkIdentityRotationEpochSet: 'Network identity rotation epoch set', NextQuoteTokenSet: 'Token next quote token set', NonceIncremented: 'Nonce incremented', OrderCancelled: 'Order cancelled', OrderFilled: 'Order filled', OrderFlipped: 'Order flipped', OrderPlaced: 'Order placed', OwnershipTransferred: 'Ownership transferred', PairCreated: 'Trading pair created', PauseStateUpdate: 'Token pause set', PolicyAdminUpdated: 'Policy admin set', PolicyCreated: 'Policy created', QuoteTokenUpdate: 'Token quote token set', RebalanceSwap: 'Fees rebalanced', RewardDistributed: 'Reward distributed', RewardRecipientSet: 'Reward recipient set', RoleAdminUpdated: 'Role admin set', RoleMembershipUpdated: 'Role membership set', Settled: 'Channel settled', SkippedValidatorMigration: 'Validator migration skipped', SpendingLimitUpdated: 'Spending limit updated', SupplyCapUpdate: 'Token supply cap set', TokenCreated: 'Token created', TopUp: 'Channel funded', Transfer: 'Token transferred', TransferPolicyUpdate: 'Token transfer policy set', TransferWithMemo: 'Token transferred', UserTokenSet: 'Fee user token set', ValidatorAdded: 'Validator added', ValidatorDeactivated: 'Validator deactivated', ValidatorMigrated: 'Validator migrated', ValidatorOwnershipTransferred: 'Validator ownership transferred', ValidatorRotated: 'Validator rotated', ValidatorTokenSet: 'Fee validator token set', WhitelistUpdated: 'Whitelist updated', } // keccak256("CloseRequested(bytes32,address,address,uint256)") — emitted by a // payment channel when settlement is requested; matched on the raw selector // because it is not part of the merged `viem/tempo` ABI. const closeRequestedSelector = '0xf5a36fc00a96cbb9cf1f8f59299165e1d8ffffe94396d82904b4da524d16bbce' // keccak256("AccessKeySpend(address,address,address,uint256,uint256)") — emitted // by the accountKeychain whenever a tx is signed by an access key. topic2 is the // access-key publicKey, i.e. the real signer. const accessKeySpendSelector = '0xe0815e3aaadddf4dd75bde97fc060f0c38afe18e87a169be86a3f5c28247f192' /** TIP-20 / ERC-20 `Transfer` event signature for the decoded CTE. */ const transferSignature = 'event Transfer(address indexed from, address indexed to, uint256 value)' // Standard ERC-4626 vault events, absent from the merged `viem/tempo` ABI. // Decoding them lets external vault deposits/withdrawals classify instead of // falling through to the share-token mint/burn legs. 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 // TIP-20 payment-channel reserve precompile. Not exported by `viem/tempo` // `Addresses`, so the known address is pinned here: channel events // (`ChannelOpened`/`Settled`/`TopUp`/`ChannelClosed`) are emitted here, and // their paired TIP-20 `Transfer` legs use it as the funding counterparty, which // is how `channel-funded`/`channel-settled`/`channel-closed` recover the token. const tip20ChannelReserve = '0x4d50500000000000000000000000000000000000' as Address.Address // Tempo system addresses other than the DEX are never swap-route // counterparties; a newly shipped precompile joins the exclusion automatically. const nonRouteSystemAddresses = new Set( [...Object.values(Addresses), tip20ChannelReserve] .filter((value) => value !== Addresses.stablecoinDex) .map((value) => value.toLowerCase()), ) /** Number of grouped entries to target before stopping the page-fetch loop. */ const minGroupEntries = 5 /** Largest number of items folded into a single access-key group. */ const maxGroupSize = 50 /** Token-bearing keys on an item's `data` payload, walked for enrichment. */ const tokenKeys = ['destinationToken', 'refund', 'sourceToken'] as const const amountKeyByTokenKey = { destinationToken: 'destinationAmount', refund: 'refundAmount', sourceToken: 'sourceAmount', } as const /** The base fields ({@link schema.base}) every activity item carries. */ type Base = { id: string logIndex: number perspective: 'incoming' | 'outgoing' timestamp: string transactionHash: Hex.Hex } /** Builds common public activity fields from the normalized transaction hash and representative log. */ function baseFor(options: { logIndex: number perspective: 'incoming' | 'outgoing' timestamp: string transactionHash: Hex.Hex }): Base { return { perspective: options.perspective, id: `${options.transactionHash}-${options.logIndex}`, logIndex: options.logIndex, timestamp: options.timestamp, transactionHash: options.transactionHash, } } /** * A normalized provider row. Mirrors the bounded receipt/log lookups * (`source: 'q1'`, full log columns) and the decoded `Transfer` projection * (`source: 'q2'`, transfer columns), so the classification pipeline does not * care which query produced the row. */ export type Row = { blockNum: number blockTimestamp: number data: string | null feePayer: Address.Address | null logAddress: Address.Address logIdx: number selector: string | null source: 'q1' | 'q2' topic1: string | null topic2: string | null topic3: string | null transferAmount?: bigint transferFrom?: Address.Address transferTo?: Address.Address transferToken?: Address.Address txHash: Hex.Hex txIdx: number txSender: Address.Address | null } /** A swappable activity data source. */ export type Provider = { /** Encodes the opaque cursor that resumes strictly after `group`. */ cursorForGroup(group: { blockNum: number; txIdx: number }): string /** Fetches a window of merged rows in `(blockNum desc, txIdx desc)` order. */ fetchRows(options: { address: Address.Address cursor?: string | undefined limit: number }): Promise<{ rows: Row[] }> } /** Internal group of rows that share a transaction hash. */ type TxGroup = { blockNum: number blockTimestamp: number feePayer: Address.Address | null rows: Row[] txHash: Hex.Hex txIdx: number txSender: Address.Address | null } /** * Creates address-scoped activity handlers, mounted under the `/addresses` * composer. Exposes `GET /:address/activities`. * * Runs the full activity classification pipeline: outbound receipt/log * lookups plus inbound and outbound transfer queries, merged * and decoded into typed items (`transfer`/`mint`/`burn`/`swap`/ * `approval`/`session-closed`/`access-key-created`/`access-key-revoked`/ * `token-created`/`burn-blocked`/`channel-opened`/`channel-funded`/ * `channel-settled`/`channel-closed`/`channel-close-cancelled`/ * `order-placed`/`order-cancelled`/`order-filled`/`order-flipped`/ * `pair-created`/`fees-distributed`/`fee-rebalance-swap`/`reward-distributed`/ * `reward-recipient-set`/`spending-limit-updated`/ token, fee, policy, registry, * access-key-witness and validator admin events (e.g. `token-pause-set`, * `policy-created`, `validator-added`)/`unknown`), then folds * consecutive access-key-signed items into `group` entries. Pagination uses the * same `(block_num, tx_idx)` keyset cursor the pipeline emits. */ export function addresses(options: addresses.Options = {}) { const oracle = options.fx?.oracle ?? FxOracle.ecb() return new Hono().get( '/v1/addresses/:address/activities', Auth.policy({ apiKey: { scopes: ['data:read'] }, mpp: true, public: true }), OpenApi.validate('param', schema.getAddressActivities.Params, { code: 'address_invalid', message: 'Check the account address and try again.', }), OpenApi.validate('query', schema.getAddressActivities.Query, { code: 'query_invalid', message: 'Check the query parameters and try again.', }), OpenApi.describeRoute({ description: 'Get a human-readable feed of what an address has been doing onchain, including payments sent and received, swaps, approvals, and more.', operationId: 'getAddressActivities', responses: OpenApi.responses({ errors: { 400: { codes: ['address_invalid', 'chain_id_invalid', 'chain_id_unsupported', 'query_invalid'], }, 502: 'Tempo could not read activity or token data from an upstream service.', }, success: { description: 'A page of address activity entries.', schema: schema.getAddressActivities.Response, }, }), summary: 'List address activities', tags: ['Activities'], }), Cache.response({ cacheControl: Cache.policies.noStore, name: 'tempo-api:addresses:v1', key: (c) => Cache.urlKey(c, schema.getAddressActivities.Query), }), async (c) => { if (Auth.narrowAccess) return Auth.paidAccessError(c) if (OpenApi.narrowValidation) return OpenApi.validationError(c, { code: 'address_invalid', message: 'Check the account address and try again.', }) if (OpenApi.narrowValidation) return OpenApi.validationError(c, { code: 'query_invalid', message: 'Check the query parameters and try again.', }) const { address } = c.req.valid('param') const query = c.req.valid('query') if (!query.include.includes('zones')) Cache.setPolicy( c, query.cursor === undefined ? Cache.policies.feed : Cache.policies.metadata, ) try { const chainId = query.chainId ?? c.get('chainId') const listed = await Timing.time(c, 'address_activities', async () => { if (!query.include.includes('zones')) { const result = await Store.memoize( (signal) => list(tidx({ chainId, signal, tidx: c.get('getTidx')(chainId) }), { address: address as Hex.Hex, cursor: query.cursor, group: query.group, limit: query.limit, logs: query.logs, store: c.get('store'), }), { key: `activities:v1:${chainId}:${address.toLowerCase()}:${query.group ?? false}:${query.logs ?? false}:${query.cursor ?? 'head'}:${query.limit}`, store: c.get('store'), ttl: Ttl.seconds(15), }, ) return { chainId, kind: 'chain' as const, ...result } } const zoneChainIds = c.get('zoneChainIds') if (!zoneChainIds) throw new Error('Inferred Zone selection was not resolved.') const chainIds = [chainId, ...zoneChainIds] const result = await Store.memoize( (signal) => listChains({ address: address as Hex.Hex, cursor: query.cursor, group: query.group, limit: query.limit, logs: query.logs, providers: chainIds.map((chainId) => ({ chainId, provider: tidx({ chainId, signal, tidx: c.get('getTidx')(chainId), }), })), store: c.get('store'), }), { key: `activities:zones:v2:${chainIds.join(',')}:${address.toLowerCase()}:${query.group ?? false}:${query.logs ?? false}:${query.cursor ?? 'head'}:${query.limit}`, store: c.get('store'), ttl: Ttl.seconds(15), }, ) return { kind: 'zones' as const, ...result } }) const { items, rates } = listed.kind === 'zones' ? await enrichChainTokens(c, { denomination: query['valuation.currency'], include: query.include, items: listed.items, oracle, }) : await enrichTokens(c, { chainId: listed.chainId, denomination: query['valuation.currency'], include: query.include, items: listed.items, oracle, }) // Fold consecutive access-key-signed items into `group` entries only // when requested; otherwise every item is returned at the root. const data = query.group ? groupByAccessKey(items) : items return c.json( Response.validated(schema.getAddressActivities.Response, { data, ...(rates ? { meta: { valuation: Valuation.pricing(rates, oracle) } } : {}), nextCursor: listed.nextCursor, }), 200, ) } catch (cause) { if (cause instanceof Valuation.UnsupportedDenominationError) return Response.error(c, { code: 'query_invalid', message: cause.message, status: 400 }) return Response.upstream(c, cause) } }, ) } export declare namespace addresses { /** Options for the address-scoped activity handlers. */ type Options = { /** FX configuration backing amount valuation. */ fx?: Valuation.addresses.Fx | undefined } } /** * Creates transaction-scoped activity handlers, mounted under the * `/transactions` composer. Exposes `GET /:transactionHash/activities`. * * Runs the same `groupByTx` → `classifyGroup` pipeline the address feed uses * over a single transaction's logs, anchored on the transaction sender as the * viewer (a transaction-scoped feed has no subject account). Returns the * classified items (one per independent activity) with the full per-tx * event set available via `logs=true`. */ export function transactions(options: transactions.Options = {}) { const oracle = options.fx?.oracle ?? FxOracle.ecb() return new Hono().get( '/v1/transactions/:transactionHash/activities', Auth.policy({ apiKey: { scopes: ['data:read'] }, mpp: true, public: true }), OpenApi.validate('param', schema.getTransactionActivities.Params, { code: 'transaction_invalid', message: 'Check the transaction hash and try again.', }), OpenApi.validate('query', schema.getTransactionActivities.Query, { code: 'query_invalid', message: 'Check the query parameters and try again.', }), OpenApi.describeRoute({ description: 'Get a human-readable list of what happened on one transaction onchain, including payments, swaps, approvals, and more.', operationId: 'getTransactionActivities', responses: OpenApi.responses({ errors: { 400: { codes: [ 'chain_id_invalid', 'chain_id_unsupported', 'query_invalid', 'transaction_invalid', ], }, 404: { description: 'No mined transaction was found for that hash.', codes: ['transaction_not_found'], }, 409: { description: 'More than one readable chain contains that transaction hash.', codes: ['transaction_ambiguous'], }, 502: 'Tempo could not read activity or token data from an upstream service.', }, success: { description: 'The classified activity for the transaction.', schema: schema.getTransactionActivities.Response, }, }), summary: 'List transaction activities', tags: ['Activities'], }), Cache.response({ // Default to no-store so a 404 does not mask the eventual mined result. // Successful responses use the metadata policy for optional valuations. cacheControl: Cache.policies.noStore, name: 'tempo-api:transactions:v1', key: (c) => Cache.urlKey(c, schema.getTransactionActivities.Query), }), async (c) => { if (Auth.narrowAccess) return Auth.paidAccessError(c) if (OpenApi.narrowValidation) return OpenApi.validationError(c, { code: 'transaction_invalid', message: 'Check the transaction hash and try again.', }) if (OpenApi.narrowValidation) return OpenApi.validationError(c, { code: 'query_invalid', message: 'Check the query parameters and try again.', }) const { transactionHash } = c.req.valid('param') const query = c.req.valid('query') try { const result = await Timing.time(c, 'transaction_activities', async () => { const selected = await (async () => { if (!query.include.includes('zones')) return { chainId: query.chainId ?? c.get('chainId'), kind: 'selected' as const, receipt: null, } const zoneChainIds = c.get('zoneChainIds') if (!zoneChainIds) throw new Error('Inferred Zone selection was not resolved.') const chainIds = [query.chainId ?? c.get('chainId'), ...zoneChainIds] // Cache only positive resolution. Missing transactions must remain // visible as soon as any selected indexer catches up. const match = await Store.memoize( async () => { const probes: readonly ZoneSelection.uniqueMatch.Probe[] = await Promise.all( chainIds.map(async (chainId) => { try { return { chainId, value: await getTransactionReceipt({ chainId, tidx: c.get('getTidx')(chainId), transactionHash, }), } } catch (cause) { return { cause, chainId } } }), ) return ZoneSelection.uniqueMatch(probes) }, { key: `activities:zones:v2:${chainIds.join(',')}:tx:${transactionHash.toLowerCase()}`, shouldCache: (match) => match.kind === 'selected', store: c.get('store'), ttl: Ttl.seconds(15), }, ) if (match.kind === 'failed') { c.set( 'providerFailures', match.failures.map(({ cause, chainId }) => ({ ...Tidx.providerFailure(cause), chainId, })), ) throw match.failures[0]!.cause } if (match.kind !== 'selected') return match return { chainId: match.chainId, kind: 'selected' as const, receipt: match.value } })() if (selected.kind !== 'selected') return selected const { chainId } = selected const unenrichedItems = await Store.memoize( () => selected.receipt === null ? getTransactionActivities_inner({ chainId, logs: query.logs, store: c.get('store'), tidx: c.get('getTidx')(chainId), time: (name, fn) => Timing.time(c, name, fn), transactionHash, }) : classifyTransactionActivities({ chainId, logs: query.logs, receipt: selected.receipt, store: c.get('store'), tidx: c.get('getTidx')(chainId), time: (name, fn) => Timing.time(c, name, fn), transactionHash, }), { key: `activities:v1:${chainId}:tx:${transactionHash.toLowerCase()}:${query.logs ?? false}`, store: c.get('store'), ttl: Ttl.seconds(15), }, ) return unenrichedItems === null ? ({ kind: 'not_found' } as const) : ({ chainId, items: unenrichedItems, kind: 'found' } as const) }) if (result.kind === 'not_found') return Response.error(c, { code: 'transaction_not_found', message: 'Transaction not found', status: 404, }) if (result.kind === 'ambiguous') return Response.error(c, { code: 'transaction_ambiguous', message: 'Transaction hash matched more than one readable chain', status: 409, }) // Mined activity can carry current valuations and verification data. if (!query.include.includes('zones')) Cache.setPolicy(c, Cache.policies.metadata) c.set('chainId', result.chainId) // Build immutable token and structured amount copies from RPC metadata. const { items, rates } = await enrichTokens(c, { chainId: result.chainId, denomination: query['valuation.currency'], include: query.include, items: result.items, oracle, }) return c.json( Response.validated(schema.getTransactionActivities.Response, { chainId: result.chainId, data: items, ...(rates ? { meta: { valuation: Valuation.pricing(rates, oracle) } } : {}), }), 200, ) } catch (cause) { if (cause instanceof Valuation.UnsupportedDenominationError) return Response.error(c, { code: 'query_invalid', message: cause.message, status: 400 }) return Response.upstream(c, cause) } }, ) } export declare namespace transactions { /** Options for the transaction-scoped activity handlers. */ type Options = { /** FX configuration backing amount valuation. */ fx?: Valuation.addresses.Fx | undefined } } /** * Classifies the activity for a single transaction. Fetches the transaction's * receipt (for its block position, sender, and fee payer) and its logs, then * runs the same `groupByTx` → `classifyGroup` pipeline the address feed uses, * anchored on the transaction sender as the viewer. Returns `null` when no * receipt exists for the hash (an unknown or not-yet-mined transaction), or an * empty list for a mined transaction that emitted no classifiable logs. * * Items carry stub tokens; the caller enriches them via {@link enrichTokens}. */ export async function getTransactionActivities( options: getTransactionActivities.Options, ): Promise { return getTransactionActivities_inner({ ...options, time: async (_name, fn) => fn(), }) } async function getTransactionActivities_inner( options: TransactionActivityOptions, ): Promise { const receipt = await options.time('transaction_activity_receipt', () => getTransactionReceipt(options), ) if (!receipt) return null return classifyTransactionActivities({ ...options, receipt }) } async function getTransactionReceipt( options: getTransactionReceipt.Options, ): Promise { const { chainId, tidx: tidxClient } = options const hash = options.transactionHash.toLowerCase() // The `receipts` table is indexed by `tx_hash`, so this point lookup needs no // block bound; it also yields the block number the `logs` lookup requires. // Raw-table query (no signature-decoded CTE), so the inline SQL is cast to // `string`. const receiptResult = await tidxClient.fetch({ chainId, query: `SELECT block_num, tx_idx, "from", fee_payer FROM receipts WHERE tx_hash = '${hash}' LIMIT 1` as string, }) const receipt = receiptResult.rows[0] if (!receipt) return null return { blockNum: Number(Value.toNumber(receipt['block_num'])), feePayer: lowerOrNull(receipt['fee_payer']), txSender: lowerOrNull(receipt['from']), } } declare namespace getTransactionReceipt { type Options = Pick } type TransactionReceipt = { /** Block number containing the transaction. */ blockNum: number /** Account that paid the transaction fee, when indexed. */ feePayer: Address.Address | null /** Account that sent the transaction, when indexed. */ txSender: Address.Address | null } async function classifyTransactionActivities( options: classifyTransactionActivities.Options, ): Promise { const { chainId, logs = false, receipt, tidx: tidxClient } = options const hash = options.transactionHash.toLowerCase() const { blockNum, feePayer, txSender } = receipt const fingerprintMap_promise = options .time('transaction_activity_mpp', () => Mpp.fingerprintMap({ store: options.store })) .catch(() => ({})) // `logs` is sorted by block position, so a bare `tx_hash =` scans to genesis; // pair it with the receipt's block number (see `embedReceipts` in receipts.ts). const logsResult = await options.time('transaction_activity_logs', () => tidxClient.fetch({ chainId, query: `SELECT tx_hash, block_num, block_timestamp, tx_idx, log_idx, address, selector, topic1, topic2, topic3, data FROM logs WHERE block_num = ${blockNum} AND tx_hash = '${hash}' ORDER BY log_idx ASC` as string, }), ) const rows: Row[] = logsResult.rows.map((r) => ({ blockNum: Number(Value.toNumber(r['block_num'])), blockTimestamp: Number(Value.toNumber(r['block_timestamp'])), data: Value.toText(r['data']) ?? null, feePayer, logAddress: lower(r['address']), logIdx: Number(Value.toNumber(r['log_idx'])), selector: Value.toText(r['selector']) ?? null, source: 'q1' as const, topic1: Value.toText(r['topic1']) ?? null, topic2: Value.toText(r['topic2']) ?? null, topic3: Value.toText(r['topic3']) ?? null, txHash: lowerHash(r['tx_hash']), txIdx: Number(Value.toNumber(r['tx_idx'])), txSender, })) // A mined transaction with no logs (e.g. a plain value transfer) has no // classifiable activity; return an empty list rather than a fabricated item. if (rows.length === 0) return [] // Best-effort MPP service directory: a fetch failure yields an empty map, so // enrichment never blocks classification. const fingerprintMap = await fingerprintMap_promise // Anchor the viewer on the transaction sender: the sender initiated every // activity in the transaction, so `perspective` is `outgoing` and `signer` // resolves to `self`. Fall back to the zero address when the sender is absent. const viewer = (txSender ?? zeroAddress) as Address.Address const group = groupByTx(rows)[0]! return classifyGroup(group, viewer, fingerprintMap, logs).map(withTitle) } declare namespace classifyTransactionActivities { type Options = TransactionActivityOptions & { /** Indexed receipt fields used to bound the log lookup. */ receipt: TransactionReceipt } } export declare namespace getTransactionActivities { /** Options for {@link getTransactionActivities}. */ type Options = { /** Source chain id. */ chainId: number /** Attach each item's full transaction `events` capture (decoded + raw logs). */ logs?: boolean | undefined /** Cache store for the MPP fingerprint-directory lookup. */ store: Store.Store /** TIDX query client. */ tidx: Tidx.Client /** Transaction hash to classify. */ transactionHash: string } } type TransactionActivityOptions = getTransactionActivities.Options & { time: ( name: 'transaction_activity_logs' | 'transaction_activity_mpp' | 'transaction_activity_receipt', fn: () => Promise | value, ) => Promise } /** * Lists classified activity for an address by running the shared pipeline * (`groupByTx` → `classifyGroup` → `enrichItem`) over rows from a * {@link Provider}. Returns classified items plus the next cursor; callers run * {@link groupByAccessKey} over the result. */ export async function list(provider: Provider, options: list.Options): Promise { const { address, cursor, group = false, limit = 20, logs = false } = options const viewer = address.toLowerCase() as Address.Address // Best-effort MPP service directory: a fetch failure yields an empty map, so // enrichment never blocks the feed. const fingerprintMap_promise = Mpp.fingerprintMap({ store: options.store }).catch(() => ({})) let currentCursor: string | undefined = cursor const allItems: UnenrichedItem[] = [] let hasMore = false let lastGroup: TxGroup | undefined // Fetch pages until the target entry count is reached (or no more rows // exist), up to three attempts. When folding is requested the target is // measured against grouped entries (folding can collapse a page to very few), // otherwise against the raw item count. for (let attempt = 0; attempt < 3; attempt++) { const { rows } = await provider.fetchRows({ address: viewer, cursor: currentCursor, limit }) const grouped = groupByTx(rows) const txGroups = grouped.slice(0, limit + 1) hasMore = txGroups.length > limit const page = txGroups.slice(0, limit) const fingerprintMap = await fingerprintMap_promise if (page.length === 0) break allItems.push( ...page.flatMap((txGroup) => classifyGroup(txGroup, viewer, fingerprintMap, logs).map(withTitle), ), ) lastGroup = page[page.length - 1] const enough = group ? groupByAccessKeyItems(allItems).length >= minGroupEntries : allItems.length >= limit if (enough || !hasMore) break if (lastGroup) currentCursor = provider.cursorForGroup(lastGroup) } let nextCursor: string | null = null if (hasMore && lastGroup) nextCursor = provider.cursorForGroup(lastGroup) // Items carry private `address`/`amount` stubs; the caller splits them into // public token references and structured amounts via {@link enrichTokens}. return { items: allItems, nextCursor } } export declare namespace list { /** Options for {@link list}. */ type Options = { /** Subject account address. */ address: Address.Address /** Opaque cursor from a previous page; omit for the head page. */ cursor?: string | undefined /** Whether the caller will fold items into access-key groups (tunes the page-fill target). */ group?: boolean | undefined /** Attach each item's full transaction `events` capture (decoded + raw logs). */ logs?: boolean | undefined /** Target page size (entries after grouping). */ limit?: number | undefined /** Cache store for the MPP fingerprint-directory lookup. */ store: Store.Store } /** Result of {@link list}. */ type ReturnType = { items: UnenrichedItem[] nextCursor: string | null } } const chainCursorVersion = 2 type ChainPageState = { /** Chain ID backing this page state. */ chainId: number /** Whether the current buffer reaches the end of this chain feed. */ exhausted: boolean /** Unconsumed transaction groups already fetched from this chain. */ groups: TxGroup[] /** Activity data source for this chain. */ provider: Provider } /** * Lists address activity across multiple chains in one stable timestamp-ordered * feed. Its opaque cursor retains each provider's native continuation. */ export async function listChains(options: listChains.Options): Promise { const { address, cursor, limit = 20, logs = false, providers, store } = options const group = options.group ?? false const orderedProviders = [...providers].sort((a, b) => a.chainId - b.chainId) const chainIds = orderedProviders.map(({ chainId }) => chainId) const cursors = decodeChainCursor(cursor, chainIds) const viewer = address.toLowerCase() as Address.Address const fingerprintMap_promise = Mpp.fingerprintMap({ store }).catch(() => ({})) const states: ChainPageState[] = orderedProviders.map(({ chainId, provider }) => ({ chainId, exhausted: false, groups: [], provider, })) const allItems: listChains.Item[] = [] let hasMore = false for (let attempt = 0; attempt < 3; attempt++) { await Promise.all( states.map(async (state) => { if (state.exhausted || state.groups.length > 0) return const { rows } = await state.provider.fetchRows({ address: viewer, cursor: cursors.get(state.chainId), limit, }) const groups = groupByTx(rows).slice(0, limit + 1) state.exhausted = groups.length <= limit state.groups.push(...groups) }), ) const candidates = states .flatMap((state) => state.groups.map((txGroup) => ({ state, txGroup }))) .sort((a, b) => { if (a.txGroup.blockTimestamp !== b.txGroup.blockTimestamp) return b.txGroup.blockTimestamp - a.txGroup.blockTimestamp if (a.state.chainId !== b.state.chainId) return a.state.chainId - b.state.chainId if (a.txGroup.blockNum !== b.txGroup.blockNum) return b.txGroup.blockNum - a.txGroup.blockNum return b.txGroup.txIdx - a.txGroup.txIdx }) const page = candidates.slice(0, limit) const fingerprintMap = await fingerprintMap_promise if (page.length === 0) break for (const { state, txGroup } of page) { state.groups.splice(state.groups.indexOf(txGroup), 1) cursors.set(state.chainId, state.provider.cursorForGroup(txGroup)) allItems.push( ...classifyGroup(txGroup, viewer, fingerprintMap, logs).map((item) => ({ ...withTitle(item), chainId: state.chainId, })), ) } hasMore = states.some((state) => !state.exhausted || state.groups.length > 0) const enough = group ? groupByAccessKeyItems(allItems).length >= minGroupEntries : allItems.length >= limit if (enough || !hasMore) break } // A consumed sentinel proves only that the provider may have more rows. // Probe drained buffers so `nextCursor` remains the end-of-list signal. await Promise.all( states.map(async (state) => { if (state.exhausted || state.groups.length > 0) return const { rows } = await state.provider.fetchRows({ address: viewer, cursor: cursors.get(state.chainId), limit: 1, }) const group = groupByTx(rows)[0] if (group) state.groups.push(group) else state.exhausted = true }), ) hasMore = states.some((state) => !state.exhausted || state.groups.length > 0) return { items: allItems, nextCursor: hasMore ? encodeChainCursor(cursors, chainIds) : null, } } export declare namespace listChains { /** One chain activity provider. */ type ChainProvider = { /** Chain ID. */ chainId: number /** Activity data source for the chain. */ provider: Provider } /** A classified item tagged with its chain ID. */ type Item = UnenrichedItem & { /** Chain ID containing the activity. */ chainId: number } /** Options for {@link listChains}. */ type Options = { /** Subject account address. */ address: Address.Address /** Opaque multi-chain cursor from a previous page. */ cursor?: string | undefined /** Whether the caller will fold access-key items. */ group?: boolean | undefined /** Target number of transaction groups per page. */ limit?: number | undefined /** Attach each item's full transaction event capture. */ logs?: boolean | undefined /** Chain providers participating in the merged feed. */ providers: readonly ChainProvider[] /** Cache store for the MPP fingerprint-directory lookup. */ store: Store.Store } /** Multi-chain classified page before token enrichment. */ type ReturnType = { /** Timestamp-ordered, chain-tagged activity items. */ items: Item[] /** Opaque continuation for every chain, or null at the end. */ nextCursor: string | null } } function decodeChainCursor(cursor: string | undefined, chainIds: readonly number[]) { const fields: Cursor.Field[] = ['int'] for (const _chainId of chainIds) fields.push('int', 'id') const decoded = cursor ? Cursor.decode(cursor, fields) : undefined const cursors = new Map() if (!decoded || decoded[0] !== chainCursorVersion) return cursors for (let index = 0; index < chainIds.length; index++) { const chainId = chainIds[index]! const offset = index * 2 + 1 if (decoded[offset] !== chainId) return new Map() const value = decoded[offset + 1] as string if (value !== 'head') cursors.set(chainId, value) } return cursors } function encodeChainCursor(cursors: ReadonlyMap, chainIds: readonly number[]) { return Cursor.encode([ chainCursorVersion, ...chainIds.flatMap((chainId) => [chainId, cursors.get(chainId) ?? 'head']), ]) } /** * Creates a TIDX-backed {@link Provider} that runs three sub-queries in * parallel and merges them: * - Q1 (outbound): page receipts where the address is `from` or `fee_payer`, * then fetch the selected transactions' logs with a bounded lookup. * - Q2 (inbound): decoded `Transfer` events to the address. * - Q3 (outbound transfers): decoded `Transfer` events from the address * (catches access-key-signed sends where `tx.from` differs from the address). */ export function tidx(deps: tidx.Deps): Provider { const { chainId, signal, tidx: tidxClient } = deps const fetch: Tidx.Client['fetch'] = (options) => { const requestSignal = signal && options.signal ? AbortSignal.any([signal, options.signal]) : (signal ?? options.signal) return tidxClient.fetch({ ...options, ...(requestSignal ? { signal: requestSignal } : {}), }) } function parseCursor(cursor: string): { blockNum: number; txIdx: number } | undefined { // Opaque `[blockNum, txIdx]` keyset cursor, the same encoding every other // list endpoint uses; a malformed/forged cursor decodes to `undefined` and // falls back to the head page. const decoded = Cursor.decode(cursor, ['int', 'int']) if (!decoded) return undefined return { blockNum: decoded[0] as number, txIdx: decoded[1] as number } } /** * Q1 pages outbound receipts, then fetches their logs by bounded block range * and transaction hash. The cursor preserves block and transaction index order. */ async function queryOutbound(options: { address: Address.Address cursor: { blockNum: number; txIdx: number } | undefined limit: number }): Promise { const { address, cursor, limit } = options const keyset = cursor ? `AND block_num <= ${cursor.blockNum} AND (block_num < ${cursor.blockNum} OR tx_idx < ${cursor.txIdx})` : '' const queryLimit = limit + 1 const fetchReceipts = (side: 'fee_payer' | 'from') => fetch({ chainId, // Raw-table query (no signature-decoded CTE), so the inline SQL is cast // to `string`. query: ` SELECT tx_hash, block_num, block_timestamp, tx_idx, "from", fee_payer FROM receipts WHERE "${side}" = '${address}' ${keyset} ORDER BY block_num DESC, tx_idx DESC LIMIT ${queryLimit} ` as string, }) // Fetching `limit + 1` from each side is sufficient to form the newest // `limit + 1` transactions after the two ordered sets are merged. const [paid, sent] = await Promise.all([fetchReceipts('fee_payer'), fetchReceipts('from')]) const seen = new Set() const receipts = [...paid.rows, ...sent.rows] .map((r) => ({ blockNum: Number(Value.toNumber(r['block_num'])), blockTimestamp: Number(Value.toNumber(r['block_timestamp'])), feePayer: lowerOrNull(r['fee_payer']), txHash: lowerHash(r['tx_hash']), txIdx: Number(Value.toNumber(r['tx_idx'])), txSender: lowerOrNull(r['from']), })) .filter((receipt) => { if (seen.has(receipt.txHash)) return false seen.add(receipt.txHash) return true }) .sort((a, b) => { if (a.blockNum !== b.blockNum) return b.blockNum - a.blockNum return b.txIdx - a.txIdx }) .slice(0, queryLimit) if (receipts.length === 0) return [] const blocks = receipts.map((receipt) => receipt.blockNum) const receiptByHash = new Map(receipts.map((receipt) => [receipt.txHash, receipt])) const txHashes = receipts.map((receipt) => `'${receipt.txHash}'`).join(', ') // `logs` is not keyed by transaction hash, so pair the bounded hash list // with the selected receipts' block range to avoid a full archive scan. const logs = await fetch({ chainId, // Raw-table query (no signature-decoded CTE), so the inline SQL is cast // to `string`. query: ` SELECT tx_hash, block_num, tx_idx, log_idx, address, selector, topic1, topic2, topic3, data FROM logs WHERE block_num BETWEEN ${Math.min(...blocks)} AND ${Math.max(...blocks)} AND tx_hash IN (${txHashes}) ORDER BY block_num DESC, tx_idx DESC, log_idx ASC ` as string, }) const rows = logs.rows.flatMap((r) => { const receipt = receiptByHash.get(lowerHash(r['tx_hash'])) if (!receipt) return [] return [ { blockNum: receipt.blockNum, blockTimestamp: receipt.blockTimestamp, data: Value.toText(r['data']) ?? null, feePayer: receipt.feePayer, logAddress: lower(r['address']), logIdx: Number(Value.toNumber(r['log_idx'])), selector: Value.toText(r['selector']) ?? null, source: 'q1' as const, topic1: Value.toText(r['topic1']) ?? null, topic2: Value.toText(r['topic2']) ?? null, topic3: Value.toText(r['topic3']) ?? null, txHash: receipt.txHash, txIdx: receipt.txIdx, txSender: receipt.txSender, }, ] }) return mergeAndSort(rows, [], []) } /** Q2/Q3: decoded `Transfer` events on one side of the address. */ async function queryTransfers(options: { address: Address.Address cursor: { blockNum: number; txIdx: number } | undefined limit: number side: 'inbound' | 'outbound' }): Promise { const { address, cursor, limit, side } = options const where = side === 'inbound' ? `"to" = '${address}' AND "from" != '${address}'` : `"from" = '${address}' AND "to" != '${address}'` const keyset = cursor ? `AND block_num < ${cursor.blockNum}` : '' const query = (blockKey?: string) => ` SELECT "from", "to", address, value, tx_hash, block_num, block_timestamp, log_idx, tx_idx FROM Transfer WHERE ${where} ${keyset} ${blockKey ? `ORDER BY ${blockKey} DESC, tx_idx DESC` : ''} LIMIT ${blockKey ? limit * 4 : Schema.countCap} ` const result = await (async () => { try { return await fetch({ chainId, query: query('block_num'), signatures: [transferSignature], }) } catch (error) { if (!Tidx.isDeterministicError(error)) throw error try { // The expression blocks a mis-selected block-ordered top-N walk. return await fetch({ chainId, query: query('block_num + 0'), signatures: [transferSignature], }) } catch (fallbackError) { if (!Tidx.isDeterministicError(fallbackError)) throw fallbackError const result = await fetch({ chainId, query: query(), signatures: [transferSignature], }) // An unordered sample cannot safely produce an activity cursor. if (result.rows.length >= Schema.countCap) throw fallbackError return result } } })() const rows = result.rows.map((r) => ({ blockNum: Number(Value.toNumber(r['block_num'])), blockTimestamp: Number(Value.toNumber(r['block_timestamp'])), data: null, feePayer: null, logAddress: lower(r['address']), logIdx: Number(Value.toNumber(r['log_idx'])), selector: null, source: 'q2' as const, topic1: null, topic2: null, topic3: null, transferAmount: BigInt(Value.toIntegerString(r['value']) ?? '0'), transferFrom: lower(r['from']), transferTo: lower(r['to']), transferToken: lower(r['address']), txHash: lowerHash(r['tx_hash']), txIdx: Number(Value.toNumber(r['tx_idx'])), txSender: null, })) return mergeAndSort([], rows, []).slice(0, limit * 4) } return { cursorForGroup(group) { return Cursor.encode([group.blockNum, group.txIdx]) }, async fetchRows({ address, cursor, limit }) { const parsed = cursor ? parseCursor(cursor) : undefined const [q1, q2, q3] = await Promise.all([ queryOutbound({ address, cursor: parsed, limit }), queryTransfers({ address, cursor: parsed, limit, side: 'inbound' }), queryTransfers({ address, cursor: parsed, limit, side: 'outbound' }), ]) return { rows: mergeAndSort(q1, q2, q3) } }, } } export declare namespace tidx { /** Dependencies for {@link tidx}. */ type Deps = { /** Source chain id. */ chainId: number /** Optional deadline signal shared by this provider's TIDX reads. */ signal?: AbortSignal | undefined /** TIDX query client. */ tidx: Tidx.Client } } /** * Merges three sub-query results into one `(blockNum desc, txIdx desc)` stream. * Q2/Q3 rows whose hash already appears in Q1 are dropped (Q1 rows are richer), * and Q3 is also deduped against Q2. */ export function mergeAndSort(q1: readonly Row[], q2: readonly Row[], q3: readonly Row[]): Row[] { const q1Hashes = new Set(q1.map((r) => r.txHash)) const filteredQ2 = q2.filter((r) => !q1Hashes.has(r.txHash)) const filteredQ3 = q3.filter((r) => !q1Hashes.has(r.txHash)) // Dedup by row, not transaction: a tx that both credits and debits the // viewer keeps its rows from both sides, one item each. const q2Keys = new Set(filteredQ2.map((r) => `${r.txHash}:${r.logIdx}`)) const dedupedQ3 = filteredQ3.filter((r) => !q2Keys.has(`${r.txHash}:${r.logIdx}`)) return [...q1, ...filteredQ2, ...dedupedQ3].sort((a, b) => { if (a.blockNum !== b.blockNum) return b.blockNum - a.blockNum return b.txIdx - a.txIdx }) } /** Groups rows by transaction hash, preserving sort order. */ function groupByTx(rows: Row[]): TxGroup[] { const map = new Map() const order: string[] = [] for (const row of rows) { const key = row.txHash let group = map.get(key) if (!group) { group = { blockNum: row.blockNum, blockTimestamp: row.blockTimestamp, feePayer: row.feePayer, rows: [], txHash: row.txHash, txIdx: row.txIdx, txSender: row.txSender, } map.set(key, group) order.push(key) } // Inherit tx sender / fee payer from richer Q1 rows when present. if (row.txSender && !group.txSender) group.txSender = row.txSender if (row.feePayer && !group.feePayer) group.feePayer = row.feePayer group.rows.push(row) } return order.map((key) => map.get(key)!) } /** * Classifies a transaction group into one or more activity items. Q1 groups go * through full log decoding; Q2-only groups (transfers without a joined receipt) * are classified directly from the transfer columns. */ function classifyGroup( group: TxGroup, viewer: Address.Address, fingerprintMap: Record, includeLogs = false, ): RawItem[] { const { blockNum, blockTimestamp, feePayer, txHash, txSender } = group // `blockTimestamp` is epoch seconds; the public feed uses ISO 8601 like every // other resource's `timestamp`. const timestamp = new Date(Number(blockTimestamp) * 1000).toISOString() // `outgoing` when the viewer initiated the transaction (sent it or paid the // fee); `incoming` when it merely happened to them (someone else initiated it). const perspective: 'incoming' | 'outgoing' = (txSender && Address.isEqual(txSender, viewer)) || (feePayer && Address.isEqual(feePayer, viewer)) ? 'outgoing' : 'incoming' const itemBase = (logIndex: number) => baseFor({ logIndex, perspective, timestamp, transactionHash: txHash }) const signer: 'self' | Address.Address = txSender && Address.isEqual(txSender, viewer) ? 'self' : (txSender ?? 'self') const q1Rows = group.rows.filter((r) => r.source === 'q1') const q2Rows = group.rows.filter((r) => r.source === 'q2') if (q1Rows.length > 0) return classifyFromLogs(q1Rows, { blockNum, fingerprintMap, includeLogs, itemBase, signer, viewer, }) // Q2-only: transfer rows without a joined receipt, one item per row so a // batch paying the viewer several times surfaces every payment. Classify by // comparing `from`/`to` against the viewer (zero-address sides mint/burn). if (q2Rows.length > 0) return [...q2Rows] .sort((a, b) => a.logIdx - b.logIdx) .map((r) => { const base = itemBase(r.logIdx) const from = r.transferFrom! const to = r.transferTo! const sourceToken = token(r.transferAmount, r.transferToken!) if (Address.isEqual(from, zeroAddress as Address.Address)) return { ...base, data: { recipient: viewer, sourceToken }, type: 'mint' } if (Address.isEqual(to, zeroAddress as Address.Address)) return { ...base, data: { sender: from, signer, sourceToken }, type: 'burn' } // Q2-only rows carry no memo, so MPP service attribution is unavailable // here; `attribution` is resolved only on the log-bearing (Q1) path. if (Address.isEqual(from, viewer)) return transfer(base, blockNum, { direction: 'out', recipient: to, sender: viewer, signer, sourceToken, }) return transfer(base, blockNum, { direction: 'in', recipient: viewer, sender: from, sourceToken, }) }) // Fallback — should not happen (a group with neither q1 nor q2 rows). return [ unknown(itemBase(Math.min(...group.rows.map((r) => r.logIdx))), { events: buildTxEvents(group.rows, []), signer, }), ] } /** Classifies a Q1 (log-bearing) group via `parseEventLogs`. */ function classifyFromLogs( rows: Row[], ctx: { blockNum: number fingerprintMap: Record includeLogs: boolean itemBase: (logIndex: number) => Base signer: 'self' | Address.Address viewer: Address.Address }, ): RawItem[] { const decoded = decodeRows(rows) const items = (() => { try { return classifyFromLogsCore(rows, ctx, decoded) } catch { // Indexed logs can match a known signature without satisfying that event's expected shape. return [ unknown(ctx.itemBase(Math.min(...rows.map((row) => row.logIdx))), { events: buildTxEvents(rows, decoded), signer: signerForRows(rows, { fallback: ctx.signer, viewer: ctx.viewer }), }), ] } })() // When the caller requested raw logs, attach the full per-transaction log set // (decoded when possible) to every classified item so no co-located log is // hidden. `unknown` items already carry it. if (!ctx.includeLogs) return items const events = buildTxEvents(rows, decoded) return items.map((item) => (item.type === 'unknown' ? item : { ...item, events })) } /** Resolves an access-key signer from raw rows, falling back to the transaction signer. */ function signerForRows( rows: readonly Row[], options: { fallback: 'self' | Address.Address; viewer: Address.Address }, ) { const accessKeySpendRow = rows.find( (row) => row.selector === accessKeySpendSelector && Address.isEqual(row.logAddress, Addresses.accountKeychain), ) const accessKeySigner = accessKeySpendRow?.topic2 ? (`0x${accessKeySpendRow.topic2.slice(-40)}`.toLowerCase() as Address.Address) : null return accessKeySigner && !Address.isEqual(accessKeySigner, options.viewer) ? accessKeySigner : options.fallback } /** * Classifies every activity in a Q1 (log-bearing) transaction. Recognizers run * in priority order over a pool of decoded events, each emitting one item per * matched anchor and claiming the exact logs backing it. */ function classifyFromLogsCore( rows: Row[], ctx: { blockNum: number fingerprintMap: Record itemBase: (logIndex: number) => Base signer: 'self' | Address.Address viewer: Address.Address }, events = decodeRows(rows), ): RawItem[] { const { blockNum, fingerprintMap, itemBase, viewer } = ctx const signer = signerForRows(rows, { fallback: ctx.signer, viewer }) const minLogIndex = Math.min(...rows.map((r) => r.logIdx)) // Pair folds are counted: each TransferWithMemo folds one matching plain // Transfer and each Mint/Burn event folds one zero-address transfer, so an // extra identical payment survives as its own item. Keys require the full // event shape; a malformed event must not fold away a classifiable leg. const foldCounts = new Map() const addFold = (key: string) => foldCounts.set(key, (foldCounts.get(key) ?? 0) + 1) for (const event of events) { if (event.eventName === 'TransferWithMemo' && 'from' in event.args && 'to' in event.args) { const args = event.args as { amount: bigint; from: Address.Address; to: Address.Address } addFold(`${event.address}:${args.from}:${args.to}:${args.amount}`) } if (event.eventName === 'Mint' && 'amount' in event.args && 'to' in event.args) addFold(`mint:${event.address}:${(event.args as { amount: bigint }).amount}`) if (event.eventName === 'Burn' && 'amount' in event.args && 'from' in event.args) addFold(`burn:${event.address}:${(event.args as { amount: bigint }).amount}`) } const takeFold = (key: string) => { const count = foldCounts.get(key) ?? 0 if (count === 0) return false foldCounts.set(key, count - 1) return true } const finalEvents = events.filter((event) => { if (event.eventName !== 'Transfer' && event.eventName !== 'TransferWithMemo') return true if (!('from' in event.args) || !('to' in event.args)) return true const args = event.args as { amount: bigint; from: Address.Address; to: Address.Address } if ( event.eventName === 'Transfer' && takeFold(`${event.address}:${args.from}:${args.to}:${args.amount}`) ) return false if ( Address.isEqual(args.from, zeroAddress as Address.Address) && takeFold(`mint:${event.address}:${args.amount}`) ) return false if ( Address.isEqual(args.to, zeroAddress as Address.Address) && takeFold(`burn:${event.address}:${args.amount}`) ) return false return true }) type Decoded = (typeof finalEvents)[number] const pool = [...finalEvents].sort((a, b) => a.logIndex - b.logIndex) const claimed = new Set() const items: RawItem[] = [] // Claims made while building an item are journaled, so a build that throws // on a malformed event releases its legs and consumes only the anchor. let journal: number[] | undefined const claim = (event: Decoded) => { claimed.add(event.logIndex) journal?.push(event.logIndex) } // A predicate throwing on a malformed decode must not discard the whole // transaction; treat that event as a non-match instead. const unclaimed = (predicate?: (event: Decoded) => boolean) => pool.filter((event) => { if (claimed.has(event.logIndex)) return false if (!predicate) return true try { return predicate(event) } catch { return false } }) const lc = (value: Address.Address) => value.toLowerCase() as Address.Address const hx = (value: Hex.Hex) => value.toLowerCase() as Hex.Hex // Non-strict decoding can surface Transfer-shaped events with malformed // args; validating (once per event) keeps predicate comparisons throw-free. const transferIndexes = new Set( pool .filter((event) => { if (event.eventName !== 'Transfer' && event.eventName !== 'TransferWithMemo') return false const args = event.args as { amount?: unknown; from?: unknown; to?: unknown } return ( typeof args.amount === 'bigint' && typeof args.from === 'string' && Address.validate(args.from) && typeof args.to === 'string' && Address.validate(args.to) ) }) .map((event) => event.logIndex), ) const isTransfer = (event: Decoded) => transferIndexes.has(event.logIndex) const transferArgs = (event: Decoded) => event.args as { amount: bigint from: Address.Address memo?: Hex.Hex | undefined to: Address.Address } // Finds and claims the first unclaimed event matching `predicate`, so a // later identical anchor pairs with the next matching leg instead of // reusing this one. const takeEvent = (predicate: (event: Decoded) => boolean) => { for (const event of pool) { if (claimed.has(event.logIndex)) continue const match = (() => { try { return predicate(event) } catch { return false } })() if (!match) continue claim(event) return event } return undefined } const takeTransfer = (predicate: (event: Decoded) => boolean) => takeEvent((event) => isTransfer(event) && predicate(event)) // Runs `build` once per unclaimed matching event, in log order; `build` // claims its legs and returns the item(s) to emit, or nothing for // fold-only recognitions. A throwing build consumes only its anchor. const recognize = ( predicate: (event: Decoded) => boolean, build: (event: Decoded) => RawItem | readonly RawItem[] | undefined, ) => { for (const anchor of unclaimed(predicate)) { // A build may claim a later anchor of the same pass (or a log decoded // into duplicate ABI copies); never process a claimed anchor again. if (claimed.has(anchor.logIndex)) continue claim(anchor) journal = [] try { const built = build(anchor) if (built) items.push(...(Array.isArray(built) ? built : [built as RawItem])) } catch { // Indexed logs can match a known signature without satisfying that // event's expected shape; release the legs and skip the anchor. for (const index of journal ?? []) claimed.delete(index) } finally { journal = undefined } } } const named = (name: string) => (event: Decoded) => event.eventName === name // Claims and returns a vault operation's token legs, matched by role and // amount: asset legs in or out of the vault, share mint/burn legs on it. const claimVaultLegs = (vault: Address.Address, amounts: readonly bigint[]) => { const legs: Decoded[] = [] for (const event of unclaimed()) { const isVaultMintBurn = (event.eventName === 'Mint' || event.eventName === 'Burn') && Address.isEqual(event.address, vault) const isVaultLeg = (() => { if (!isTransfer(event)) return false const args = transferArgs(event) if (Address.isEqual(args.from, vault) || Address.isEqual(args.to, vault)) return true return ( Address.isEqual(event.address, vault) && (Address.isEqual(args.from, zeroAddress as Address.Address) || Address.isEqual(args.to, zeroAddress as Address.Address)) ) })() if (!isVaultMintBurn && !isVaultLeg) continue const amount = (event.args as { amount?: bigint }).amount if (amount === undefined || !amounts.includes(amount)) continue claim(event) legs.push(event) } // The allowance granted for this operation (approve+deposit multicall) is // internal: exact amount, spender is the vault, on a token a leg moved. const legTokens = new Set(legs.map((leg) => leg.address.toLowerCase())) for (const event of unclaimed()) { if (event.eventName !== 'Approval' || !('spender' in event.args)) continue if (!legTokens.has(event.address.toLowerCase())) continue if (!Address.isEqual((event.args as { spender: Address.Address }).spender, vault)) continue const amount = (event.args as { amount?: bigint }).amount if (amount === undefined || !amounts.includes(amount)) continue claim(event) legs.push(event) } return legs } // Private Earn router operations. Each wraps a public vault operation on the // vault it names; fold that vault's own event and token legs, leaving vault // activity elsewhere in the batch unclaimed. recognize(named('EarnDeposit'), (event) => { const args = event.args as { actionId: Hex.Hex earnShares: bigint earnVault: Address.Address inputAmount: bigint inputToken: Address.Address vaultAssets: bigint zoneDepositHash: Hex.Hex } const vault = lc(args.earnVault) // Parse before claiming so a malformed event consumes nothing but itself. const data = { actionId: args.actionId, assets: args.vaultAssets.toString(), inputAmount: args.inputAmount.toString(), inputToken: lc(args.inputToken), shares: args.earnShares.toString(), signer, status: 'completed' as const, vault, zoneDepositHash: args.zoneDepositHash, } // Fold the vault event this router call produced: the nearest preceding // amount match, so a separate same-vault deposit keeps its own. const nested = unclaimed( (e) => e.eventName === 'Deposited' && e.logIndex < event.logIndex && Address.isEqual(e.address, vault) && (e.args as { assets?: bigint }).assets === args.vaultAssets, ).at(-1) if (nested) claim(nested) else takeEvent((e) => e.eventName === 'Deposited' && Address.isEqual(e.address, vault)) claimVaultLegs(vault, [args.inputAmount, args.vaultAssets, args.earnShares]) // The funding leg pays the router (the emitting contract), not the vault. claimVaultLegs(event.address as Address.Address, [args.inputAmount]) return { ...itemBase(event.logIndex), data, type: 'private-assets-deposited' } }) recognize(named('EarnRedeem'), (event) => { const args = event.args as { actionId: Hex.Hex earnShares: bigint earnVault: Address.Address outputAmount: bigint outputToken: Address.Address vaultAssets: bigint zoneDepositHash: Hex.Hex } const vault = lc(args.earnVault) // Parse before claiming so a malformed event consumes nothing but itself. const data = { actionId: args.actionId, assets: args.vaultAssets.toString(), outputAmount: args.outputAmount.toString(), outputToken: lc(args.outputToken), shares: args.earnShares.toString(), signer, status: 'completed' as const, vault, zoneDepositHash: args.zoneDepositHash, } // Fold the vault event this router call produced: the nearest preceding // amount match, so a separate same-vault redemption keeps its own. const nested = unclaimed( (e) => (e.eventName === 'Redeemed' || e.eventName === 'WithdrewExact') && e.logIndex < event.logIndex && Address.isEqual(e.address, vault) && (e.args as { assets?: bigint }).assets === args.vaultAssets, ).at(-1) if (nested) claim(nested) else takeEvent( (e) => (e.eventName === 'Redeemed' || e.eventName === 'WithdrewExact') && Address.isEqual(e.address, vault), ) claimVaultLegs(vault, [args.outputAmount, args.vaultAssets, args.earnShares]) // The payout leg leaves through the router (the emitting contract). claimVaultLegs(event.address as Address.Address, [args.outputAmount]) return { ...itemBase(event.logIndex), data, type: 'private-shares-redeemed' } }) // Earn vault operations, each claiming its own token legs. recognize(named('Deposited'), (event) => { const args = event.args as { assets: bigint caller: Address.Address earnShares: bigint receiver: Address.Address } claimVaultLegs(event.address as Address.Address, [args.assets, args.earnShares]) return { ...itemBase(event.logIndex), data: { assets: args.assets.toString(), caller: lc(args.caller), receiver: lc(args.receiver), shares: args.earnShares.toString(), signer, status: 'completed', vault: lc(event.address), }, type: 'assets-deposited', } }) recognize(named('VenueSharesDeposited'), (event) => { const args = event.args as { caller: Address.Address earnShares: bigint receivedEngineShares: bigint receiver: Address.Address requestedVenueShares: bigint } claimVaultLegs(event.address as Address.Address, [ args.earnShares, args.receivedEngineShares, args.requestedVenueShares, ]) return { ...itemBase(event.logIndex), data: { caller: lc(args.caller), receivedEngineShares: args.receivedEngineShares.toString(), receiver: lc(args.receiver), requestedVenueShares: args.requestedVenueShares.toString(), shares: args.earnShares.toString(), signer, status: 'completed', vault: lc(event.address), }, type: 'shares-deposited', } }) recognize(named('Redeemed'), (event) => { const args = event.args as { assets: bigint caller: Address.Address earnShares: bigint receiver: Address.Address } claimVaultLegs(event.address as Address.Address, [args.assets, args.earnShares]) return { ...itemBase(event.logIndex), data: { assets: args.assets.toString(), caller: lc(args.caller), receiver: lc(args.receiver), shares: args.earnShares.toString(), signer, status: 'completed', vault: lc(event.address), }, type: 'shares-redeemed', } }) recognize(named('WithdrewExact'), (event) => { const args = event.args as { assets: bigint caller: Address.Address earnSharesBurned: bigint receiver: Address.Address } claimVaultLegs(event.address as Address.Address, [args.assets, args.earnSharesBurned]) return { ...itemBase(event.logIndex), data: { assets: args.assets.toString(), caller: lc(args.caller), receiver: lc(args.receiver), sharesBurned: args.earnSharesBurned.toString(), signer, status: 'completed', vault: lc(event.address), }, type: 'assets-withdrawn', } }) recognize(named('RedeemRequested'), (event) => { const args = event.args as { earnShares: bigint receiver: Address.Address requestId: Hex.Hex requester: Address.Address } claimVaultLegs(event.address as Address.Address, [args.earnShares]) return { ...itemBase(event.logIndex), data: { receiver: lc(args.receiver), requestId: args.requestId, requester: lc(args.requester), shares: args.earnShares.toString(), signer, status: 'pending', vault: lc(event.address), }, type: 'shares-redemption-requested', } }) recognize(named('RedeemFinalized'), (event) => { const args = event.args as { asset: Address.Address assets: bigint earnShares: bigint receiver: Address.Address requestId: Hex.Hex } claimVaultLegs(event.address as Address.Address, [args.assets, args.earnShares]) return { ...itemBase(event.logIndex), data: { asset: lc(args.asset), assets: args.assets.toString(), receiver: lc(args.receiver), requestId: args.requestId, shares: args.earnShares.toString(), signer, status: 'completed', vault: lc(event.address), }, type: 'shares-redemption-finalized', } }) recognize(named('RedeemCancelled'), (event) => { const args = event.args as { earnShares: bigint receiver: Address.Address requestId: Hex.Hex } claimVaultLegs(event.address as Address.Address, [args.earnShares]) return { ...itemBase(event.logIndex), data: { receiver: lc(args.receiver), requestId: args.requestId, shares: args.earnShares.toString(), signer, status: 'cancelled', vault: lc(event.address), }, type: 'shares-redemption-cancelled', } }) // External ERC-4626 vaults. Claim the share and asset legs unconditionally // so nested vault plumbing never resurfaces as burns or transfers, but emit // only operations the viewer is a party to. Fold-only recognitions are // buffered and surface only when nothing else classified. const suppressedVaultItems: RawItem[] = [] const viewerInvolved = (parties: readonly Address.Address[], legs: readonly Decoded[]) => parties.some((party) => Address.isEqual(party, viewer)) || legs.some( (leg) => isTransfer(leg) && (Address.isEqual(transferArgs(leg).from, viewer) || Address.isEqual(transferArgs(leg).to, viewer)), ) recognize(named('Withdraw'), (event) => { const args = event.args as { assets: bigint owner: Address.Address receiver: Address.Address sender: Address.Address shares: bigint } const vault = event.address as Address.Address // Parse before claiming so a malformed event consumes nothing but itself. const data = { assets: args.assets.toString(), caller: lc(args.sender), receiver: lc(args.receiver), sharesBurned: args.shares.toString(), signer, status: 'completed' as const, vault: lc(vault), } const legs = claimVaultLegs(vault, [args.assets, args.shares]) const item = { ...itemBase(event.logIndex), data, type: 'assets-withdrawn' as const } if (viewerInvolved([args.sender, args.receiver, args.owner], legs)) return item suppressedVaultItems.push(item) return undefined }) recognize(named('Deposit'), (event) => { const args = event.args as { assets: bigint owner: Address.Address sender: Address.Address shares: bigint } const vault = event.address as Address.Address // Parse before claiming so a malformed event consumes nothing but itself. const data = { assets: args.assets.toString(), caller: lc(args.sender), receiver: lc(args.owner), shares: args.shares.toString(), signer, status: 'completed' as const, vault: lc(vault), } const legs = claimVaultLegs(vault, [args.assets, args.shares]) const item = { ...itemBase(event.logIndex), data, type: 'assets-deposited' as const } if (viewerInvolved([args.sender, args.owner], legs)) return item suppressedVaultItems.push(item) return undefined }) // DEX order placed: `OrderPlaced` carries the escrow token/amount and side; // claim the escrow transfer that funded it. const orderPlacedItem = (event: Decoded): RawItem => { const args = event.args as { amount: bigint isBid: boolean orderId: bigint tick: number token: Address.Address } const escrow = (leg: Decoded) => isTransfer(leg) && Address.isEqual(leg.address, args.token) && Address.isEqual(transferArgs(leg).to, event.address as Address.Address) // The escrow funds the placement, so it is the nearest preceding match; a // same-token earlier swap input must not be taken instead. const escrowLeg = unclaimed( (leg) => escrow(leg) && leg.logIndex < event.logIndex && transferArgs(leg).amount === args.amount, ).at(-1) ?? unclaimed((leg) => escrow(leg) && leg.logIndex < event.logIndex).at(-1) if (escrowLeg) claim(escrowLeg) return { ...itemBase(event.logIndex), data: { orderId: args.orderId.toString(), side: args.isBid ? 'bid' : 'ask', signer, sourceToken: token(args.amount, args.token), tick: Number(args.tick), }, type: 'order-placed', } } const isFlipOrder = (event: Decoded) => 'isFlipOrder' in event.args && (event.args as { isFlipOrder: boolean }).isFlipOrder === true // Non-flip order escrow legs claim before swap pairing so they cannot // mispair with an unrelated swap's output leg. recognize((event) => event.eventName === 'OrderPlaced' && !isFlipOrder(event), orderPlacedItem) // Swap: a transfer into a route paired with a different token coming back // out. The DEX matches any swapper; other routes must be viewer-bounded // with route-emitted logs between the legs. { // Outgoing transfers indexed by lowercase sender, for O(candidates) // destination lookups. Claimed entries are skipped at scan time. const transfersByFrom = new Map() for (const event of pool) { if (!isTransfer(event)) continue const key = transferArgs(event).from.toLowerCase() const list = transfersByFrom.get(key) if (list) list.push(event) else transfersByFrom.set(key, [event]) } // One forward pass: claims only shrink the pool, so a source that finds // no destination now never finds one later, and scanning each source once // keeps the cost linear in sources instead of restarting per pair. for (const source of unclaimed(isTransfer)) { if (claimed.has(source.logIndex)) continue const sourceArgs = transferArgs(source) const route = sourceArgs.to const routeKey = route.toLowerCase() // Tempo system addresses (the reserve, fee manager, outbox, and every // other non-DEX precompile) are never swap routes; new precompiles join // the exclusion automatically. if (nonRouteSystemAddresses.has(routeKey)) continue if (Address.isEqual(route, zeroAddress as Address.Address)) continue if (Address.isEqual(route, viewer)) continue const isDex = Address.isEqual(route, Addresses.stablecoinDex) if (!isDex && !Address.isEqual(sourceArgs.from, viewer)) continue const candidates = (transfersByFrom.get(routeKey) ?? []).filter((event) => { if (claimed.has(event.logIndex) || event.logIndex <= source.logIndex) return false if (Address.isEqual(event.address, source.address)) return false return !Address.isEqual(transferArgs(event).to, Addresses.feeManager) }) // Prefer the output paying the swapper back (so interleaved swaps by // different accounts keep their own outputs); otherwise a DEX pairs // first-in-first-out, and a route's last leg is a direct delivery // (earlier different-token legs are intermediate hops). const destination = candidates.find((event) => Address.isEqual(transferArgs(event).to, sourceArgs.from)) ?? (isDex ? candidates[0] : candidates.at(-1)) if (!destination) continue // Route evidence for non-precompile routes: the route itself emitted a // log between the legs (raw rows, so undecoded router events count). A // counterparty that merely received and later paid back is not a route. if (!isDex) { const evidence = rows.some( (r) => r.logIdx > source.logIndex && r.logIdx < destination.logIndex && r.logAddress.toLowerCase() === routeKey, ) if (!evidence) continue } journal = [] try { claim(source) claim(destination) const destinationArgs = transferArgs(destination) const swapper = isDex ? destinationArgs.to : sourceArgs.from const destTokenAddress = destination.address as Address.Address const sourceToken = token(sourceArgs.amount, source.address as Address.Address) // Router plumbing between the legs: route-touching or zero-address // token moves and router-side approvals, never viewer-party transfers. // DEX-precompile pairs claim nothing here, so an interleaved swap by // another account keeps its own legs. if (!isDex) for (const event of unclaimed()) { if (event.logIndex <= source.logIndex || event.logIndex >= destination.logIndex) continue if (isTransfer(event)) { const args = transferArgs(event) if (Address.isEqual(args.from, viewer) || Address.isEqual(args.to, viewer)) continue if ( Address.isEqual(args.from, route) || Address.isEqual(args.to, route) || Address.isEqual(args.from, zeroAddress as Address.Address) || Address.isEqual(args.to, zeroAddress as Address.Address) ) claim(event) } else if ( event.eventName === 'Approval' && 'owner' in event.args && !Address.isEqual((event.args as { owner: Address.Address }).owner, viewer) ) claim(event) } // The allowance granted for this swap: same token, owner, and amount, // and causally tied to the input leg (spender is the route, or the // approval immediately precedes it). Unrelated approvals stay. unclaimed( (event) => event.eventName === 'Approval' && 'amount' in event.args && 'owner' in event.args && 'spender' in event.args && Address.isEqual(event.address, source.address) && Address.isEqual((event.args as { owner: Address.Address }).owner, sourceArgs.from) && (event.args as { amount: bigint }).amount === sourceArgs.amount && event.logIndex < source.logIndex && (Address.isEqual((event.args as { spender: Address.Address }).spender, route) || event.logIndex === source.logIndex - 1), ) .slice(-1) .forEach(claim) // DEX bookkeeping this swap produced: fills against resting orders and // the auto-flip re-placement, acted by the swapper or its route account. // A fill of the viewer's own resting order stays its own item. const isBookkeepingActor = (value: Address.Address) => Address.isEqual(value, swapper) || Address.isEqual(value, route) unclaimed( (event) => (Address.isEqual(event.address, route) || Address.isEqual(event.address, Addresses.stablecoinDex)) && ((event.eventName === 'OrderFilled' && 'taker' in event.args && isBookkeepingActor((event.args as { taker: Address.Address }).taker) && !( 'maker' in event.args && Address.isEqual((event.args as { maker: Address.Address }).maker, viewer) )) || (event.eventName === 'OrderPlaced' && isFlipOrder(event) && 'maker' in event.args && isBookkeepingActor((event.args as { maker: Address.Address }).maker))), ).forEach(claim) // Cross-token transfer: the swapped-out token is forwarded whole from // the swapper to a new recipient after the swap (the account server's // approve+swap+transfer multicall), or the route delivered it to the // recipient directly; fold both shapes into one `transfer`. A leg // whose recipient later forwards a different token is the input of a // chained swap, not a delivery. const forwardsOn = (candidate: Decoded) => { const args = transferArgs(candidate) return (transfersByFrom.get(args.to.toLowerCase()) ?? []).some( (onward) => !claimed.has(onward.logIndex) && onward.logIndex > candidate.logIndex && !Address.isEqual(onward.address, candidate.address), ) } const delivery = !isDex && !Address.isEqual(destinationArgs.to, viewer) ? destination : takeTransfer((event) => { const args = transferArgs(event) return ( event.logIndex > destination.logIndex && Address.isEqual(event.address, destTokenAddress) && Address.isEqual(args.from, swapper) && args.amount === destinationArgs.amount && !Address.isEqual(args.to, route) && !Address.isEqual(args.to, swapper) && !forwardsOn(event) ) }) // A malformed delivery memo degrades to a plain swap item; the claimed // legs must still produce an activity. const item = (() => { if (!delivery) return undefined try { const args = transferArgs(delivery) const rawMemo = delivery.eventName === 'TransferWithMemo' ? args.memo : undefined const memo = rawMemo ? Transfers.memoToString(rawMemo) : undefined const attribution = Mpp.resolve({ fingerprintMap, memo: rawMemo }) return transfer(itemBase(delivery.logIndex), blockNum, { attribution, destinationToken: token(args.amount, delivery.address as Address.Address), direction: Address.isEqual(args.to, viewer) ? ('in' as const) : ('out' as const), memo, recipient: lc(args.to), sender: lc(swapper), signer, sourceToken, }) } catch { return undefined } })() items.push( item ?? { ...itemBase(source.logIndex), data: { destinationToken: token(destinationArgs.amount, destTokenAddress), signer, sourceToken, }, type: 'swap', }, ) } catch { // A malformed leg costs only this pair; release its claims so the // legs fall through to the later recognizers. for (const index of journal ?? []) claimed.delete(index) } finally { journal = undefined } } } // Mint: explicit `Mint` events (not the fee manager) and remaining transfers // from the zero address (the dedup above already folded event/transfer pairs). const mintItem = (event: Decoded, args: { amount: bigint; to: Address.Address }): RawItem => ({ ...itemBase(event.logIndex), data: { recipient: lc(args.to), sourceToken: token(args.amount, event.address as Address.Address), }, type: 'mint', }) recognize( (event) => event.eventName === 'Mint' && 'amount' in event.args && 'to' in event.args && !Address.isEqual(event.address, Addresses.feeManager), (event) => mintItem(event, event.args as { amount: bigint; to: Address.Address }), ) recognize( (event) => isTransfer(event) && Address.isEqual(transferArgs(event).from, zeroAddress as Address.Address), (event) => mintItem(event, transferArgs(event)), ) // Burn blocked: a compliance burn of receive-policy-blocked funds. It also // emits a zero-address `Transfer`; claim that paired leg so it cannot // classify as a plain `burn`. recognize(named('BurnBlocked'), (event) => { const args = event.args as { amount: bigint; from: Address.Address } takeTransfer( (leg) => Address.isEqual(leg.address, event.address) && Address.isEqual(transferArgs(leg).to, zeroAddress as Address.Address) && transferArgs(leg).amount === args.amount, ) return { ...itemBase(event.logIndex), data: { sender: lc(args.from), signer, sourceToken: token(args.amount, event.address as Address.Address), }, type: 'burn-blocked', } }) // Burn: explicit `Burn` events and remaining transfers to the zero address. const burnItem = (event: Decoded, args: { amount: bigint; from: Address.Address }): RawItem => ({ ...itemBase(event.logIndex), data: { sender: lc(args.from), signer, sourceToken: token(args.amount, event.address as Address.Address), }, type: 'burn', }) recognize( (event) => event.eventName === 'Burn' && 'amount' in event.args && 'from' in event.args, (event) => burnItem(event, event.args as { amount: bigint; from: Address.Address }), ) recognize( (event) => isTransfer(event) && Address.isEqual(transferArgs(event).to, zeroAddress as Address.Address), (event) => burnItem(event, transferArgs(event)), ) // Approval (excluding zone-outbox spenders): only viewer-party approvals // surface here; the rest stay for the nothing-else-classified fallback. const isErc20Approval = (event: Decoded) => { if (event.eventName !== 'Approval') return false const args = event.args as { spender?: unknown } if (typeof args.spender !== 'string' || !Address.validate(args.spender)) return false // ERC-721 uses the same selector but indexes its token ID as topic3. In // non-strict mode viem otherwise decodes the missing ERC-20 amount as zero. return ( rows.some( (row) => row.logIdx === event.logIndex && row.topic3 === null && /^0x[0-9a-fA-F]{64}$/.test(row.data ?? ''), ) && !Address.isEqual(args.spender as Address.Address, Addresses.zoneOutbox) ) } const approvalItem = (event: Decoded): RawItem => { const args = event.args as { amount: bigint; spender: Address.Address } return { ...itemBase(event.logIndex), data: { signer, sourceToken: token(args.amount, event.address as Address.Address), spender: lc(args.spender), }, type: 'approval', } } recognize( (event) => isErc20Approval(event) && 'owner' in event.args && (Address.isEqual((event.args as { owner: Address.Address }).owner, viewer) || Address.isEqual((event.args as { spender: Address.Address }).spender, viewer)), approvalItem, ) // Payment channels. The reserve doesn't put the token on `TopUp`/`Settled`/ // `ChannelClosed`, so each anchor claims its own reserve leg to recover it; // batched channel operations pair with their own legs one at a time. recognize(named('ChannelOpened'), (event) => { const args = event.args as { channelId: Hex.Hex deposit: bigint payee: Address.Address payer: Address.Address token: Address.Address } takeTransfer( (leg) => Address.isEqual(leg.address, args.token) && Address.isEqual(transferArgs(leg).to, tip20ChannelReserve) && transferArgs(leg).amount === args.deposit, ) return { ...itemBase(event.logIndex), data: { channelId: hx(args.channelId), payee: lc(args.payee), payer: lc(args.payer), signer, sourceToken: token(args.deposit, args.token), }, type: 'channel-opened', } }) recognize(named('TopUp'), (event) => { const args = event.args as { additionalDeposit: bigint; channelId: Hex.Hex } const inflow = takeTransfer( (leg) => Address.isEqual(transferArgs(leg).to, tip20ChannelReserve) && transferArgs(leg).amount === args.additionalDeposit, ) ?? takeTransfer((leg) => Address.isEqual(transferArgs(leg).to, tip20ChannelReserve)) // A top-up cancels any pending close request on the channel; fold the // cancellation event it bundles. unclaimed( (e) => e.eventName === 'CloseRequestCancelled' && 'channelId' in e.args && (e.args as { channelId: Hex.Hex }).channelId === args.channelId, ) .slice(0, 1) .forEach(claim) const tokenAddress = inflow ? (inflow.address as Address.Address) : (zeroAddress as Address.Address) return { ...itemBase(event.logIndex), data: { channelId: hx(args.channelId), signer, sourceToken: token(args.additionalDeposit, tokenAddress), }, type: 'channel-funded', } }) recognize(named('Settled'), (event) => { const args = event.args as { channelId: Hex.Hex deltaPaid: bigint payee: Address.Address payer: Address.Address } const payout = (leg: Decoded) => Address.isEqual(transferArgs(leg).from, tip20ChannelReserve) && Address.isEqual(transferArgs(leg).to, args.payee) // Prefer the amount-matched payout so batched channel operations sharing // a payee keep their own legs. const payeeLeg = takeTransfer((leg) => payout(leg) && transferArgs(leg).amount === args.deltaPaid) ?? takeTransfer(payout) const tokenAddress = payeeLeg ? (payeeLeg.address as Address.Address) : (zeroAddress as Address.Address) return { ...itemBase(event.logIndex), data: { channelId: hx(args.channelId), payee: lc(args.payee), payer: lc(args.payer), signer, sourceToken: token(args.deltaPaid, tokenAddress), }, type: 'channel-settled', } }) recognize(named('ChannelClosed'), (event) => { const args = event.args as { channelId: Hex.Hex payee: Address.Address payer: Address.Address refundedToPayer: bigint settledToPayee: bigint } // A zero settlement or refund has no leg; claiming one would steal it // from a batched sibling operation. const reserveLegTo = (to: Address.Address, amount: bigint) => { if (amount === 0n) return undefined const payout = (leg: Decoded) => Address.isEqual(transferArgs(leg).from, tip20ChannelReserve) && Address.isEqual(transferArgs(leg).to, to) return ( takeTransfer((leg) => payout(leg) && transferArgs(leg).amount === amount) ?? takeTransfer(payout) ) } const payeeLeg = reserveLegTo(args.payee, args.settledToPayee) const payerLeg = reserveLegTo(args.payer, args.refundedToPayer) return { ...itemBase(event.logIndex), data: { channelId: hx(args.channelId), payee: lc(args.payee), payer: lc(args.payer), ...(payerLeg && args.refundedToPayer > 0n ? { refund: token(args.refundedToPayer, payerLeg.address as Address.Address) } : {}), signer, ...(payeeLeg ? { sourceToken: token(args.settledToPayee, payeeLeg.address as Address.Address) } : {}), }, type: 'channel-closed', } }) // Payment channel close cancelled: carries no transfer, so the event itself // is the signal. Cancellations bundled into a top-up were claimed above. recognize(named('CloseRequestCancelled'), (event) => { const args = event.args as { channelId: Hex.Hex payee: Address.Address payer: Address.Address } return { ...itemBase(event.logIndex), data: { channelId: hx(args.channelId), payee: lc(args.payee), payer: lc(args.payer), signer, }, type: 'channel-close-cancelled', } }) // Payment-channel close requested: matched on the raw selector (the event // is not in the merged ABI). Runs after the channel recognizers claimed // their own reserve legs, then takes the leftover refund back to the viewer. for (const row of rows.filter((r) => r.selector === closeRequestedSelector)) { const refundLeg = takeTransfer((event) => { const args = transferArgs(event) if (!Address.isEqual(args.to, viewer)) return false return ( Address.isEqual(args.from, tip20ChannelReserve) || Address.isEqual(args.from, row.logAddress) ) }) const refund = refundLeg ? token(transferArgs(refundLeg).amount, refundLeg.address as Address.Address) : undefined items.push({ ...itemBase(row.logIdx), data: { ...(refund ? { refund } : {}), signer }, type: 'session-closed', }) } // Auto-flip order placements not folded into a swap above. recognize(named('OrderPlaced'), orderPlacedItem) // DEX order cancelled: refund is credited to the internal DEX balance, so // there is no co-located transfer; the event itself is the signal. recognize(named('OrderCancelled'), (event) => { const args = event.args as { orderId: bigint } return { ...itemBase(event.logIndex), data: { orderId: args.orderId.toString(), signer }, type: 'order-cancelled', } }) // Fee-AMM rebalance swap: `validatorToken` in, `userToken` out; claim both // fee-manager legs so neither resurfaces as a plain transfer. recognize(named('RebalanceSwap'), (event) => { const args = event.args as { amountIn: bigint amountOut: bigint swapper: Address.Address userToken: Address.Address validatorToken: Address.Address } takeTransfer( (leg) => Address.isEqual(leg.address, args.validatorToken) && Address.isEqual(transferArgs(leg).from, args.swapper) && Address.isEqual(transferArgs(leg).to, Addresses.feeManager) && transferArgs(leg).amount === args.amountIn, ) takeTransfer( (leg) => Address.isEqual(leg.address, args.userToken) && Address.isEqual(transferArgs(leg).from, Addresses.feeManager) && Address.isEqual(transferArgs(leg).to, args.swapper) && transferArgs(leg).amount === args.amountOut, ) return { ...itemBase(event.logIndex), data: { destinationToken: token(args.amountOut, args.userToken), signer, sourceToken: token(args.amountIn, args.validatorToken), swapper: lc(args.swapper), }, type: 'fee-rebalance-swap', } }) // Validator fees distributed; claim the fee-manager payout leg. recognize(named('FeesDistributed'), (event) => { const args = event.args as { amount: bigint token: Address.Address validator: Address.Address } takeTransfer( (leg) => Address.isEqual(leg.address, args.token) && Address.isEqual(transferArgs(leg).from, Addresses.feeManager) && Address.isEqual(transferArgs(leg).to, args.validator) && transferArgs(leg).amount === args.amount, ) return { ...itemBase(event.logIndex), data: { signer, sourceToken: token(args.amount, args.token), validator: lc(args.validator), }, type: 'fees-distributed', } }) // Reward pool funded: the funded token is the emitting contract itself; // claim the funding transfer. recognize(named('RewardDistributed'), (event) => { const args = event.args as { amount: bigint; funder: Address.Address } takeTransfer( (leg) => Address.isEqual(leg.address, event.address as Address.Address) && Address.isEqual(transferArgs(leg).from, args.funder) && Address.isEqual(transferArgs(leg).to, event.address as Address.Address) && transferArgs(leg).amount === args.amount, ) return { ...itemBase(event.logIndex), data: { funder: lc(args.funder), signer, sourceToken: token(args.amount, event.address as Address.Address), }, type: 'reward-distributed', } }) // Reward recipient redirected. recognize(named('RewardRecipientSet'), (event) => { const args = event.args as { holder: Address.Address; recipient: Address.Address } return { ...itemBase(event.logIndex), data: { holder: lc(args.holder), recipient: lc(args.recipient), signer, }, type: 'reward-recipient-set', } }) // Access-key spending limit updated: `newLimit` is carried as the token // amount so it enriches with the token's metadata. recognize(named('SpendingLimitUpdated'), (event) => { const args = event.args as { account: Address.Address newLimit: bigint publicKey: Address.Address token: Address.Address } return { ...itemBase(event.logIndex), data: { account: lc(args.account), publicKey: lc(args.publicKey), signer, sourceToken: token(args.newLimit, args.token), }, type: 'spending-limit-updated', } }) // Access-key lifecycle events. recognize( (event) => event.eventName === 'KeyAuthorized' && Address.isEqual(event.address, Addresses.accountKeychain), (event) => { const args = event.args as { account: Address.Address expiry: bigint publicKey: Address.Address signatureType: number } return { ...itemBase(event.logIndex), data: { account: lc(args.account), expiry: Number(args.expiry), publicKey: lc(args.publicKey), signatureType: Number(args.signatureType), }, type: 'access-key-created', } }, ) recognize( (event) => event.eventName === 'KeyRevoked' && Address.isEqual(event.address, Addresses.accountKeychain), (event) => { const args = event.args as { account: Address.Address; publicKey: Address.Address } return { ...itemBase(event.logIndex), data: { account: lc(args.account), publicKey: lc(args.publicKey), }, type: 'access-key-revoked', } }, ) // Token creation. Factory initialization (role grants, config) precedes the // creation event; claim those, leaving later admin actions on the token as // independent activities. recognize( (event) => event.eventName === 'TokenCreated' && Address.isEqual(event.address, Addresses.tip20Factory), (event) => { const args = event.args as { currency: string name: string symbol: string token: Address.Address } unclaimed( (e) => e.logIndex < event.logIndex && Address.isEqual(e.address, args.token), ).forEach(claim) return { ...itemBase(event.logIndex), data: { address: lc(args.token), currency: args.currency, name: args.name, symbol: args.symbol, }, type: 'token-created', } }, ) // Governance / admin / DEX-bookkeeping events. Each maps a single decoded // event to a typed item; every remaining occurrence emits. These carry no // token movement, so the event itself is the whole signal. recognize(named('OrderFilled'), (event) => { const a = event.args as { amountFilled: bigint maker: Address.Address orderId: bigint partialFill: boolean taker: Address.Address } return { ...itemBase(event.logIndex), data: { amountFilled: a.amountFilled.toString(), maker: lc(a.maker), orderId: a.orderId.toString(), partialFill: a.partialFill, signer, taker: lc(a.taker), }, type: 'order-filled', } }) recognize(named('OrderFlipped'), (event) => { const a = event.args as { amount: bigint flipTick: number isBid: boolean maker: Address.Address orderId: bigint tick: number token: Address.Address } return { ...itemBase(event.logIndex), data: { flipTick: Number(a.flipTick), maker: lc(a.maker), orderId: a.orderId.toString(), side: a.isBid ? 'bid' : 'ask', signer, sourceToken: token(a.amount, a.token), tick: Number(a.tick), }, type: 'order-flipped', } }) recognize(named('PairCreated'), (event) => { const a = event.args as { base: Address.Address; key: Hex.Hex; quote: Address.Address } return { ...itemBase(event.logIndex), data: { base: lc(a.base), key: hx(a.key), quote: lc(a.quote), signer }, type: 'pair-created', } }) // TIP-20 token configuration / admin. recognize(named('PauseStateUpdate'), (event) => { const a = event.args as { isPaused: boolean; updater: Address.Address } return { ...itemBase(event.logIndex), data: { paused: a.isPaused, signer, updater: lc(a.updater) }, type: 'token-pause-set', } }) recognize(named('SupplyCapUpdate'), (event) => { const a = event.args as { newSupplyCap: bigint; updater: Address.Address } return { ...itemBase(event.logIndex), data: { signer, supplyCap: a.newSupplyCap.toString(), updater: lc(a.updater) }, type: 'token-supply-cap-set', } }) recognize(named('TransferPolicyUpdate'), (event) => { const a = event.args as { newPolicyId: bigint; updater: Address.Address } return { ...itemBase(event.logIndex), data: { policyId: a.newPolicyId.toString(), signer, updater: lc(a.updater) }, type: 'token-transfer-policy-set', } }) recognize(named('QuoteTokenUpdate'), (event) => { const a = event.args as { newQuoteToken: Address.Address; updater: Address.Address } return { ...itemBase(event.logIndex), data: { quoteToken: lc(a.newQuoteToken), signer, updater: lc(a.updater) }, type: 'token-quote-token-set', } }) recognize(named('NextQuoteTokenSet'), (event) => { const a = event.args as { nextQuoteToken: Address.Address; updater: Address.Address } return { ...itemBase(event.logIndex), data: { nextQuoteToken: lc(a.nextQuoteToken), signer, updater: lc(a.updater) }, type: 'token-next-quote-token-set', } }) recognize(named('LogoURIUpdated'), (event) => { const a = event.args as { newLogoURI: string; updater: Address.Address } return { ...itemBase(event.logIndex), data: { logoUri: a.newLogoURI, signer, updater: lc(a.updater) }, type: 'token-logo-set', } }) recognize(named('RoleMembershipUpdated'), (event) => { const a = event.args as { account: Address.Address hasRole: boolean role: Hex.Hex sender: Address.Address } return { ...itemBase(event.logIndex), data: { account: lc(a.account), granted: a.hasRole, role: hx(a.role), sender: lc(a.sender), signer, }, type: 'role-membership-set', } }) recognize(named('RoleAdminUpdated'), (event) => { const a = event.args as { newAdminRole: Hex.Hex role: Hex.Hex sender: Address.Address } return { ...itemBase(event.logIndex), data: { newAdminRole: hx(a.newAdminRole), role: hx(a.role), sender: lc(a.sender), signer, }, type: 'role-admin-set', } }) // Fee-manager token configuration. recognize(named('UserTokenSet'), (event) => { const a = event.args as { token: Address.Address; user: Address.Address } return { ...itemBase(event.logIndex), data: { signer, token: lc(a.token), user: lc(a.user) }, type: 'fee-user-token-set', } }) recognize(named('ValidatorTokenSet'), (event) => { const a = event.args as { token: Address.Address; validator: Address.Address } return { ...itemBase(event.logIndex), data: { signer, token: lc(a.token), validator: lc(a.validator) }, type: 'fee-validator-token-set', } }) // TIP-403 policy registry. Creation events claim the member events they // bundle (which precede the anchor); later member updates stay independent. recognize(named('CompoundPolicyCreated'), (event) => { const a = event.args as { creator: Address.Address mintRecipientPolicyId: bigint policyId: bigint recipientPolicyId: bigint senderPolicyId: bigint } const componentIds = new Set( [a.mintRecipientPolicyId, a.recipientPolicyId, a.senderPolicyId].map(String), ) unclaimed( (e) => e.logIndex < event.logIndex && Address.isEqual(e.address, event.address) && ['BlacklistUpdated', 'PolicyAdminUpdated', 'PolicyCreated', 'WhitelistUpdated'].includes( e.eventName, ) && 'policyId' in e.args && componentIds.has(String((e.args as { policyId: bigint }).policyId)), ).forEach(claim) return { ...itemBase(event.logIndex), data: { creator: lc(a.creator), mintRecipientPolicyId: a.mintRecipientPolicyId.toString(), policyId: a.policyId.toString(), recipientPolicyId: a.recipientPolicyId.toString(), senderPolicyId: a.senderPolicyId.toString(), signer, }, type: 'compound-policy-created', } }) recognize(named('PolicyCreated'), (event) => { const a = event.args as { policyId: bigint policyType: number updater: Address.Address } unclaimed( (e) => e.logIndex < event.logIndex && Address.isEqual(e.address, event.address) && ['BlacklistUpdated', 'PolicyAdminUpdated', 'WhitelistUpdated'].includes(e.eventName) && 'policyId' in e.args && (e.args as { policyId: bigint }).policyId === a.policyId, ).forEach(claim) return { ...itemBase(event.logIndex), data: { policyId: a.policyId.toString(), policyType: Number(a.policyType), signer, updater: lc(a.updater), }, type: 'policy-created', } }) recognize(named('PolicyAdminUpdated'), (event) => { const a = event.args as { admin: Address.Address policyId: bigint updater: Address.Address } return { ...itemBase(event.logIndex), data: { admin: lc(a.admin), policyId: a.policyId.toString(), signer, updater: lc(a.updater), }, type: 'policy-admin-set', } }) recognize(named('WhitelistUpdated'), (event) => { const a = event.args as { account: Address.Address allowed: boolean policyId: bigint updater: Address.Address } return { ...itemBase(event.logIndex), data: { account: lc(a.account), allowed: a.allowed, policyId: a.policyId.toString(), signer, updater: lc(a.updater), }, type: 'whitelist-updated', } }) recognize(named('BlacklistUpdated'), (event) => { const a = event.args as { account: Address.Address policyId: bigint restricted: boolean updater: Address.Address } return { ...itemBase(event.logIndex), data: { account: lc(a.account), policyId: a.policyId.toString(), restricted: a.restricted, signer, updater: lc(a.updater), }, type: 'blacklist-updated', } }) // Address / nonce registry. recognize(named('MasterRegistered'), (event) => { const a = event.args as { masterAddress: Address.Address; masterId: Hex.Hex } return { ...itemBase(event.logIndex), data: { masterAddress: lc(a.masterAddress), masterId: hx(a.masterId), signer }, type: 'master-registered', } }) recognize(named('NonceIncremented'), (event) => { const a = event.args as { account: Address.Address newNonce: bigint nonceKey: bigint } return { ...itemBase(event.logIndex), data: { account: lc(a.account), nonce: a.newNonce.toString(), nonceKey: a.nonceKey.toString(), signer, }, type: 'nonce-incremented', } }) // Access-key authorization witnesses. recognize(named('KeyAuthorizationWitness'), (event) => { const a = event.args as { account: Address.Address; witness: Hex.Hex } return { ...itemBase(event.logIndex), data: { account: lc(a.account), signer, witness: hx(a.witness) }, type: 'key-authorization-witness', } }) recognize(named('KeyAuthorizationWitnessBurned'), (event) => { const a = event.args as { account: Address.Address; witness: Hex.Hex } return { ...itemBase(event.logIndex), data: { account: lc(a.account), signer, witness: hx(a.witness) }, type: 'key-authorization-witness-burned', } }) // Validator configuration. Rotation claims the add/deactivate pair it // bundles, matched by validator index. recognize(named('ValidatorRotated'), (event) => { const a = event.args as { caller: Address.Address deactivatedIndex: bigint egress: string index: bigint ingress: string newPublicKey: Hex.Hex oldPublicKey: Hex.Hex validatorAddress: Address.Address } unclaimed((e) => { if (!('index' in e.args)) return false const index = (e.args as { index: bigint }).index return ( (e.eventName === 'ValidatorAdded' && index === a.index) || (e.eventName === 'ValidatorDeactivated' && index === a.deactivatedIndex) ) }).forEach(claim) return { ...itemBase(event.logIndex), data: { caller: lc(a.caller), deactivatedIndex: a.deactivatedIndex.toString(), egress: a.egress, index: a.index.toString(), ingress: a.ingress, newPublicKey: hx(a.newPublicKey), oldPublicKey: hx(a.oldPublicKey), signer, validator: lc(a.validatorAddress), }, type: 'validator-rotated', } }) recognize(named('ValidatorAdded'), (event) => { const a = event.args as { egress: string feeRecipient: Address.Address index: bigint ingress: string publicKey: Hex.Hex validatorAddress: Address.Address } return { ...itemBase(event.logIndex), data: { egress: a.egress, feeRecipient: lc(a.feeRecipient), index: a.index.toString(), ingress: a.ingress, publicKey: hx(a.publicKey), signer, validator: lc(a.validatorAddress), }, type: 'validator-added', } }) recognize(named('ValidatorDeactivated'), (event) => { const a = event.args as { index: bigint; validatorAddress: Address.Address } return { ...itemBase(event.logIndex), data: { index: a.index.toString(), signer, validator: lc(a.validatorAddress) }, type: 'validator-deactivated', } }) recognize(named('ValidatorMigrated'), (event) => { const a = event.args as { index: bigint publicKey: Hex.Hex validatorAddress: Address.Address } return { ...itemBase(event.logIndex), data: { index: a.index.toString(), publicKey: hx(a.publicKey), signer, validator: lc(a.validatorAddress), }, type: 'validator-migrated', } }) recognize(named('SkippedValidatorMigration'), (event) => { const a = event.args as { index: bigint publicKey: Hex.Hex validatorAddress: Address.Address } return { ...itemBase(event.logIndex), data: { index: a.index.toString(), publicKey: hx(a.publicKey), signer, validator: lc(a.validatorAddress), }, type: 'validator-migration-skipped', } }) recognize(named('FeeRecipientUpdated'), (event) => { const a = event.args as { caller: Address.Address feeRecipient: Address.Address index: bigint } return { ...itemBase(event.logIndex), data: { caller: lc(a.caller), feeRecipient: lc(a.feeRecipient), index: a.index.toString(), signer, }, type: 'validator-fee-recipient-set', } }) recognize(named('IpAddressesUpdated'), (event) => { const a = event.args as { caller: Address.Address egress: string index: bigint ingress: string } return { ...itemBase(event.logIndex), data: { caller: lc(a.caller), egress: a.egress, index: a.index.toString(), ingress: a.ingress, signer, }, type: 'validator-ip-set', } }) recognize(named('ValidatorOwnershipTransferred'), (event) => { const a = event.args as { caller: Address.Address index: bigint newAddress: Address.Address oldAddress: Address.Address } return { ...itemBase(event.logIndex), data: { caller: lc(a.caller), index: a.index.toString(), newAddress: lc(a.newAddress), oldAddress: lc(a.oldAddress), signer, }, type: 'validator-ownership-transferred', } }) recognize(named('OwnershipTransferred'), (event) => { const a = event.args as { newOwner: Address.Address oldOwner: Address.Address } return { ...itemBase(event.logIndex), data: { newOwner: lc(a.newOwner), oldOwner: lc(a.oldOwner), signer }, type: 'ownership-transferred', } }) recognize(named('NetworkIdentityRotationEpochSet'), (event) => { const a = event.args as { nextEpoch: bigint; previousEpoch: bigint } return { ...itemBase(event.logIndex), data: { nextEpoch: a.nextEpoch.toString(), previousEpoch: a.previousEpoch.toString(), signer, }, type: 'network-identity-rotation-epoch-set', } }) recognize(named('Initialized'), (event) => { const a = event.args as { height: bigint } return { ...itemBase(event.logIndex), data: { height: a.height.toString(), signer }, type: 'initialized', } }) // Plain transfers: every remaining viewer-party transfer is its own // activity. Non-viewer transfers are internal legs of what classified // above and surface only via the fallback below. const buildTransfer = (event: Decoded): RawItem => { const args = transferArgs(event) const to = lc(args.to) const from = lc(args.from) const rawMemo = event.eventName === 'TransferWithMemo' ? args.memo : undefined // The raw memo (not the suppressed display string) carries the MPP // attribution fingerprint, so resolve the service name from it. const memo = rawMemo ? Transfers.memoToString(rawMemo) : undefined const attribution = Mpp.resolve({ fingerprintMap, memo: rawMemo }) const sourceToken = token(args.amount, event.address as Address.Address) if (Address.isEqual(to, viewer)) return transfer(itemBase(event.logIndex), blockNum, { attribution, direction: 'in', memo, recipient: viewer, sender: from, sourceToken, }) return transfer(itemBase(event.logIndex), blockNum, { attribution, direction: 'out', memo, recipient: to, sender: from, signer, sourceToken, }) } const isUserTransfer = (event: Decoded) => isTransfer(event) && !Address.isEqual(transferArgs(event).to, Addresses.feeManager) recognize( (event) => isUserTransfer(event) && (Address.isEqual(transferArgs(event).from, viewer) || Address.isEqual(transferArgs(event).to, viewer)), buildTransfer, ) // Fallback when nothing viewer-facing classified: surface fold-only vault // items, third-party approvals, and transfers (e.g. a sponsored batch) // instead of reporting `unknown`. if (items.length === 0) { items.push(...suppressedVaultItems) recognize(isErc20Approval, approvalItem) recognize(isUserTransfer, buildTransfer) } // Unclassified: a tx the address sent or paid for whose logs matched no // known classifier. Surface it honestly as an `unknown` item carrying every // log (decoded when possible), rather than fabricating a zero transfer. if (items.length === 0) return [unknown(itemBase(minLogIndex), { events: buildTxEvents(rows, events), signer })] return items.sort((a, b) => a.logIndex - b.logIndex) } /** * Builds a `transfer` item whose `data` is the `Transfer` API resource plus the * feed-only `direction`/`memo`/`attribution`/`signer` fields. Optional fields * are omitted when absent (`exactOptionalPropertyTypes`). */ function transfer( base: Base, blockNumber: number, data: { attribution?: string | undefined destinationToken?: UnenrichedToken | undefined direction: 'in' | 'out' memo?: string | undefined recipient: Address.Address sender: Address.Address signer?: 'self' | Address.Address | undefined sourceToken: UnenrichedToken }, ): Extract { return { ...base, data: { ...(data.attribution ? { attribution: data.attribution } : {}), blockNumber, ...(data.destinationToken ? { destinationToken: data.destinationToken } : {}), direction: data.direction, ...(data.memo ? { memo: data.memo } : {}), recipient: data.recipient, sender: data.sender, ...(data.signer ? { signer: data.signer } : {}), sourceToken: data.sourceToken, timestamp: base.timestamp, transactionHash: base.transactionHash, }, type: 'transfer', } } /** One entry in an item's `events` capture: a log, decoded when possible (derived from the schema). */ type TxEvent = NonNullable[number] /** The structural slice of a decoded `parseEventLogs` event that {@link buildTxEvents} reads. */ type DecodedEvent = { args: unknown; eventName: string; logIndex: number } /** * Builds an `unknown` item for a transaction the address sent or paid for that * did not match any known classifier. Carries the real `transactionHash`/ * `timestamp` plus every log of the transaction in the top-level `events` * array (decoded into `eventName`/`args`/`title` when the log matched a known * `viem/tempo` ABI, otherwise just the raw `topics`/`data`), so no log is ever * silently dropped. */ function unknown( base: Base, data: { events: TxEvent[]; signer: 'self' | Address.Address }, ): Extract { return { ...base, data: { signer: data.signer }, events: data.events, type: 'unknown' } } /** Decodes a group's raw rows into `viem/tempo` events via `parseEventLogs` (non-strict). */ function decodeRows(rows: readonly Row[]) { const logs = rows.map( (r): Log => ({ address: r.logAddress as `0x${string}`, blockHash: '0x0' as Hex.Hex, blockNumber: BigInt(r.blockNum), data: (r.data ?? '0x') as Hex.Hex, logIndex: r.logIdx, removed: false, topics: [r.selector, r.topic1, r.topic2, r.topic3].filter(Boolean) as [Hex.Hex, ...Hex.Hex[]], transactionHash: r.txHash as `0x${string}`, transactionIndex: r.txIdx, }), ) try { // One batched call: viem re-processes the merged ABI per invocation, so // per-row calls cost ~55x more. Non-strict decoding skips undecodable // logs per log and can yield `args: undefined`; normalize so `in`-guards // never throw. return parseEventLogs({ abi: [...Abis.abis, ...Abis.earnRouter, ...Abis.earnVault, ...erc4626Events], logs, strict: false, }).map((event) => ({ ...event, args: event.args ?? {} })) } catch { return [] } } /** * Builds the full per-transaction `events` capture: one entry per raw log, * enriched with `eventName`/`args`/`title` when that log decoded into a known * event. Decoded entries are matched to their raw log by block-wide log index, * so the result covers every log (decoded or not) exactly once. */ function buildTxEvents(rows: readonly Row[], decoded: readonly DecodedEvent[]): TxEvent[] { const decodedByIndex = new Map(decoded.map((event) => [event.logIndex, event])) return rows .map((row) => { const base = { address: row.logAddress, data: (row.data ?? '0x') as Hex.Hex, logIndex: row.logIdx, topics: [row.selector, row.topic1, row.topic2, row.topic3].filter( (topic): topic is Hex.Hex => topic !== null, ), } const event = decodedByIndex.get(row.logIdx) if (!event) return base return { ...base, args: (jsonSafe(event.args) ?? {}) as Record, eventName: event.eventName, ...(titleByEventName[event.eventName] ? { title: titleByEventName[event.eventName] } : {}), } }) .sort((a, b) => a.logIndex - b.logIndex) } /** * Recursively converts decoded-event values into JSON-serializable form: bigints * become decimal strings, address-shaped strings are lowercased to match the * API's address convention (viem returns them checksummed), arrays/objects are * walked, everything else passes through. Applied to `parseEventLogs` args * before they cross the API boundary. */ function jsonSafe(value: unknown): unknown { if (typeof value === 'bigint') return value.toString() if (typeof value === 'string') return /^0x[0-9a-fA-F]{40}$/.test(value) ? value.toLowerCase() : value if (Array.isArray(value)) return value.map(jsonSafe) if (value && typeof value === 'object') { const out: Record = {} for (const [key, val] of Object.entries(value)) out[key] = jsonSafe(val) return out } return value } /** Lowercases a TIDX address column, defaulting to the zero address. */ function lower(value: unknown): Address.Address { return (Value.toText(value)?.toLowerCase() ?? zeroAddress) as Address.Address } /** Lowercases a TIDX transaction hash column, defaulting to `0x` when absent. */ function lowerHash(value: unknown): Hex.Hex { return (Value.toText(value)?.toLowerCase() ?? '0x') as Hex.Hex } /** Lowercases an optional TIDX address column, or null when absent. */ function lowerOrNull(value: unknown): Address.Address | null { const text = Value.toText(value) return text ? (text.toLowerCase() as Address.Address) : null } /** Builds a token stub whose metadata is filled by {@link enrichItem}. */ function token(amount: bigint | undefined, address: Address.Address): UnenrichedToken { return { address: address.toLowerCase() as Address.Address, amount: (amount ?? 0n).toString() } } /** Collects every token address referenced by an item's `data`. */ function collectTokens(item: UnenrichedItem, set: Set) { const data = item.data as Partial> for (const key of tokenKeys) { const t = data[key] if (t) set.add(t.address.toLowerCase() as Address.Address) } } /** Resolved token fields keyed by lowercased address. */ type ResolvedToken = { currency: string decimals: number logoUri?: string | undefined name: string symbol: string verified?: boolean | undefined } /** Resolves TIP-20 metadata or the standard ERC-20 subset used by activity transfers. */ function getActivityTokenMetadata( c: Context, options: getActivityTokenMetadata.Options, ): Promise> { const { address, chainId } = options if (address.startsWith(tip20AddressPrefix)) return Tokens.getTokenMetadata(c, { address, chainId }) return Store.memoize( async () => { const metadata = await viem_Token.getMetadata(c.get('getClient')(chainId), { token: address }) // ERC-20 has no currency field; its symbol is the amount's unit label. return { currency: metadata.symbol, decimals: metadata.decimals, name: metadata.name, symbol: metadata.symbol, } }, { key: `token:erc20:v1:${chainId}:${address}:metadata`, store: c.get('store'), ttl: Ttl.minutes(1) }, // prettier-ignore ) } declare namespace getActivityTokenMetadata { type Options = { /** Token contract address. */ address: Address.Address /** Source chain id. */ chainId: z.output } } /** Resolves core metadata into immutable items. Optional logo and verification sources are best-effort. */ async function enrichTokens( c: Context, options: enrichTokens.Options, ): Promise<{ items: Item[]; rates: FxOracle.RateSet | undefined }> { const { chainId, denomination, include, items, oracle } = options const addresses = new Set() for (const item of items) collectTokens(item, addresses) const includeLogoUri = include.includes('token.logoUri') const includeVerified = include.includes('token.verified') const resolved_promise = addresses.size > 0 ? Timing.time(c, 'tokens', () => Promise.all( [...addresses].map(async (address) => { const [logoUri, metadata] = await Promise.all([ includeLogoUri ? Tokens.getTokenLogo(c, { address, chainId }).catch(() => undefined) : undefined, getActivityTokenMetadata(c, { address, chainId }), ]) return { address: address.toLowerCase(), logoUri, metadata } }), ), ) : undefined // Curated data is needed only for requested valuation or token fields. const snapshot_promise = addresses.size > 0 && (denomination !== undefined || includeLogoUri || includeVerified) ? VerifiedTokens.snapshot(c, chainId).catch(() => undefined) : Promise.resolve(undefined) const rates_promise = (async () => { if (!denomination) return undefined const snapshot = await snapshot_promise return Valuation.ratesFor(c, { currencies: snapshot ? [...addresses].flatMap((address) => { const held = snapshot.byAddress.get(address)?.currency return held === undefined ? [] : [held] }) : [], denomination, oracle, }) })() if (!resolved_promise) return { items: items as Item[], rates: await rates_promise } const [resolved_raw, rates, snapshot] = await Promise.all([ resolved_promise, rates_promise, snapshot_promise, ]) const resolved = resolved_raw.map( ({ address, logoUri, metadata }) => [ address, { currency: metadata.currency, decimals: metadata.decimals, logoUri: includeLogoUri ? (logoUri ?? snapshot?.byAddress.get(address)?.logoUri ?? metadata.logoUri) : undefined, name: metadata.name, symbol: metadata.symbol, verified: includeVerified && snapshot ? snapshot.byAddress.has(address) : undefined, }, ] as const, ) const byAddress = new Map(resolved) return { items: items.map((item) => enrichItem(item, byAddress, { denomination, rates, snapshot })), rates, } } declare namespace enrichTokens { type Options = { chainId: z.output /** Currency the amount valuations are denominated in (uppercase). */ denomination?: string | undefined include: readonly z.output[] items: readonly UnenrichedItem[] /** FX rate oracle backing amount valuation. */ oracle: FxOracle.Oracle } } /** Resolves token metadata on each item's source chain without changing feed order. */ async function enrichChainTokens( c: Context, options: enrichChainTokens.Options, ): Promise<{ items: Item[]; rates: FxOracle.RateSet | undefined }> { const byChain = new Map() for (const item of options.items) { const items = byChain.get(item.chainId) ?? [] items.push(item) byChain.set(item.chainId, items) } const results = await Promise.all( [...byChain].map(async ([chainId, items]) => ({ chainId, ...(await enrichTokens(c, { chainId, denomination: options.denomination, include: options.include, items, oracle: options.oracle, })), })), ) type Bucket = { index: number; items: Item[] } const buckets = new Map() for (const result of results) buckets.set(result.chainId, { index: 0, items: result.items }) return { items: options.items.map((item) => { const bucket = buckets.get(item.chainId)! return bucket.items[bucket.index++]! }), rates: results.find((result) => result.rates !== undefined)?.rates, } } declare namespace enrichChainTokens { type Options = { /** Currency the amount valuations are denominated in. */ denomination?: string | undefined /** Optional token fields to resolve. */ include: readonly z.output[] /** Chain-tagged items awaiting token enrichment. */ items: readonly listChains.Item[] /** FX rate oracle backing amount valuation. */ oracle: FxOracle.Oracle } } /** Returns an enriched copy of an item without mutating its cached token stubs. */ function enrichItem( item: UnenrichedItem, byAddress: Map, valuation: enrichItem.Valuation, ): Item { const source = item.data as Partial> const data: Record = { ...item.data } for (const key of tokenKeys) { const t = source[key] if (!t) continue const meta = byAddress.get(t.address.toLowerCase()) if (!meta) throw new Error(`Token metadata unavailable for ${t.address}`) data[amountKeyByTokenKey[key]] = { ...Value.tokenAmount({ baseUnits: t.amount, currency: meta.currency, decimals: meta.decimals, }), ...(valuation.denomination ? { // Valued via the curated display currency, never the on-chain metadata. valuation: Valuation.valuationFor({ amount: BigInt(t.amount), denomination: valuation.denomination, rates: valuation.rates, token: valuation.snapshot?.byAddress.get(t.address.toLowerCase()), }), } : {}), } data[key] = { address: t.address, currency: meta.currency, decimals: meta.decimals, ...(meta.logoUri !== undefined ? { logoUri: meta.logoUri } : {}), name: meta.name, symbol: meta.symbol, ...(meta.verified !== undefined ? { verified: meta.verified } : {}), } } return { ...item, data } as Item } declare namespace enrichItem { /** Valuation inputs threaded from {@link enrichTokens}. */ type Valuation = { /** Currency the amount valuations are denominated in (uppercase). */ denomination?: string | undefined /** Oracle rate set, when loaded. */ rates: FxOracle.RateSet | undefined /** Compiled verified-token snapshot, when available. */ snapshot: VerifiedTokens.Snapshot | undefined } } /** * Folds adjacent items signed by the same access key on the same UTC calendar * day into a single `group` entry. Self-signed items pass through untouched. * Must be called once over the full, timestamp-sorted item list. */ export function groupByAccessKey(items: Item[]): Entry[] { return groupByAccessKeyItems(items) } type GroupableItem = { chainId?: number | undefined data: object id: string perspective: 'incoming' | 'outgoing' timestamp: string } type GroupedItem = | item | { chainId?: number | undefined data: { items: item[]; signer: Address.Address } id: string perspective: 'incoming' | 'outgoing' timestamp: string title: 'Group' type: 'group' } /** Groups items while preserving whether their token data has been enriched. */ function groupByAccessKeyItems(items: item[]): GroupedItem[] { const entries: GroupedItem[] = [] let currentGroup: { items: item[]; signer: Address.Address } | null = null for (const item of items) { const itemSigner = signerOf(item) if (itemSigner !== 'self') { if ( currentGroup && currentGroup.items[0]!.chainId === item.chainId && Address.isEqual(currentGroup.signer, itemSigner) && sameCalendarDay(currentGroup.items[0]!.timestamp, item.timestamp) ) { currentGroup.items.push(item) if (currentGroup.items.length >= maxGroupSize) { entries.push(flushGroup(currentGroup)) currentGroup = null } } else { if (currentGroup) entries.push(flushGroup(currentGroup)) currentGroup = { items: [item], signer: itemSigner } } } else { if (currentGroup) { entries.push(flushGroup(currentGroup)) currentGroup = null } entries.push(item) } } if (currentGroup) entries.push(flushGroup(currentGroup)) return entries } /** The access-key signer of an item, or `self` when it has no access-key signer. */ function signerOf(item: GroupableItem): 'self' | Address.Address { return (item.data as { signer?: 'self' | Address.Address }).signer ?? 'self' } /** Unwraps single-item groups; otherwise wraps the items in a `group` entry. */ function flushGroup(group: { items: item[] signer: Address.Address }): GroupedItem { if (group.items.length === 1) return group.items[0]! const firstItem = group.items[0]! const lastItem = group.items[group.items.length - 1]! return { ...(firstItem.chainId === undefined ? {} : { chainId: firstItem.chainId }), data: { items: group.items, signer: group.signer }, perspective: firstItem.perspective, id: `${firstItem.id}..${lastItem.id}:${group.items.length}`, timestamp: firstItem.timestamp, title: 'Group', type: 'group', } } /** Whether two ISO 8601 timestamps fall on the same UTC calendar day. */ function sameCalendarDay(a: string, b: string): boolean { const da = new Date(a) const db = new Date(b) return ( da.getUTCFullYear() === db.getUTCFullYear() && da.getUTCMonth() === db.getUTCMonth() && da.getUTCDate() === db.getUTCDate() ) }