From b3e16d728b2ea4b41416ac4c152a897f679f0834 Mon Sep 17 00:00:00 2001 From: Seranged <80223622+Seranged@users.noreply.github.com> Date: Sat, 27 Jun 2026 14:50:28 +0100 Subject: [PATCH 01/11] feat: add activity service Adds a V3-backed activity service with account and vault event fetchers, normalized activity labels/categories, SDK config wiring, and coverage for local proxy usage. --- packages/euler-v2-sdk/src/index.ts | 21 +- packages/euler-v2-sdk/src/sdk/buildSDK.ts | 25 ++ packages/euler-v2-sdk/src/sdk/config.ts | 24 +- .../euler-v2-sdk/src/sdk/defaultConfig.ts | 8 +- packages/euler-v2-sdk/src/sdk/sdk.ts | 4 + .../activityService/activityService.ts | 277 ++++++++++++++++++ .../activityService/activityServiceTypes.ts | 72 +++++ .../src/services/activityService/index.ts | 22 ++ packages/euler-v2-sdk/src/utils/queryNames.ts | 2 + .../euler-v2-sdk/test/activityService.test.ts | 167 +++++++++++ 10 files changed, 603 insertions(+), 19 deletions(-) create mode 100644 packages/euler-v2-sdk/src/services/activityService/activityService.ts create mode 100644 packages/euler-v2-sdk/src/services/activityService/activityServiceTypes.ts create mode 100644 packages/euler-v2-sdk/src/services/activityService/index.ts create mode 100644 packages/euler-v2-sdk/test/activityService.test.ts diff --git a/packages/euler-v2-sdk/src/index.ts b/packages/euler-v2-sdk/src/index.ts index f2f21c0a..d037d575 100644 --- a/packages/euler-v2-sdk/src/index.ts +++ b/packages/euler-v2-sdk/src/index.ts @@ -35,6 +35,7 @@ export * from "./services/oracleAdapterService/index.js"; export * from "./services/feeFlowService/index.js"; export * from "./services/reulLockService/index.js"; export * from "./services/positionMigrationService/index.js"; +export * from "./services/activityService/index.js"; // Plugins export * from "./plugins/index.js"; @@ -44,16 +45,16 @@ export * from "./utils/subAccounts.js"; export * from "./utils/accountPositionClassification.js"; export { VaultType } from "./utils/types.js"; export { - type BuildQueryFn, - type BuildQueryContext, - type QueryCacheConfig, - applyBuildQuery, - createQueryCacheBuildQuery, - getEulerSdkQueryKey, - normalizeQueryKeyObjectSets, - normalizeQueryKeySet, - normalizeQueryKeyValue, - serializeQueryArgs, + type BuildQueryFn, + type BuildQueryContext, + type QueryCacheConfig, + applyBuildQuery, + createQueryCacheBuildQuery, + getEulerSdkQueryKey, + normalizeQueryKeyObjectSets, + normalizeQueryKeySet, + normalizeQueryKeyValue, + serializeQueryArgs, } from "./utils/buildQuery.js"; export type { EulerSDKQueryName, QueryMethodName } from "./utils/queryNames.js"; export * from "./utils/stateOverrides/index.js"; diff --git a/packages/euler-v2-sdk/src/sdk/buildSDK.ts b/packages/euler-v2-sdk/src/sdk/buildSDK.ts index 99033010..bd4821f7 100644 --- a/packages/euler-v2-sdk/src/sdk/buildSDK.ts +++ b/packages/euler-v2-sdk/src/sdk/buildSDK.ts @@ -100,6 +100,7 @@ import { defaultEulerEarnV3AdapterConfig, defaultEulerLabelsURLAdapterConfig, defaultIntrinsicApyV3AdapterConfig, + defaultActivityServiceConfig, defaultPricingServiceConfig, defaultRewardsV3AdapterConfig, defaultSwapServiceConfig, @@ -127,6 +128,11 @@ import { type MorphoMigrationConnectorConfig, type PositionMigrationServiceConfig, } from "../services/positionMigrationService/index.js"; +import { + ActivityService, + type ActivityServiceConfig, + type IActivityService, +} from "../services/activityService/index.js"; import { type EulerSDKConfig, readEulerSDKEnvConfig, @@ -191,6 +197,7 @@ export interface BuildSDKOverrides< feeFlowService?: IFeeFlowService; reulLockService?: IREULLockService; positionMigrationService?: IPositionMigrationService; + activityService?: IActivityService; } export type { EulerSDKConfig } from "./config.js"; @@ -228,6 +235,7 @@ export interface BuildSDKOptions< aave?: AaveMigrationConnectorConfig; metamorpho?: MetamorphoMigrationConnectorConfig; }; + activityServiceConfig?: ActivityServiceConfig; /** Default in-memory cache applied to all decorated `query*` methods. Enabled by default with 5s success and failure TTLs. */ queryCacheConfig?: QueryCacheConfig; /** Optional query decorator applied to all query* functions across all services. Use for global logging, caching, profiling, etc. */ @@ -538,6 +546,7 @@ export async function buildEulerSDK< feeFlowServiceConfig, positionMigrationServiceConfig, positionMigrationConnectorConfig, + activityServiceConfig, } = options; const envConfig = readEulerSDKEnvConfig(); @@ -1532,6 +1541,21 @@ export async function buildEulerSDK< resolvedBuildQuery, ); })(); + const activityService = + servicesOverrides?.activityService ?? + new ActivityService( + resolveV3AdapterConfig(defaultActivityServiceConfig, { + explicitConfig: activityServiceConfig, + explicitV3ApiKey: v3ApiKey, + envConfig, + config, + envEndpoint: envConfig.activityV3ApiUrl, + configEndpoint: config?.activityV3ApiUrl, + envApiKey: envConfig.activityV3ApiKey, + configApiKey: config?.activityV3ApiKey, + }), + resolvedBuildQuery, + ); if (executionService instanceof ExecutionService) { executionService.setProviderService(providerService as ProviderService); @@ -1626,6 +1650,7 @@ export async function buildEulerSDK< feeFlowService, reulLockService, positionMigrationService, + activityService, plugins, }); diff --git a/packages/euler-v2-sdk/src/sdk/config.ts b/packages/euler-v2-sdk/src/sdk/config.ts index 8bccac61..ec827f5b 100644 --- a/packages/euler-v2-sdk/src/sdk/config.ts +++ b/packages/euler-v2-sdk/src/sdk/config.ts @@ -102,6 +102,9 @@ export interface EulerSDKConfig { feeFlowControllerUtilAddress?: Address; feeFlowDefaultBuyDeadlineSeconds?: number; + activityV3ApiUrl?: string; + activityV3ApiKey?: string; + queryCacheEnabled?: boolean; queryCacheTtlMs?: number; } @@ -236,7 +239,10 @@ function readOptionalAddress( } } -function readOptionalString(value: unknown, message: string): string | undefined { +function readOptionalString( + value: unknown, + message: string, +): string | undefined { if (value === undefined) return undefined; if (typeof value !== "string") throw new Error(message); return value; @@ -268,12 +274,13 @@ function readTurtleRewardToken( token.address, `${name} rewardToken.address values must be EVM addresses`, ), - chainId: token.chainId === undefined - ? undefined - : readPositiveInteger( - token.chainId, - `${name} rewardToken.chainId values must be positive integers`, - ), + chainId: + token.chainId === undefined + ? undefined + : readPositiveInteger( + token.chainId, + `${name} rewardToken.chainId values must be positive integers`, + ), symbol: readOptionalString( token.symbol, `${name} rewardToken.symbol values must be strings`, @@ -548,6 +555,9 @@ export function readEulerSDKEnvConfig( "EULER_SDK_FEE_FLOW_DEFAULT_BUY_DEADLINE_SECONDS", ), + activityV3ApiUrl: readString(env, "EULER_SDK_ACTIVITY_V3_API_URL"), + activityV3ApiKey: readString(env, "EULER_SDK_ACTIVITY_V3_API_KEY"), + queryCacheEnabled: readBoolean(env, "EULER_SDK_QUERY_CACHE_ENABLED"), queryCacheTtlMs: readNumber(env, "EULER_SDK_QUERY_CACHE_TTL_MS"), }); diff --git a/packages/euler-v2-sdk/src/sdk/defaultConfig.ts b/packages/euler-v2-sdk/src/sdk/defaultConfig.ts index c3913ab8..88427fef 100644 --- a/packages/euler-v2-sdk/src/sdk/defaultConfig.ts +++ b/packages/euler-v2-sdk/src/sdk/defaultConfig.ts @@ -13,6 +13,7 @@ import type { TokenlistServiceConfig } from "../services/tokenlistService/index. import type { PricingServiceConfig } from "../services/priceService/index.js"; import type { IntrinsicApyV3AdapterConfig } from "../services/intrinsicApyService/index.js"; import type { RewardsV3AdapterConfig } from "../services/rewardsService/index.js"; +import type { ActivityServiceConfig } from "../services/activityService/index.js"; const SUBGRAPH_BASE_URL = "https://api.goldsky.com/api/public/project_cm4iagnemt1wp01xn4gh1agft/subgraphs"; @@ -68,6 +69,10 @@ export const defaultRewardsV3AdapterConfig: RewardsV3AdapterConfig = { endpoint: DEFAULT_V3_API_URL, }; +export const defaultActivityServiceConfig: ActivityServiceConfig = { + endpoint: DEFAULT_V3_API_URL, +}; + /** Same subgraph endpoints as account vaults; kept for explicit subgraph-based vault type resolution. */ export const defaultVaultTypeSubgraphAdapterConfig: VaultTypeSubgraphAdapterConfig = defaultAccountVaultsAdapterConfig; @@ -106,8 +111,7 @@ export const defaultDeploymentServiceConfig: DeploymentServiceConfig = { "https://raw.githubusercontent.com/euler-xyz/euler-interfaces/refs/heads/master/EulerChains.json", }; -export const DEFAULT_TOKENLIST_API_BASE_URL = - DEFAULT_V3_API_URL; +export const DEFAULT_TOKENLIST_API_BASE_URL = DEFAULT_V3_API_URL; export const defaultTokenlistServiceConfig: TokenlistServiceConfig = { getTokenListUrl: (chainId: number) => diff --git a/packages/euler-v2-sdk/src/sdk/sdk.ts b/packages/euler-v2-sdk/src/sdk/sdk.ts index 96d32164..8f23859e 100644 --- a/packages/euler-v2-sdk/src/sdk/sdk.ts +++ b/packages/euler-v2-sdk/src/sdk/sdk.ts @@ -23,6 +23,7 @@ import type { IOracleAdapterService } from "../services/oracleAdapterService/ind import type { IFeeFlowService } from "../services/feeFlowService/index.js"; import type { IREULLockService } from "../services/reulLockService/index.js"; import type { IPositionMigrationService } from "../services/positionMigrationService/index.js"; +import type { IActivityService } from "../services/activityService/index.js"; import type { EulerPlugin, PluginPrefetchData } from "../plugins/types.js"; import type { TransactionPlan } from "../services/executionService/executionServiceTypes.js"; import type { AddressOrAccount } from "../entities/Account.js"; @@ -51,6 +52,7 @@ export interface EulerSDKOptions< feeFlowService: IFeeFlowService; reulLockService: IREULLockService; positionMigrationService: IPositionMigrationService; + activityService: IActivityService; plugins?: EulerPlugin[]; } @@ -76,6 +78,7 @@ export class EulerSDK { public readonly feeFlowService: IFeeFlowService; public readonly reulLockService: IREULLockService; public readonly positionMigrationService: IPositionMigrationService; + public readonly activityService: IActivityService; public readonly plugins: EulerPlugin[]; constructor(options: EulerSDKOptions) { @@ -100,6 +103,7 @@ export class EulerSDK { this.feeFlowService = options.feeFlowService; this.reulLockService = options.reulLockService; this.positionMigrationService = options.positionMigrationService; + this.activityService = options.activityService; this.plugins = options.plugins ?? []; } diff --git a/packages/euler-v2-sdk/src/services/activityService/activityService.ts b/packages/euler-v2-sdk/src/services/activityService/activityService.ts new file mode 100644 index 00000000..0f2d6c33 --- /dev/null +++ b/packages/euler-v2-sdk/src/services/activityService/activityService.ts @@ -0,0 +1,277 @@ +import { getAddress, isAddress, type Address } from "viem"; +import { type BuildQueryFn, applyBuildQuery } from "../../utils/buildQuery.js"; +import type { + ActivityCategory, + ActivityCategoryOption, + ActivityEvent, + ActivityEventsMeta, + ActivityEventsPage, + ActivityEventsQuery, + ActivityServiceConfig, + FetchAccountActivityEventsArgs, + FetchVaultActivityEventsArgs, + IActivityService, +} from "./activityServiceTypes.js"; + +type RawActivityResponse = { + data?: unknown[]; + meta?: ActivityEventsMeta; +}; + +const SELECTOR_INFO: Record< + string, + { label: string; category: ActivityCategory } +> = { + // ERC4626 share operations used by EVK and Earn vaults. + "0x6e553f65": { label: "Deposit", category: "lending" }, + "0x94bf804d": { label: "Mint shares", category: "lending" }, + "0xb460af94": { label: "Withdraw", category: "lending" }, + "0xba087652": { label: "Redeem shares", category: "lending" }, + // Common EVC/account operations. + "0x2b67b570": { label: "Permit approval", category: "account" }, + "0x3f8a17e2": { label: "Enable collateral", category: "account" }, + "0x1d5a6eb6": { label: "Disable collateral", category: "account" }, + "0x92b7d2bb": { label: "Enable controller", category: "account" }, + "0xa789fe84": { label: "Disable controller", category: "account" }, +}; + +export const ACTIVITY_CATEGORIES: ActivityCategoryOption[] = [ + { value: "lending", label: "Lending" }, + { value: "borrowing", label: "Borrowing" }, + { value: "swaps", label: "Swaps" }, + { value: "liquidations", label: "Liquidations" }, + { value: "account", label: "Account" }, + { value: "rewards", label: "Rewards" }, +]; + +const getString = (value: unknown): string | undefined => + typeof value === "string" && value.trim() ? value : undefined; + +export const getActivityPayloadString = ( + event: Pick, + keys: string[], +): string | undefined => { + for (const key of keys) { + const value = getString(event.payload[key]); + if (value) return value; + } + return undefined; +}; + +const normalizeSelector = ( + payload: Record, +): string | undefined => getString(payload.selector)?.toLowerCase(); + +const titleize = (value: string): string => { + const cleaned = value + .replace(/[_-]+/g, " ") + .replace(/([a-z])([A-Z])/g, "$1 $2") + .trim(); + if (!cleaned) return "Activity"; + return cleaned.charAt(0).toUpperCase() + cleaned.slice(1); +}; + +const inferCategory = ( + type: string, + payload: Record, +): ActivityCategory => { + const selector = normalizeSelector(payload); + const selectorInfo = selector ? SELECTOR_INFO[selector] : undefined; + if (selectorInfo) return selectorInfo.category; + + const haystack = [ + type, + getString(payload.event), + getString(payload.kind), + getString(payload.action), + getString(payload.operation), + getString(payload.selector_name), + getString(payload.method), + ] + .filter(Boolean) + .join(" ") + .toLowerCase(); + + if (/reward|claim|merkl|reul|lock|unlock/.test(haystack)) return "rewards"; + if (/liquidat|violator|liquidator/.test(haystack)) return "liquidations"; + if (/swap|multiply|cow/.test(haystack)) return "swaps"; + if (/borrow|repay|debt/.test(haystack)) return "borrowing"; + if (/deposit|withdraw|mint|redeem|supply|share/.test(haystack)) { + return "lending"; + } + return "account"; +}; + +const inferLabel = (type: string, payload: Record): string => { + const selector = normalizeSelector(payload); + const selectorInfo = selector ? SELECTOR_INFO[selector] : undefined; + if (selectorInfo) return selectorInfo.label; + + return ( + getString(payload.label) ?? + getString(payload.event) ?? + getString(payload.action) ?? + getString(payload.operation) ?? + titleize(type) + ); +}; + +const normalizeAddressValue = (value: unknown): Address | undefined => { + const maybeAddress = getString(value); + if (!maybeAddress || !isAddress(maybeAddress)) return undefined; + return getAddress(maybeAddress) as Address; +}; + +export const getActivityTargetContract = ( + event: Pick, +): Address | undefined => + normalizeAddressValue( + event.payload.target_contract ?? + event.payload.targetContract ?? + event.payload.vault ?? + event.payload.vault_address ?? + event.payload.vaultAddress, + ); + +export const getActivityAccount = ( + event: Pick, +): Address | undefined => + normalizeAddressValue( + event.payload.on_behalf_of_account ?? + event.payload.onBehalfOfAccount ?? + event.payload.account ?? + event.payload.sub_account ?? + event.payload.subAccount, + ); + +export const getActivityCaller = ( + event: Pick, +): Address | undefined => + normalizeAddressValue( + event.payload.caller ?? event.payload.sender ?? event.payload.owner, + ); + +export const normalizeActivityEvent = (raw: unknown): ActivityEvent | null => { + if (!raw || typeof raw !== "object") return null; + const record = raw as Record; + const type = getString(record.type) ?? "activity"; + const payload = + record.payload && typeof record.payload === "object" + ? (record.payload as Record) + : {}; + + const chainId = Number(record.chainId); + const timestamp = getString(record.timestamp) ?? getString(record.createdAt); + if (!Number.isFinite(chainId) || !timestamp) return null; + + const blockNumber = getString(record.blockNumber) ?? getString(record.block); + const txHash = getString(record.txHash) ?? getString(record.transactionHash); + + return { + chainId, + type, + timestamp, + ...(blockNumber ? { blockNumber } : {}), + ...(txHash ? { txHash } : {}), + payload, + category: inferCategory(type, payload), + label: inferLabel(type, payload), + }; +}; + +export const normalizeActivityEventsResponse = ( + raw: unknown, +): ActivityEventsPage => { + const parsed = + typeof raw === "string" ? (JSON.parse(raw) as RawActivityResponse) : raw; + const response = (parsed ?? {}) as RawActivityResponse; + return { + data: (response.data ?? []) + .map(normalizeActivityEvent) + .filter((event): event is ActivityEvent => Boolean(event)), + ...(response.meta ? { meta: response.meta } : {}), + }; +}; + +export class ActivityService implements IActivityService { + private readonly endpoint: string; + private readonly apiKey?: string; + + constructor(config: ActivityServiceConfig, buildQuery?: BuildQueryFn) { + this.endpoint = config.endpoint; + this.apiKey = config.apiKey; + if (buildQuery) applyBuildQuery(this, buildQuery); + } + + queryAccountActivityEvents = async ( + args: FetchAccountActivityEventsArgs, + ): Promise => { + const url = this.buildEventsUrl( + `/v3/evc/accounts/${getAddress(args.account)}/events`, + args, + ); + return this.fetchEvents(url); + }; + + queryVaultActivityEvents = async ( + args: FetchVaultActivityEventsArgs, + ): Promise => { + const url = this.buildEventsUrl( + `/v3/evk/vaults/${args.chainId}/${getAddress(args.vault)}/events`, + args, + ); + return this.fetchEvents(url); + }; + + async fetchAccountActivityEvents( + args: FetchAccountActivityEventsArgs, + ): Promise { + return this.queryAccountActivityEvents(args); + } + + async fetchVaultActivityEvents( + args: FetchVaultActivityEventsArgs, + ): Promise { + return this.queryVaultActivityEvents(args); + } + + private async fetchEvents(url: string): Promise { + const response = await fetch(url, { + method: "GET", + headers: this.getHeaders(), + }); + if (!response.ok) { + const body = await response.text().catch(() => ""); + throw new Error( + `ActivityService request failed (${response.status} ${response.statusText}): ${body.slice(0, 200)}`, + ); + } + return normalizeActivityEventsResponse(await response.json()); + } + + private getHeaders(): Record { + return { + Accept: "application/json", + ...(this.apiKey ? { "X-API-Key": this.apiKey } : {}), + }; + } + + private buildEventsUrl(path: string, args: ActivityEventsQuery): string { + const params = new URLSearchParams({ + chainId: String(args.chainId), + from: String(args.from), + to: String(args.to), + }); + if (args.type !== undefined) params.set("type", args.type); + if (args.offset !== undefined) params.set("offset", String(args.offset)); + if (args.limit !== undefined) params.set("limit", String(args.limit)); + + const normalizedEndpoint = this.endpoint.replace(/\/+$/, ""); + const url = + normalizedEndpoint.startsWith("http://") || + normalizedEndpoint.startsWith("https://") + ? new URL(path, `${normalizedEndpoint}/`).toString() + : `${normalizedEndpoint}${path}`; + return `${url}?${params.toString()}`; + } +} diff --git a/packages/euler-v2-sdk/src/services/activityService/activityServiceTypes.ts b/packages/euler-v2-sdk/src/services/activityService/activityServiceTypes.ts new file mode 100644 index 00000000..c2678abd --- /dev/null +++ b/packages/euler-v2-sdk/src/services/activityService/activityServiceTypes.ts @@ -0,0 +1,72 @@ +import type { Address } from "viem"; + +export type ActivityCategory = + | "lending" + | "borrowing" + | "swaps" + | "liquidations" + | "account" + | "rewards"; + +export interface ActivityCategoryOption { + value: ActivityCategory; + label: string; +} + +export interface ActivityEvent { + chainId: number; + type: string; + timestamp: string; + blockNumber?: string; + txHash?: string; + payload: Record; + category: ActivityCategory; + label: string; +} + +export interface ActivityEventsMeta { + hasMore?: boolean; + offset?: number; + limit?: number; + total?: number; + timestamp?: string; + chainId?: string | number; +} + +export interface ActivityEventsPage { + data: ActivityEvent[]; + meta?: ActivityEventsMeta; +} + +export interface ActivityEventsQuery { + chainId: number; + from: number; + to: number; + type?: string; + offset?: number; + limit?: number; +} + +export interface FetchAccountActivityEventsArgs extends ActivityEventsQuery { + account: Address; +} + +export interface FetchVaultActivityEventsArgs extends ActivityEventsQuery { + vault: Address; +} + +export interface ActivityServiceConfig { + /** V3 API endpoint URL. */ + endpoint: string; + /** Optional API key sent as `X-API-Key` for V3 backend requests. */ + apiKey?: string; +} + +export interface IActivityService { + fetchAccountActivityEvents( + args: FetchAccountActivityEventsArgs, + ): Promise; + fetchVaultActivityEvents( + args: FetchVaultActivityEventsArgs, + ): Promise; +} diff --git a/packages/euler-v2-sdk/src/services/activityService/index.ts b/packages/euler-v2-sdk/src/services/activityService/index.ts new file mode 100644 index 00000000..7c5aa594 --- /dev/null +++ b/packages/euler-v2-sdk/src/services/activityService/index.ts @@ -0,0 +1,22 @@ +export { + ACTIVITY_CATEGORIES, + ActivityService, + getActivityAccount, + getActivityCaller, + getActivityPayloadString, + getActivityTargetContract, + normalizeActivityEvent, + normalizeActivityEventsResponse, +} from "./activityService.js"; +export type { + ActivityCategory, + ActivityCategoryOption, + ActivityEvent, + ActivityEventsMeta, + ActivityEventsPage, + ActivityEventsQuery, + ActivityServiceConfig, + FetchAccountActivityEventsArgs, + FetchVaultActivityEventsArgs, + IActivityService, +} from "./activityServiceTypes.js"; diff --git a/packages/euler-v2-sdk/src/utils/queryNames.ts b/packages/euler-v2-sdk/src/utils/queryNames.ts index 622b445a..067f8c39 100644 --- a/packages/euler-v2-sdk/src/utils/queryNames.ts +++ b/packages/euler-v2-sdk/src/utils/queryNames.ts @@ -15,6 +15,7 @@ import type { RewardsService } from "../services/rewardsService/rewardsService.j import type { SwapService } from "../services/swapService/swapService.js"; import type { TokenlistService } from "../services/tokenlistService/tokenlistService.js"; import type { WalletOnchainAdapter } from "../services/walletService/adapters/walletOnchainAdapter.js"; +import type { ActivityService } from "../services/activityService/activityService.js"; import type { EVaultOnchainAdapter } from "../services/vaults/eVaultService/adapters/eVaultOnchainAdapter/eVaultOnchainAdapter.js"; import type { EVaultV3Adapter } from "../services/vaults/eVaultService/adapters/eVaultV3Adapter/eVaultV3Adapter.js"; import type { EulerEarnOnchainAdapter } from "../services/vaults/eulerEarnService/adapters/eulerEarnOnchainAdapter.js"; @@ -43,6 +44,7 @@ export type EulerSDKQueryName = | QueryMethodName | QueryMethodName | QueryMethodName + | QueryMethodName | QueryMethodName | QueryMethodName | QueryMethodName diff --git a/packages/euler-v2-sdk/test/activityService.test.ts b/packages/euler-v2-sdk/test/activityService.test.ts new file mode 100644 index 00000000..100c9cea --- /dev/null +++ b/packages/euler-v2-sdk/test/activityService.test.ts @@ -0,0 +1,167 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { getAddress } from "viem"; +import type { Address } from "viem"; +import { buildEulerSDK } from "../src/sdk/buildSDK.js"; +import type { IDeploymentService } from "../src/services/deploymentService/index.js"; +import { + ActivityService, + getActivityAccount, + getActivityCaller, + getActivityTargetContract, + normalizeActivityEvent, +} from "../src/services/activityService/index.js"; + +const ACCOUNT = getAddress( + "0xee5b5c82a365d75e9f8a1e982687fb5b6ceb606c", +) as Address; +const VAULT = "0xD8b27CF359b7D15710a5BE299AF6e7Bf904984C2" as Address; +const CALLER = "0x0000000000000000000000000000000000000001" as Address; + +const deploymentService: IDeploymentService = { + getDeploymentChainIds: () => [], + getDeployment: () => { + throw new Error("not used"); + }, + addDeployment: () => {}, +}; + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe("ActivityService", () => { + it("fetches and normalizes account activity from a relative V3 endpoint", async () => { + const requests: Array<{ url: string; headers: HeadersInit | undefined }> = []; + vi.stubGlobal( + "fetch", + vi.fn(async (url: string, init?: RequestInit) => { + requests.push({ url, headers: init?.headers }); + return new Response( + JSON.stringify({ + data: [ + { + chainId: 1, + type: "call_with_context", + timestamp: "2024-09-04T15:04:35.000Z", + blockNumber: "20700000", + txHash: + "0x2162b1e4c31ccb11d1d84a08e517f3509d4ee74022f0a4670e4502b461b191f6", + payload: { + caller: CALLER, + selector: "0x6e553f65", + target_contract: VAULT, + on_behalf_of_account: ACCOUNT, + }, + }, + ], + meta: { hasMore: false, offset: 0, limit: 25 }, + }), + { status: 200, headers: { "content-type": "application/json" } }, + ); + }), + ); + + const service = new ActivityService({ + endpoint: "/api", + apiKey: "secret", + }); + const page = await service.fetchAccountActivityEvents({ + account: ACCOUNT, + chainId: 1, + from: 1725148800, + to: 1726358400, + offset: 0, + limit: 25, + }); + + expect(requests[0]?.url).toBe( + `/api/v3/evc/accounts/${getAddress(ACCOUNT)}/events?chainId=1&from=1725148800&to=1726358400&offset=0&limit=25`, + ); + expect(requests[0]?.headers).toMatchObject({ + Accept: "application/json", + "X-API-Key": "secret", + }); + expect(page.meta?.hasMore).toBe(false); + expect(page.data[0]).toMatchObject({ + category: "lending", + label: "Deposit", + blockNumber: "20700000", + }); + expect(getActivityTargetContract(page.data[0]!)).toBe(VAULT); + expect(getActivityAccount(page.data[0]!)).toBe(ACCOUNT); + expect(getActivityCaller(page.data[0]!)).toBe(CALLER); + }); + + it("fetches vault activity from the V3 vault event endpoint", async () => { + const requests: string[] = []; + vi.stubGlobal( + "fetch", + vi.fn(async (url: string) => { + requests.push(url); + return new Response( + JSON.stringify({ + data: [ + { + chainId: 1, + type: "borrow", + timestamp: "2024-09-05T10:00:00.000Z", + payload: { vault: VAULT, account: ACCOUNT }, + }, + ], + }), + { status: 200, headers: { "content-type": "application/json" } }, + ); + }), + ); + + const service = new ActivityService({ endpoint: "https://v3.example/" }); + const page = await service.fetchVaultActivityEvents({ + vault: VAULT, + chainId: 1, + from: 1725148800, + to: 1726358400, + type: "borrow", + limit: 1, + }); + + expect(requests[0]).toBe( + "https://v3.example/v3/evk/vaults/1/0xD8b27CF359b7D15710a5BE299AF6e7Bf904984C2/events?chainId=1&from=1725148800&to=1726358400&type=borrow&limit=1", + ); + expect(page.data[0]?.category).toBe("borrowing"); + expect(page.data[0]?.label).toBe("Borrow"); + }); + + it("normalizes labels and filters invalid rows", () => { + const event = normalizeActivityEvent({ + chainId: "1", + type: "reward_claimed", + timestamp: "2024-09-06T12:00:00.000Z", + payload: { label: "Claimed reward" }, + }); + + expect(event).toMatchObject({ + category: "rewards", + label: "Claimed reward", + }); + expect(normalizeActivityEvent({ chainId: 1, type: "deposit" })).toBeNull(); + }); + + it("exposes activityService from buildEulerSDK with V3 config precedence", async () => { + const sdk = await buildEulerSDK({ + v3ApiKey: "shared-key", + activityServiceConfig: { + endpoint: "https://activity.example", + apiKey: "activity-key", + }, + servicesOverrides: { deploymentService }, + }); + + expect(sdk.activityService).toBeInstanceOf(ActivityService); + expect((sdk.activityService as ActivityService as any).endpoint).toBe( + "https://activity.example", + ); + expect((sdk.activityService as ActivityService as any).apiKey).toBe( + "activity-key", + ); + }); +}); From 306fe5dc42192054b336e48203ccfbf0c47a90fb Mon Sep 17 00:00:00 2001 From: Seranged <80223622+Seranged@users.noreply.github.com> Date: Mon, 29 Jun 2026 09:14:53 +0100 Subject: [PATCH 02/11] test: cover activity v3 config overrides Adds coverage showing activity-specific V3 endpoint and API key config override the shared V3 config, matching the SDK service convention. --- .../euler-v2-sdk/test/activityService.test.ts | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/packages/euler-v2-sdk/test/activityService.test.ts b/packages/euler-v2-sdk/test/activityService.test.ts index 100c9cea..654c11cd 100644 --- a/packages/euler-v2-sdk/test/activityService.test.ts +++ b/packages/euler-v2-sdk/test/activityService.test.ts @@ -164,4 +164,23 @@ describe("ActivityService", () => { "activity-key", ); }); + + it("lets activity V3 config override the shared V3 endpoint and API key", async () => { + const sdk = await buildEulerSDK({ + config: { + v3ApiUrl: "https://shared-v3.example", + v3ApiKey: "shared-config-key", + activityV3ApiUrl: "https://activity-v3.example", + activityV3ApiKey: "activity-v3-key", + }, + servicesOverrides: { deploymentService }, + }); + + expect((sdk.activityService as ActivityService as any).endpoint).toBe( + "https://activity-v3.example", + ); + expect((sdk.activityService as ActivityService as any).apiKey).toBe( + "activity-v3-key", + ); + }); }); From 23a3c3a2903846a20f97254d091e4973fa245276 Mon Sep 17 00:00:00 2001 From: Seranged <80223622+Seranged@users.noreply.github.com> Date: Mon, 29 Jun 2026 09:40:27 +0100 Subject: [PATCH 03/11] fix: keep activity service constructor compatible Makes activityService optional for direct EulerSDK construction and falls back to the default V3 activity service when omitted. --- packages/euler-v2-sdk/src/sdk/sdk.ts | 11 ++++++-- .../euler-v2-sdk/test/activityService.test.ts | 28 +++++++++++++++++++ 2 files changed, 36 insertions(+), 3 deletions(-) diff --git a/packages/euler-v2-sdk/src/sdk/sdk.ts b/packages/euler-v2-sdk/src/sdk/sdk.ts index 8f23859e..81741625 100644 --- a/packages/euler-v2-sdk/src/sdk/sdk.ts +++ b/packages/euler-v2-sdk/src/sdk/sdk.ts @@ -23,10 +23,14 @@ import type { IOracleAdapterService } from "../services/oracleAdapterService/ind import type { IFeeFlowService } from "../services/feeFlowService/index.js"; import type { IREULLockService } from "../services/reulLockService/index.js"; import type { IPositionMigrationService } from "../services/positionMigrationService/index.js"; -import type { IActivityService } from "../services/activityService/index.js"; +import { + ActivityService, + type IActivityService, +} from "../services/activityService/index.js"; import type { EulerPlugin, PluginPrefetchData } from "../plugins/types.js"; import type { TransactionPlan } from "../services/executionService/executionServiceTypes.js"; import type { AddressOrAccount } from "../entities/Account.js"; +import { defaultActivityServiceConfig } from "./defaultConfig.js"; export interface EulerSDKOptions< TVaultEntity extends IVaultEntity = VaultEntity, @@ -52,7 +56,7 @@ export interface EulerSDKOptions< feeFlowService: IFeeFlowService; reulLockService: IREULLockService; positionMigrationService: IPositionMigrationService; - activityService: IActivityService; + activityService?: IActivityService; plugins?: EulerPlugin[]; } @@ -103,7 +107,8 @@ export class EulerSDK { this.feeFlowService = options.feeFlowService; this.reulLockService = options.reulLockService; this.positionMigrationService = options.positionMigrationService; - this.activityService = options.activityService; + this.activityService = + options.activityService ?? new ActivityService(defaultActivityServiceConfig); this.plugins = options.plugins ?? []; } diff --git a/packages/euler-v2-sdk/test/activityService.test.ts b/packages/euler-v2-sdk/test/activityService.test.ts index 654c11cd..5c1bcff7 100644 --- a/packages/euler-v2-sdk/test/activityService.test.ts +++ b/packages/euler-v2-sdk/test/activityService.test.ts @@ -2,6 +2,7 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { getAddress } from "viem"; import type { Address } from "viem"; import { buildEulerSDK } from "../src/sdk/buildSDK.js"; +import { EulerSDK } from "../src/sdk/sdk.js"; import type { IDeploymentService } from "../src/services/deploymentService/index.js"; import { ActivityService, @@ -165,6 +166,33 @@ describe("ActivityService", () => { ); }); + it("keeps direct EulerSDK construction compatible without an activity service", () => { + const sdk = new EulerSDK({ + accountService: {} as never, + portfolioService: {} as never, + walletService: {} as never, + eVaultService: {} as never, + eulerEarnService: {} as never, + securitizeVaultService: {} as never, + vaultMetaService: {} as never, + deploymentService, + providerService: {} as never, + abiService: {} as never, + eulerLabelsService: {} as never, + tokenlistService: {} as never, + swapService: {} as never, + executionService: {} as never, + priceService: {} as never, + rewardsService: {} as never, + intrinsicApyService: {} as never, + oracleAdapterService: {} as never, + feeFlowService: {} as never, + reulLockService: {} as never, + }); + + expect(sdk.activityService).toBeInstanceOf(ActivityService); + }); + it("lets activity V3 config override the shared V3 endpoint and API key", async () => { const sdk = await buildEulerSDK({ config: { From dced6d5b3366f1f9c0cbbb8b400d15baee38911f Mon Sep 17 00:00:00 2001 From: Seranged <80223622+Seranged@users.noreply.github.com> Date: Mon, 13 Jul 2026 12:57:06 +0100 Subject: [PATCH 04/11] feat: normalize activity service Align the SDK adapter, validation, capability reporting, and configuration with the normalized V3 Activity contract. --- .../euler-v2-sdk/docs/config-through-env.md | 10 + packages/euler-v2-sdk/docs/configuration.md | 10 +- packages/euler-v2-sdk/src/sdk/buildSDK.ts | 31 +- packages/euler-v2-sdk/src/sdk/config.ts | 5 +- .../euler-v2-sdk/src/sdk/defaultConfig.ts | 2 +- packages/euler-v2-sdk/src/sdk/sdk.ts | 7 +- .../services/activityService/activityEvent.ts | 794 +++++++++++++ .../activityService/activityService.ts | 367 ++---- .../activityService/activityServiceTypes.ts | 158 ++- .../adapters/activityV3Adapter.ts | 223 ++++ .../src/services/activityService/index.ts | 28 +- .../euler-v2-sdk/test/activityService.test.ts | 1043 +++++++++++++++-- packages/euler-v2-sdk/test/sdkConfig.test.ts | 4 + 13 files changed, 2309 insertions(+), 373 deletions(-) create mode 100644 packages/euler-v2-sdk/src/services/activityService/activityEvent.ts create mode 100644 packages/euler-v2-sdk/src/services/activityService/adapters/activityV3Adapter.ts diff --git a/packages/euler-v2-sdk/docs/config-through-env.md b/packages/euler-v2-sdk/docs/config-through-env.md index 0d7a5fb8..7ea7e609 100644 --- a/packages/euler-v2-sdk/docs/config-through-env.md +++ b/packages/euler-v2-sdk/docs/config-through-env.md @@ -21,6 +21,16 @@ RPC URLs use `config.rpcUrls` or `EULER_SDK_RPC_URL_`. | `queryCacheEnabled` | `EULER_SDK_QUERY_CACHE_ENABLED` | `true` | | `queryCacheTtlMs` | `EULER_SDK_QUERY_CACHE_TTL_MS` | `5000` | +## Activity + +| Config field | Environment variable | Default | +|---|---|---| +| `activityV3ApiUrl` | `EULER_SDK_ACTIVITY_V3_API_URL` | `v3ApiUrl` | +| `activityV3ApiKey` | `EULER_SDK_ACTIVITY_V3_API_KEY` | `v3ApiKey` | + +The activity-specific values configure the normalized account and vault event +endpoints without changing the V3 configuration used by other SDK services. + ## Account Service | Config field | Environment variable | Default | diff --git a/packages/euler-v2-sdk/docs/configuration.md b/packages/euler-v2-sdk/docs/configuration.md index 34173b71..5b15f4ec 100644 --- a/packages/euler-v2-sdk/docs/configuration.md +++ b/packages/euler-v2-sdk/docs/configuration.md @@ -20,6 +20,8 @@ const sdk = await buildEulerSDK({ }, v3ApiUrl: "https://your-v3-api", v3ApiKey: process.env.EULER_SDK_V3_API_KEY, + activityV3ApiUrl: process.env.EULER_SDK_ACTIVITY_V3_API_URL, + activityV3ApiKey: process.env.EULER_SDK_ACTIVITY_V3_API_KEY, swapApiUrl: "https://swap.euler.finance", swapDefaultDeadline: 1800, }, @@ -73,6 +75,7 @@ Use explicit nested service config when the option is a function or a custom obj | `swapServiceConfig` | Euler swap API | Swap quote fetching | | `rewardsServiceConfig` | `v3` adapter with direct Brevis/Fuul helper reads | Reward campaign data, per-user rewards, and reward claim planning | | `intrinsicApyServiceConfig` | V3 intrinsic APY API | Underlying yield data for vault assets | +| `activityServiceConfig` | V3 activity API | Normalized account and vault event timelines with coverage metadata | | `buildQuery` | 5s in-memory cache | Wrap all external queries for caching, logging, or profiling | | `queryCacheConfig` | `{ enabled: true, ttlMs: 5000, failureTtlMs: 5000 }` | Built-in query cache settings when `buildQuery` is not supplied | | `plugins` | `[]` | Extend on-chain reads and transaction plans | @@ -80,7 +83,12 @@ Use explicit nested service config when the option is a function or a custom obj ## V3 adapter config -When `accountServiceConfig.adapter`, `eVaultServiceConfig.adapter`, `eulerEarnServiceConfig.adapter`, `vaultTypeAdapterConfig`, `rewardsServiceConfig.adapter`, `intrinsicApyServiceConfig`, or the built-in pricing service use V3, the SDK forwards the resolved API key as an `X-API-Key` request header. +When `accountServiceConfig.adapter`, `eVaultServiceConfig.adapter`, `eulerEarnServiceConfig.adapter`, `vaultTypeAdapterConfig`, `rewardsServiceConfig.adapter`, `intrinsicApyServiceConfig`, `activityServiceConfig`, or the built-in pricing service use V3, the SDK forwards the resolved API key as an `X-API-Key` request header. + +Activity uses the shared `v3ApiUrl` and `v3ApiKey` by default. Set +`activityV3ApiUrl` and `activityV3ApiKey`, or their +`EULER_SDK_ACTIVITY_V3_API_URL` and `EULER_SDK_ACTIVITY_V3_API_KEY` +environment variables, when activity requests use a separate endpoint or key. Adapter-specific keys override the shared key within the same configuration layer. Layer priority still applies, so `config.pricingApiKey` overrides `pricingServiceConfig.apiKey`, and `pricingServiceConfig.apiKey` overrides `EULER_SDK_PRICING_API_KEY`. diff --git a/packages/euler-v2-sdk/src/sdk/buildSDK.ts b/packages/euler-v2-sdk/src/sdk/buildSDK.ts index bd4821f7..a89bf6e4 100644 --- a/packages/euler-v2-sdk/src/sdk/buildSDK.ts +++ b/packages/euler-v2-sdk/src/sdk/buildSDK.ts @@ -100,7 +100,7 @@ import { defaultEulerEarnV3AdapterConfig, defaultEulerLabelsURLAdapterConfig, defaultIntrinsicApyV3AdapterConfig, - defaultActivityServiceConfig, + defaultActivityV3AdapterConfig, defaultPricingServiceConfig, defaultRewardsV3AdapterConfig, defaultSwapServiceConfig, @@ -130,6 +130,8 @@ import { } from "../services/positionMigrationService/index.js"; import { ActivityService, + ActivityV3Adapter, + UnavailableActivityAdapter, type ActivityServiceConfig, type IActivityService, } from "../services/activityService/index.js"; @@ -235,6 +237,7 @@ export interface BuildSDKOptions< aave?: AaveMigrationConnectorConfig; metamorpho?: MetamorphoMigrationConnectorConfig; }; + /** Configuration for the built-in V3 activity adapter. */ activityServiceConfig?: ActivityServiceConfig; /** Default in-memory cache applied to all decorated `query*` methods. Enabled by default with 5s success and failure TTLs. */ queryCacheConfig?: QueryCacheConfig; @@ -1541,19 +1544,25 @@ export async function buildEulerSDK< resolvedBuildQuery, ); })(); + const resolvedActivityV3Config = resolveV3AdapterConfig( + defaultActivityV3AdapterConfig, + { + explicitConfig: activityServiceConfig, + explicitV3ApiKey: v3ApiKey, + envConfig, + config, + envEndpoint: envConfig.activityV3ApiUrl, + configEndpoint: config?.activityV3ApiUrl, + envApiKey: envConfig.activityV3ApiKey, + configApiKey: config?.activityV3ApiKey, + }, + ); const activityService = servicesOverrides?.activityService ?? new ActivityService( - resolveV3AdapterConfig(defaultActivityServiceConfig, { - explicitConfig: activityServiceConfig, - explicitV3ApiKey: v3ApiKey, - envConfig, - config, - envEndpoint: envConfig.activityV3ApiUrl, - configEndpoint: config?.activityV3ApiUrl, - envApiKey: envConfig.activityV3ApiKey, - configApiKey: config?.activityV3ApiKey, - }), + disableV3 + ? new UnavailableActivityAdapter("v3-disabled") + : new ActivityV3Adapter(resolvedActivityV3Config), resolvedBuildQuery, ); diff --git a/packages/euler-v2-sdk/src/sdk/config.ts b/packages/euler-v2-sdk/src/sdk/config.ts index ec827f5b..0c400d76 100644 --- a/packages/euler-v2-sdk/src/sdk/config.ts +++ b/packages/euler-v2-sdk/src/sdk/config.ts @@ -11,9 +11,8 @@ export interface EulerSDKConfig { v3ApiUrl?: string; v3ApiKey?: string; /** - * Globally disables the V3 HTTP adapter as the primary in all fallback chains. - * When true, services configured for `fallback` use the secondary adapter only - * (onchain / direct / subgraph). Per-service `adapter` overrides still apply. + * Disables built-in V3 HTTP adapters. Services with secondary adapters use + * those sources; V3-only services report an unavailable capability. */ disableV3?: boolean; diff --git a/packages/euler-v2-sdk/src/sdk/defaultConfig.ts b/packages/euler-v2-sdk/src/sdk/defaultConfig.ts index 88427fef..3bde26a1 100644 --- a/packages/euler-v2-sdk/src/sdk/defaultConfig.ts +++ b/packages/euler-v2-sdk/src/sdk/defaultConfig.ts @@ -69,7 +69,7 @@ export const defaultRewardsV3AdapterConfig: RewardsV3AdapterConfig = { endpoint: DEFAULT_V3_API_URL, }; -export const defaultActivityServiceConfig: ActivityServiceConfig = { +export const defaultActivityV3AdapterConfig: ActivityServiceConfig = { endpoint: DEFAULT_V3_API_URL, }; diff --git a/packages/euler-v2-sdk/src/sdk/sdk.ts b/packages/euler-v2-sdk/src/sdk/sdk.ts index 81741625..06ccb6d2 100644 --- a/packages/euler-v2-sdk/src/sdk/sdk.ts +++ b/packages/euler-v2-sdk/src/sdk/sdk.ts @@ -25,12 +25,12 @@ import type { IREULLockService } from "../services/reulLockService/index.js"; import type { IPositionMigrationService } from "../services/positionMigrationService/index.js"; import { ActivityService, + UnavailableActivityAdapter, type IActivityService, } from "../services/activityService/index.js"; import type { EulerPlugin, PluginPrefetchData } from "../plugins/types.js"; import type { TransactionPlan } from "../services/executionService/executionServiceTypes.js"; import type { AddressOrAccount } from "../entities/Account.js"; -import { defaultActivityServiceConfig } from "./defaultConfig.js"; export interface EulerSDKOptions< TVaultEntity extends IVaultEntity = VaultEntity, @@ -108,7 +108,10 @@ export class EulerSDK { this.reulLockService = options.reulLockService; this.positionMigrationService = options.positionMigrationService; this.activityService = - options.activityService ?? new ActivityService(defaultActivityServiceConfig); + options.activityService ?? + new ActivityService( + new UnavailableActivityAdapter("source-not-configured"), + ); this.plugins = options.plugins ?? []; } diff --git a/packages/euler-v2-sdk/src/services/activityService/activityEvent.ts b/packages/euler-v2-sdk/src/services/activityService/activityEvent.ts new file mode 100644 index 00000000..43022ee7 --- /dev/null +++ b/packages/euler-v2-sdk/src/services/activityService/activityEvent.ts @@ -0,0 +1,794 @@ +import { type Address, getAddress, type Hex, isAddress } from "viem"; +import type { + ActivityAssetAmount, + ActivityAssetKind, + ActivityCategory, + ActivityCategoryOption, + ActivityChainCoverage, + ActivityChangeValue, + ActivityCoverage, + ActivityCoverageStatus, + ActivityEvent, + ActivityEventsMeta, + ActivityEventsPage, + ActivityValuation, + ActivityValueChange, + ActivityVaultType, + FetchAccountActivityEventsArgs, + FetchVaultActivityEventsArgs, +} from "./activityServiceTypes.js"; + +export const ACTIVITY_CATEGORY_VALUES = [ + "lending", + "borrowing", + "swaps", + "liquidations", + "account", + "rewards", + "governance", +] as const satisfies readonly ActivityCategory[]; + +const ACTIVITY_COVERAGE_STATUSES = [ + "complete", + "partial", + "unsupported", + "syncing", +] as const satisfies readonly ActivityCoverageStatus[]; + +export const ACTIVITY_VAULT_TYPES = [ + "evk", + "earn", + "securitize", +] as const satisfies readonly ActivityVaultType[]; + +const VALUATION_STATUSES = [ + "available", + "unavailable", + "partial", +] as const satisfies readonly ActivityValuation["status"][]; + +const ASSET_KINDS = [ + "assets", + "shares", + "value", + "collateral", + "yield", +] as const satisfies readonly ActivityAssetKind[]; + +const TX_HASH_PATTERN = /^0x[0-9a-fA-F]{64}$/; +const DECIMAL_INTEGER_PATTERN = /^\d+$/; +const RFC3339_TIMESTAMP_PATTERN = + /^(\d{4})-(\d{2})-(\d{2})[Tt](\d{2}):(\d{2}):(\d{2})(?:\.\d+)?(?:[Zz]|[+-](\d{2}):(\d{2}))$/; + +export const ACTIVITY_CATEGORIES = [ + { value: "lending", label: "Lending" }, + { value: "borrowing", label: "Borrowing" }, + { value: "swaps", label: "Swaps" }, + { value: "liquidations", label: "Liquidations" }, + { value: "account", label: "Account" }, + { value: "rewards", label: "Rewards" }, + { value: "governance", label: "Governance" }, +] as const satisfies readonly ActivityCategoryOption[]; + +export class ActivityResponseValidationError extends Error { + readonly code = "INVALID_ACTIVITY_RESPONSE"; + + constructor( + message: string, + readonly path: string, + ) { + super(`Invalid activity response at ${path}: ${message}`); + this.name = "ActivityResponseValidationError"; + } +} + +const fail = (path: string, message: string): never => { + throw new ActivityResponseValidationError(message, path); +}; + +const isRecord = (value: unknown): value is Record => + typeof value === "object" && value !== null && !Array.isArray(value); + +const readRecord = (value: unknown, path: string): Record => { + if (!isRecord(value)) fail(path, "expected an object"); + return value as Record; +}; + +const readString = (value: unknown, path: string): string => { + if (typeof value !== "string" || value.trim().length === 0) { + fail(path, "expected a non-empty string"); + } + return value as string; +}; + +const readOptionalString = ( + value: unknown, + path: string, +): string | undefined => + value === undefined ? undefined : readString(value, path); + +const readTimestamp = (value: unknown, path: string): string => { + const timestamp = readString(value, path); + const match = + RFC3339_TIMESTAMP_PATTERN.exec(timestamp) ?? + fail(path, "expected an RFC 3339 timestamp"); + + const [ + , + yearText, + monthText, + dayText, + hourText, + minuteText, + secondText, + offsetHourText, + offsetMinuteText, + ] = match; + const year = Number(yearText); + const month = Number(monthText); + const day = Number(dayText); + const hour = Number(hourText); + const minute = Number(minuteText); + const second = Number(secondText); + const offsetHour = offsetHourText === undefined ? 0 : Number(offsetHourText); + const offsetMinute = + offsetMinuteText === undefined ? 0 : Number(offsetMinuteText); + const leapYear = year % 4 === 0 && (year % 100 !== 0 || year % 400 === 0); + const daysInMonth = [ + 31, + leapYear ? 29 : 28, + 31, + 30, + 31, + 30, + 31, + 31, + 30, + 31, + 30, + 31, + ][month - 1]; + if ( + daysInMonth === undefined || + day < 1 || + day > daysInMonth || + hour > 23 || + minute > 59 || + second > 59 || + offsetHour > 23 || + offsetMinute > 59 || + !Number.isFinite(Date.parse(timestamp)) + ) { + fail(path, "expected an RFC 3339 timestamp"); + } + return timestamp; +}; + +const readDecimalString = (value: unknown, path: string): string => { + const decimal = readString(value, path); + if (!DECIMAL_INTEGER_PATTERN.test(decimal)) { + fail(path, "expected a non-negative decimal integer string"); + } + return decimal; +}; + +const readPositiveInteger = (value: unknown, path: string): number => { + if (typeof value !== "number" || !Number.isSafeInteger(value) || value <= 0) { + fail(path, "expected a positive safe integer"); + } + return value as number; +}; + +const readNonNegativeInteger = (value: unknown, path: string): number => { + if (typeof value !== "number" || !Number.isSafeInteger(value) || value < 0) { + fail(path, "expected a non-negative safe integer"); + } + return value as number; +}; + +const readAddress = (value: unknown, path: string): Address => { + const address = readString(value, path); + if (!isAddress(address)) fail(path, "expected an EVM address"); + return getAddress(address) as Address; +}; + +const readOptionalAddress = ( + value: unknown, + path: string, +): Address | undefined => + value === undefined ? undefined : readAddress(value, path); + +const readTxHash = (value: unknown, path: string): Hex => { + const txHash = readString(value, path); + if (!TX_HASH_PATTERN.test(txHash)) { + fail(path, "expected a 32-byte transaction hash"); + } + return txHash.toLowerCase() as Hex; +}; + +const readEnum = ( + value: unknown, + allowed: readonly T[], + path: string, +): T => { + if (typeof value !== "string" || !allowed.includes(value as T)) { + fail(path, `expected one of: ${allowed.join(", ")}`); + } + return value as T; +}; + +const readCategoryList = (value: unknown, path: string): ActivityCategory[] => { + if (!Array.isArray(value)) fail(path, "expected an array"); + const categories = (value as unknown[]).map((entry, index) => + readEnum(entry, ACTIVITY_CATEGORY_VALUES, `${path}[${index}]`), + ); + const seen = new Set(); + for (const [index, category] of categories.entries()) { + if (seen.has(category)) { + fail(`${path}[${index}]`, `contains duplicate category ${category}`); + } + seen.add(category); + } + return categories; +}; + +const readAsset = (value: unknown, path: string): ActivityAssetAmount => { + const record = readRecord(value, path); + const address = readOptionalAddress(record.address, `${path}.address`); + const symbol = readOptionalString(record.symbol, `${path}.symbol`); + const decimals = + record.decimals === undefined + ? undefined + : readNonNegativeInteger(record.decimals, `${path}.decimals`); + if (decimals !== undefined && decimals > 255) { + fail(`${path}.decimals`, "expected an integer no greater than 255"); + } + const amountRaw = readString(record.amountRaw, `${path}.amountRaw`); + if (!DECIMAL_INTEGER_PATTERN.test(amountRaw)) { + fail(`${path}.amountRaw`, "expected a non-negative decimal integer string"); + } + const amount = readOptionalString(record.amount, `${path}.amount`); + const amountUsd = readOptionalString(record.amountUsd, `${path}.amountUsd`); + + return { + kind: readEnum(record.kind, ASSET_KINDS, `${path}.kind`), + amountRaw, + ...(address !== undefined ? { address } : {}), + ...(symbol !== undefined ? { symbol } : {}), + ...(decimals !== undefined ? { decimals } : {}), + ...(amount !== undefined ? { amount } : {}), + ...(amountUsd !== undefined ? { amountUsd } : {}), + }; +}; + +const readChangeValue = (value: unknown, path: string): ActivityChangeValue => { + if (value === null) return value; + if (Array.isArray(value)) { + if (!value.every((entry) => typeof entry === "string")) { + fail(path, "expected an array of strings"); + } + return [...value] as string[]; + } + if ( + typeof value !== "string" && + typeof value !== "number" && + typeof value !== "boolean" + ) { + fail(path, "expected a scalar, string array, or null"); + } + if (typeof value === "number" && !Number.isFinite(value)) { + fail(path, "expected a finite number"); + } + return value as string | number | boolean; +}; + +const readChange = (value: unknown, path: string): ActivityValueChange => { + const record = readRecord(value, path); + const fieldsRecord = readRecord(record.fields, `${path}.fields`); + const fields = Object.fromEntries( + Object.entries(fieldsRecord).map(([field, entry]) => [ + field, + readChangeValue(entry, `${path}.fields.${field}`), + ]), + ); + return { fields }; +}; + +const readValuation = (value: unknown, path: string): ActivityValuation => { + const record = readRecord(value, path); + const amountUsd = readOptionalString(record.amountUsd, `${path}.amountUsd`); + const priceTimestamp = + record.priceTimestamp === undefined + ? undefined + : readTimestamp(record.priceTimestamp, `${path}.priceTimestamp`); + const source = readOptionalString(record.source, `${path}.source`); + const reason = readOptionalString(record.reason, `${path}.reason`); + return { + status: readEnum(record.status, VALUATION_STATUSES, `${path}.status`), + ...(amountUsd !== undefined ? { amountUsd } : {}), + ...(priceTimestamp !== undefined ? { priceTimestamp } : {}), + ...(source !== undefined ? { source } : {}), + ...(reason !== undefined ? { reason } : {}), + }; +}; + +const readChainCoverage = ( + value: unknown, + path: string, +): ActivityChainCoverage => { + const record = readRecord(value, path); + const indexedFromBlock = + record.indexedFromBlock === undefined + ? undefined + : readDecimalString(record.indexedFromBlock, `${path}.indexedFromBlock`); + const indexedToBlock = + record.indexedToBlock === undefined + ? undefined + : readDecimalString(record.indexedToBlock, `${path}.indexedToBlock`); + const missingCategories = readCategoryList( + record.missingCategories, + `${path}.missingCategories`, + ); + const reason = readOptionalString(record.reason, `${path}.reason`); + const status = readEnum( + record.status, + ACTIVITY_COVERAGE_STATUSES, + `${path}.status`, + ); + if ( + indexedFromBlock !== undefined && + indexedToBlock !== undefined && + BigInt(indexedFromBlock) > BigInt(indexedToBlock) + ) { + fail( + `${path}.indexedToBlock`, + "expected indexedToBlock to be at or after indexedFromBlock", + ); + } + if (status === "complete" && missingCategories.length > 0) { + fail( + `${path}.missingCategories`, + "expected no missing categories when chain coverage is complete", + ); + } + + return { + chainId: readPositiveInteger(record.chainId, `${path}.chainId`), + status, + ...(indexedFromBlock !== undefined ? { indexedFromBlock } : {}), + ...(indexedToBlock !== undefined ? { indexedToBlock } : {}), + missingCategories, + ...(reason !== undefined ? { reason } : {}), + }; +}; + +const readCoverage = (value: unknown, path: string): ActivityCoverage => { + const record = readRecord(value, path); + if (!Array.isArray(record.chains)) + fail(`${path}.chains`, "expected an array"); + const chains = (record.chains as unknown[]).map((entry, index) => + readChainCoverage(entry, `${path}.chains[${index}]`), + ); + const seenChainIds = new Set(); + for (const chain of chains) { + if (seenChainIds.has(chain.chainId)) { + fail(`${path}.chains`, `contains duplicate chain ${chain.chainId}`); + } + seenChainIds.add(chain.chainId); + } + const missingCategories = readCategoryList( + record.missingCategories, + `${path}.missingCategories`, + ); + const reason = readOptionalString(record.reason, `${path}.reason`); + const status = readEnum( + record.status, + ACTIVITY_COVERAGE_STATUSES, + `${path}.status`, + ); + const expectedStatus: ActivityCoverageStatus = + chains.length === 0 || + chains.every((chain) => chain.status === "unsupported") + ? "unsupported" + : chains.some( + (chain) => + chain.status === "partial" || chain.status === "unsupported", + ) + ? "partial" + : chains.some((chain) => chain.status === "syncing") + ? "syncing" + : "complete"; + if (status !== expectedStatus) { + fail( + `${path}.status`, + `expected ${expectedStatus} for the reported chain coverage`, + ); + } + if (status === "complete" && missingCategories.length > 0) { + fail( + `${path}.missingCategories`, + "expected no missing categories when aggregate coverage is complete", + ); + } + + return { + status, + chains, + missingCategories, + ...(reason !== undefined ? { reason } : {}), + }; +}; + +export const normalizeActivityEvent = ( + raw: unknown, + path = "$.data[]", +): ActivityEvent => { + const record = readRecord(raw, path); + const type = readString(record.type, `${path}.type`); + const rawType = + record.rawType === undefined + ? type + : readString(record.rawType, `${path}.rawType`); + const label = readOptionalString(record.label, `${path}.label`); + const owner = readOptionalAddress(record.owner, `${path}.owner`); + const account = readOptionalAddress(record.account, `${path}.account`); + const subAccountIndex = + record.subAccountIndex === undefined + ? undefined + : readNonNegativeInteger( + record.subAccountIndex, + `${path}.subAccountIndex`, + ); + if (subAccountIndex !== undefined && subAccountIndex > 255) { + fail(`${path}.subAccountIndex`, "expected an integer no greater than 255"); + } + const vault = readOptionalAddress(record.vault, `${path}.vault`); + const vaultType = + record.vaultType === undefined + ? undefined + : readEnum(record.vaultType, ACTIVITY_VAULT_TYPES, `${path}.vaultType`); + const actor = readOptionalAddress(record.actor, `${path}.actor`); + const counterparty = readOptionalAddress( + record.counterparty, + `${path}.counterparty`, + ); + const assets = + record.assets === undefined + ? undefined + : Array.isArray(record.assets) + ? record.assets.map((entry, index) => + readAsset(entry, `${path}.assets[${index}]`), + ) + : fail(`${path}.assets`, "expected an array"); + const change = + record.change === undefined + ? undefined + : readChange(record.change, `${path}.change`); + const valuation = + record.valuation === undefined + ? undefined + : readValuation(record.valuation, `${path}.valuation`); + const groupId = readOptionalString(record.groupId, `${path}.groupId`); + const payload = + record.payload === undefined + ? {} + : readRecord(record.payload, `${path}.payload`); + + return { + id: readString(record.id, `${path}.id`), + chainId: readPositiveInteger(record.chainId, `${path}.chainId`), + type, + rawType, + category: readEnum( + record.category, + ACTIVITY_CATEGORY_VALUES, + `${path}.category`, + ), + timestamp: readTimestamp(record.timestamp, `${path}.timestamp`), + blockNumber: readDecimalString(record.blockNumber, `${path}.blockNumber`), + logIndex: readNonNegativeInteger(record.logIndex, `${path}.logIndex`), + txHash: readTxHash(record.txHash, `${path}.txHash`), + source: readString(record.source, `${path}.source`), + payload, + ...(label !== undefined ? { label } : {}), + ...(owner !== undefined ? { owner } : {}), + ...(account !== undefined ? { account } : {}), + ...(subAccountIndex !== undefined ? { subAccountIndex } : {}), + ...(vault !== undefined ? { vault } : {}), + ...(vaultType !== undefined ? { vaultType } : {}), + ...(actor !== undefined ? { actor } : {}), + ...(counterparty !== undefined ? { counterparty } : {}), + ...(assets !== undefined ? { assets } : {}), + ...(change !== undefined ? { change } : {}), + ...(valuation !== undefined ? { valuation } : {}), + ...(groupId !== undefined ? { groupId } : {}), + }; +}; + +const readMeta = (value: unknown, path: string): ActivityEventsMeta => { + const record = readRecord(value, path); + if (typeof record.hasMore !== "boolean") { + fail(`${path}.hasMore`, "expected a boolean"); + } + const hasMore = record.hasMore as boolean; + if (!("nextCursor" in record)) { + fail(`${path}.nextCursor`, "expected a string or null"); + } + const nextCursor = + record.nextCursor === null + ? null + : readString(record.nextCursor, `${path}.nextCursor`); + if (hasMore && nextCursor === null) { + fail(`${path}.nextCursor`, "expected a cursor when hasMore is true"); + } + if (!hasMore && nextCursor !== null) { + fail(`${path}.nextCursor`, "expected null when hasMore is false"); + } + const limit = + record.limit === undefined + ? undefined + : readPositiveInteger(record.limit, `${path}.limit`); + const timestamp = readTimestamp(record.timestamp, `${path}.timestamp`); + + return { + hasMore, + nextCursor, + source: readString(record.source, `${path}.source`), + coverage: readCoverage(record.coverage, `${path}.coverage`), + ...(limit !== undefined ? { limit } : {}), + timestamp, + }; +}; + +export const normalizeActivityEventsResponse = ( + raw: unknown, +): ActivityEventsPage => { + let parsed = raw; + if (typeof raw === "string") { + try { + parsed = JSON.parse(raw) as unknown; + } catch { + fail("$", "expected valid JSON"); + } + } + const response = readRecord(parsed, "$"); + if (!Array.isArray(response.data)) fail("$.data", "expected an array"); + const data = (response.data as unknown[]).map((event, index) => + normalizeActivityEvent(event, `$.data[${index}]`), + ); + const seenIds = new Set(); + for (const [index, event] of data.entries()) { + if (seenIds.has(event.id)) { + fail(`$.data[${index}].id`, `contains duplicate event id ${event.id}`); + } + seenIds.add(event.id); + } + const meta = readMeta(response.meta, "$.meta"); + for (const [index, event] of data.entries()) { + if (event.source !== meta.source) { + fail( + `$.data[${index}].source`, + `expected the response source ${meta.source}`, + ); + } + } + if (meta.coverage.status === "unsupported" && data.length > 0) { + fail("$.data", "expected no events when coverage is unsupported"); + } + return { data, meta }; +}; + +type ActivityResponseRequest = + | { + kind: "account"; + chainIds: readonly number[]; + owner: Address; + categories?: readonly ActivityCategory[]; + eventTypes?: readonly string[]; + } + | { + kind: "vault"; + chainIds: readonly [number]; + vault: Address; + vaultType: ActivityVaultType; + categories?: readonly ActivityCategory[]; + eventTypes?: readonly string[]; + }; + +const sortedUniqueNumbers = (values: readonly number[]): number[] => + [...new Set(values)].sort((left, right) => left - right); + +const sameNumberList = (left: readonly number[], right: readonly number[]) => + left.length === right.length && + left.every((value, index) => value === right[index]); + +const validateMissingCategoriesForRequest = ( + categories: readonly ActivityCategory[] | undefined, + path: string, + requestedCategories: ReadonlySet | undefined, +): void => { + if (!categories || !requestedCategories) return; + for (const [index, category] of categories.entries()) { + if (!requestedCategories.has(category)) { + fail( + `${path}[${index}]`, + `category ${category} was not included in the request filter`, + ); + } + } +}; + +const validateActivityEventsPageForRequest = ( + page: ActivityEventsPage, + request: ActivityResponseRequest, +): ActivityEventsPage => { + const requestedChainIds = sortedUniqueNumbers(request.chainIds); + const coveredChainIds = sortedUniqueNumbers( + page.meta.coverage.chains.map((chain) => chain.chainId), + ); + if (!sameNumberList(requestedChainIds, coveredChainIds)) { + fail( + "$.meta.coverage.chains", + `expected exactly the requested chain ids: ${requestedChainIds.join(", ")}`, + ); + } + + const requestedCategories = request.categories + ? new Set(request.categories) + : undefined; + const requestedEventTypes = request.eventTypes + ? new Set( + request.eventTypes.map((eventType) => eventType.trim().toLowerCase()), + ) + : undefined; + validateMissingCategoriesForRequest( + page.meta.coverage.missingCategories, + "$.meta.coverage.missingCategories", + requestedCategories, + ); + const coverageByChain = new Map( + page.meta.coverage.chains.map((chain, index) => { + validateMissingCategoriesForRequest( + chain.missingCategories, + `$.meta.coverage.chains[${index}].missingCategories`, + requestedCategories, + ); + return [chain.chainId, chain] as const; + }), + ); + + for (const [index, event] of page.data.entries()) { + const path = `$.data[${index}]`; + if (!requestedChainIds.includes(event.chainId)) { + fail(`${path}.chainId`, `chain ${event.chainId} was not requested`); + } + if (coverageByChain.get(event.chainId)?.status === "unsupported") { + fail( + `${path}.chainId`, + `chain ${event.chainId} is reported as unsupported`, + ); + } + if (requestedCategories && !requestedCategories.has(event.category)) { + fail( + `${path}.category`, + `category ${event.category} was not included in the request filter`, + ); + } + if (requestedEventTypes && !requestedEventTypes.has(event.type)) { + fail( + `${path}.type`, + `event type ${event.type} was not included in the request filter`, + ); + } + + if (request.kind === "account") { + if (event.owner !== request.owner) { + fail(`${path}.owner`, "expected the requested owner"); + } + if (event.account !== undefined) { + const owner = request.owner.toLowerCase(); + const account = event.account.toLowerCase(); + if (!account.startsWith(owner.slice(0, 40))) { + fail(`${path}.account`, "expected an account in the owner's family"); + } + const derivedSubAccountIndex = + Number.parseInt(owner.slice(-2), 16) ^ + Number.parseInt(account.slice(-2), 16); + if (event.subAccountIndex !== derivedSubAccountIndex) { + fail( + `${path}.subAccountIndex`, + `expected ${derivedSubAccountIndex} for the reported account`, + ); + } + } + continue; + } + + if (event.vault !== request.vault) { + fail(`${path}.vault`, "expected the requested vault"); + } + if (event.vaultType !== request.vaultType) { + fail(`${path}.vaultType`, "expected the requested vault type"); + } + } + + return page; +}; + +export const validateAccountActivityEventsPage = ( + page: ActivityEventsPage, + args: FetchAccountActivityEventsArgs, +): ActivityEventsPage => + validateActivityEventsPageForRequest(page, { + kind: "account", + chainIds: Array.isArray(args.chainId) ? args.chainId : [args.chainId], + owner: getAddress(args.owner) as Address, + ...(args.categories !== undefined ? { categories: args.categories } : {}), + ...(args.eventTypes !== undefined ? { eventTypes: args.eventTypes } : {}), + }); + +export const validateVaultActivityEventsPage = ( + page: ActivityEventsPage, + args: FetchVaultActivityEventsArgs, +): ActivityEventsPage => + validateActivityEventsPageForRequest(page, { + kind: "vault", + chainIds: [args.chainId], + vault: getAddress(args.vault) as Address, + vaultType: args.vaultType, + ...(args.categories !== undefined ? { categories: args.categories } : {}), + ...(args.eventTypes !== undefined ? { eventTypes: args.eventTypes } : {}), + }); + +const readPayloadAddress = ( + event: Pick, + keys: string[], +): Address | undefined => { + for (const key of keys) { + const value = event.payload[key]; + if (typeof value === "string" && isAddress(value)) { + return getAddress(value) as Address; + } + } + return undefined; +}; + +export const getActivityPayloadString = ( + event: Pick, + keys: string[], +): string | undefined => { + for (const key of keys) { + const value = event.payload[key]; + if (typeof value === "string" && value.trim().length > 0) return value; + } + return undefined; +}; + +export const getActivityTargetContract = ( + event: Pick, +): Address | undefined => + event.vault ?? + readPayloadAddress(event, [ + "target_contract", + "targetContract", + "vault", + "vault_address", + "vaultAddress", + ]); + +export const getActivityAccount = ( + event: Pick, +): Address | undefined => + event.account ?? + readPayloadAddress(event, [ + "on_behalf_of_account", + "onBehalfOfAccount", + "account", + "sub_account", + "subAccount", + ]); + +export const getActivityCaller = ( + event: Pick, +): Address | undefined => + event.actor ?? readPayloadAddress(event, ["caller", "sender", "owner"]); diff --git a/packages/euler-v2-sdk/src/services/activityService/activityService.ts b/packages/euler-v2-sdk/src/services/activityService/activityService.ts index 0f2d6c33..0827a55d 100644 --- a/packages/euler-v2-sdk/src/services/activityService/activityService.ts +++ b/packages/euler-v2-sdk/src/services/activityService/activityService.ts @@ -1,227 +1,154 @@ -import { getAddress, isAddress, type Address } from "viem"; -import { type BuildQueryFn, applyBuildQuery } from "../../utils/buildQuery.js"; +import { getAddress } from "viem"; +import { + applyBuildQuery, + type BuildQueryFn, + normalizeQueryKeySet, + serializeQueryArgs, +} from "../../utils/buildQuery.js"; import type { - ActivityCategory, - ActivityCategoryOption, - ActivityEvent, - ActivityEventsMeta, + ActivityCapabilities, + ActivityCapabilityUnavailableReason, ActivityEventsPage, - ActivityEventsQuery, + ActivityScope, + ActivityScopeSupport, ActivityServiceConfig, FetchAccountActivityEventsArgs, FetchVaultActivityEventsArgs, + IActivityAdapter, IActivityService, } from "./activityServiceTypes.js"; - -type RawActivityResponse = { - data?: unknown[]; - meta?: ActivityEventsMeta; -}; - -const SELECTOR_INFO: Record< - string, - { label: string; category: ActivityCategory } -> = { - // ERC4626 share operations used by EVK and Earn vaults. - "0x6e553f65": { label: "Deposit", category: "lending" }, - "0x94bf804d": { label: "Mint shares", category: "lending" }, - "0xb460af94": { label: "Withdraw", category: "lending" }, - "0xba087652": { label: "Redeem shares", category: "lending" }, - // Common EVC/account operations. - "0x2b67b570": { label: "Permit approval", category: "account" }, - "0x3f8a17e2": { label: "Enable collateral", category: "account" }, - "0x1d5a6eb6": { label: "Disable collateral", category: "account" }, - "0x92b7d2bb": { label: "Enable controller", category: "account" }, - "0xa789fe84": { label: "Disable controller", category: "account" }, -}; - -export const ACTIVITY_CATEGORIES: ActivityCategoryOption[] = [ - { value: "lending", label: "Lending" }, - { value: "borrowing", label: "Borrowing" }, - { value: "swaps", label: "Swaps" }, - { value: "liquidations", label: "Liquidations" }, - { value: "account", label: "Account" }, - { value: "rewards", label: "Rewards" }, -]; - -const getString = (value: unknown): string | undefined => - typeof value === "string" && value.trim() ? value : undefined; - -export const getActivityPayloadString = ( - event: Pick, - keys: string[], -): string | undefined => { - for (const key of keys) { - const value = getString(event.payload[key]); - if (value) return value; +import { ActivityV3Adapter } from "./adapters/activityV3Adapter.js"; + +const isActivityAdapter = ( + value: IActivityAdapter | ActivityServiceConfig, +): value is IActivityAdapter => + "getCapabilities" in value && + typeof value.getCapabilities === "function" && + "getScopeSupport" in value && + typeof value.getScopeSupport === "function" && + "fetchAccountActivityEvents" in value && + typeof value.fetchAccountActivityEvents === "function" && + "fetchVaultActivityEvents" in value && + typeof value.fetchVaultActivityEvents === "function"; + +export class ActivityUnavailableError extends Error { + readonly code = "ACTIVITY_UNAVAILABLE"; + + constructor(readonly reason: ActivityCapabilityUnavailableReason) { + super(`Activity is unavailable: ${reason}`); + this.name = "ActivityUnavailableError"; } - return undefined; -}; - -const normalizeSelector = ( - payload: Record, -): string | undefined => getString(payload.selector)?.toLowerCase(); - -const titleize = (value: string): string => { - const cleaned = value - .replace(/[_-]+/g, " ") - .replace(/([a-z])([A-Z])/g, "$1 $2") - .trim(); - if (!cleaned) return "Activity"; - return cleaned.charAt(0).toUpperCase() + cleaned.slice(1); -}; - -const inferCategory = ( - type: string, - payload: Record, -): ActivityCategory => { - const selector = normalizeSelector(payload); - const selectorInfo = selector ? SELECTOR_INFO[selector] : undefined; - if (selectorInfo) return selectorInfo.category; +} - const haystack = [ - type, - getString(payload.event), - getString(payload.kind), - getString(payload.action), - getString(payload.operation), - getString(payload.selector_name), - getString(payload.method), - ] - .filter(Boolean) - .join(" ") - .toLowerCase(); +export class UnavailableActivityAdapter implements IActivityAdapter { + constructor(private readonly reason: ActivityCapabilityUnavailableReason) {} - if (/reward|claim|merkl|reul|lock|unlock/.test(haystack)) return "rewards"; - if (/liquidat|violator|liquidator/.test(haystack)) return "liquidations"; - if (/swap|multiply|cow/.test(haystack)) return "swaps"; - if (/borrow|repay|debt/.test(haystack)) return "borrowing"; - if (/deposit|withdraw|mint|redeem|supply|share/.test(haystack)) { - return "lending"; + getCapabilities(): ActivityCapabilities { + return { + configured: false, + adapter: null, + canQueryAccount: false, + requestableVaultTypes: [], + reason: this.reason, + }; } - return "account"; -}; - -const inferLabel = (type: string, payload: Record): string => { - const selector = normalizeSelector(payload); - const selectorInfo = selector ? SELECTOR_INFO[selector] : undefined; - if (selectorInfo) return selectorInfo.label; - return ( - getString(payload.label) ?? - getString(payload.event) ?? - getString(payload.action) ?? - getString(payload.operation) ?? - titleize(type) - ); -}; - -const normalizeAddressValue = (value: unknown): Address | undefined => { - const maybeAddress = getString(value); - if (!maybeAddress || !isAddress(maybeAddress)) return undefined; - return getAddress(maybeAddress) as Address; -}; - -export const getActivityTargetContract = ( - event: Pick, -): Address | undefined => - normalizeAddressValue( - event.payload.target_contract ?? - event.payload.targetContract ?? - event.payload.vault ?? - event.payload.vault_address ?? - event.payload.vaultAddress, - ); - -export const getActivityAccount = ( - event: Pick, -): Address | undefined => - normalizeAddressValue( - event.payload.on_behalf_of_account ?? - event.payload.onBehalfOfAccount ?? - event.payload.account ?? - event.payload.sub_account ?? - event.payload.subAccount, - ); - -export const getActivityCaller = ( - event: Pick, -): Address | undefined => - normalizeAddressValue( - event.payload.caller ?? event.payload.sender ?? event.payload.owner, - ); - -export const normalizeActivityEvent = (raw: unknown): ActivityEvent | null => { - if (!raw || typeof raw !== "object") return null; - const record = raw as Record; - const type = getString(record.type) ?? "activity"; - const payload = - record.payload && typeof record.payload === "object" - ? (record.payload as Record) - : {}; - - const chainId = Number(record.chainId); - const timestamp = getString(record.timestamp) ?? getString(record.createdAt); - if (!Number.isFinite(chainId) || !timestamp) return null; - - const blockNumber = getString(record.blockNumber) ?? getString(record.block); - const txHash = getString(record.txHash) ?? getString(record.transactionHash); + getScopeSupport(_scope: ActivityScope): ActivityScopeSupport { + return "unsupported"; + } - return { - chainId, - type, - timestamp, - ...(blockNumber ? { blockNumber } : {}), - ...(txHash ? { txHash } : {}), - payload, - category: inferCategory(type, payload), - label: inferLabel(type, payload), - }; -}; + async fetchAccountActivityEvents( + _args: FetchAccountActivityEventsArgs, + ): Promise { + throw new ActivityUnavailableError(this.reason); + } -export const normalizeActivityEventsResponse = ( - raw: unknown, -): ActivityEventsPage => { - const parsed = - typeof raw === "string" ? (JSON.parse(raw) as RawActivityResponse) : raw; - const response = (parsed ?? {}) as RawActivityResponse; - return { - data: (response.data ?? []) - .map(normalizeActivityEvent) - .filter((event): event is ActivityEvent => Boolean(event)), - ...(response.meta ? { meta: response.meta } : {}), - }; -}; + async fetchVaultActivityEvents( + _args: FetchVaultActivityEventsArgs, + ): Promise { + throw new ActivityUnavailableError(this.reason); + } +} export class ActivityService implements IActivityService { - private readonly endpoint: string; - private readonly apiKey?: string; - - constructor(config: ActivityServiceConfig, buildQuery?: BuildQueryFn) { - this.endpoint = config.endpoint; - this.apiKey = config.apiKey; + private adapter: IActivityAdapter; + + constructor( + adapterOrConfig: IActivityAdapter | ActivityServiceConfig, + buildQuery?: BuildQueryFn, + ) { + this.adapter = isActivityAdapter(adapterOrConfig) + ? adapterOrConfig + : new ActivityV3Adapter(adapterOrConfig); if (buildQuery) applyBuildQuery(this, buildQuery); } + getCapabilities(): ActivityCapabilities { + return this.adapter.getCapabilities(); + } + + getScopeSupport(scope: ActivityScope): ActivityScopeSupport { + return this.adapter.getScopeSupport(scope); + } + queryAccountActivityEvents = async ( args: FetchAccountActivityEventsArgs, - ): Promise => { - const url = this.buildEventsUrl( - `/v3/evc/accounts/${getAddress(args.account)}/events`, - args, - ); - return this.fetchEvents(url); - }; + ): Promise => + this.adapter.fetchAccountActivityEvents(args); + + getQueryKeyAccountActivityEvents( + args: FetchAccountActivityEventsArgs, + ): string | null { + const chainIds = Array.isArray(args.chainId) + ? [...args.chainId] + : [args.chainId]; + return serializeQueryArgs([ + { + ...args, + owner: getAddress(args.owner), + chainId: normalizeQueryKeySet(chainIds), + categories: + args.categories === undefined + ? undefined + : normalizeQueryKeySet([...args.categories]), + eventTypes: + args.eventTypes === undefined + ? undefined + : normalizeQueryKeySet( + args.eventTypes.map((eventType) => + eventType.trim().toLowerCase(), + ), + ), + }, + ]); + } queryVaultActivityEvents = async ( args: FetchVaultActivityEventsArgs, - ): Promise => { - const url = this.buildEventsUrl( - `/v3/evk/vaults/${args.chainId}/${getAddress(args.vault)}/events`, - args, - ); - return this.fetchEvents(url); - }; + ): Promise => this.adapter.fetchVaultActivityEvents(args); + + getQueryKeyVaultActivityEvents( + args: FetchVaultActivityEventsArgs, + ): string | null { + return serializeQueryArgs([ + { + ...args, + vault: getAddress(args.vault), + categories: + args.categories === undefined + ? undefined + : normalizeQueryKeySet([...args.categories]), + eventTypes: + args.eventTypes === undefined + ? undefined + : normalizeQueryKeySet( + args.eventTypes.map((eventType) => + eventType.trim().toLowerCase(), + ), + ), + }, + ]); + } async fetchAccountActivityEvents( args: FetchAccountActivityEventsArgs, @@ -234,44 +161,4 @@ export class ActivityService implements IActivityService { ): Promise { return this.queryVaultActivityEvents(args); } - - private async fetchEvents(url: string): Promise { - const response = await fetch(url, { - method: "GET", - headers: this.getHeaders(), - }); - if (!response.ok) { - const body = await response.text().catch(() => ""); - throw new Error( - `ActivityService request failed (${response.status} ${response.statusText}): ${body.slice(0, 200)}`, - ); - } - return normalizeActivityEventsResponse(await response.json()); - } - - private getHeaders(): Record { - return { - Accept: "application/json", - ...(this.apiKey ? { "X-API-Key": this.apiKey } : {}), - }; - } - - private buildEventsUrl(path: string, args: ActivityEventsQuery): string { - const params = new URLSearchParams({ - chainId: String(args.chainId), - from: String(args.from), - to: String(args.to), - }); - if (args.type !== undefined) params.set("type", args.type); - if (args.offset !== undefined) params.set("offset", String(args.offset)); - if (args.limit !== undefined) params.set("limit", String(args.limit)); - - const normalizedEndpoint = this.endpoint.replace(/\/+$/, ""); - const url = - normalizedEndpoint.startsWith("http://") || - normalizedEndpoint.startsWith("https://") - ? new URL(path, `${normalizedEndpoint}/`).toString() - : `${normalizedEndpoint}${path}`; - return `${url}?${params.toString()}`; - } } diff --git a/packages/euler-v2-sdk/src/services/activityService/activityServiceTypes.ts b/packages/euler-v2-sdk/src/services/activityService/activityServiceTypes.ts index c2678abd..d61e22d2 100644 --- a/packages/euler-v2-sdk/src/services/activityService/activityServiceTypes.ts +++ b/packages/euler-v2-sdk/src/services/activityService/activityServiceTypes.ts @@ -1,4 +1,4 @@ -import type { Address } from "viem"; +import type { Address, Hex } from "viem"; export type ActivityCategory = | "lending" @@ -6,63 +6,179 @@ export type ActivityCategory = | "swaps" | "liquidations" | "account" - | "rewards"; + | "rewards" + | "governance"; + +export type ActivityVaultType = "evk" | "earn" | "securitize"; + +export type ActivityCoverageStatus = + | "complete" + | "partial" + | "unsupported" + | "syncing"; export interface ActivityCategoryOption { value: ActivityCategory; label: string; } +export type ActivityAssetKind = + | "assets" + | "shares" + | "value" + | "collateral" + | "yield"; + +export interface ActivityAssetAmount { + kind: ActivityAssetKind; + amountRaw: string; + address?: Address; + symbol?: string; + decimals?: number; + amount?: string; + amountUsd?: string; +} + +export type ActivityChangeValue = string | number | boolean | string[] | null; + +export interface ActivityChange { + fields: Record; +} + +export type ActivityValueChange = ActivityChange; + +export interface ActivityValuation { + status: "available" | "unavailable" | "partial"; + amountUsd?: string; + priceTimestamp?: string; + source?: string; + reason?: string; +} + export interface ActivityEvent { + /** Stable identifier supplied by the activity source. */ + id: string; chainId: number; + /** Event type supplied by the activity source. */ type: string; + /** Original indexed type when it differs from `type`; otherwise equal to `type`. */ + rawType: string; + category: ActivityCategory; timestamp: string; - blockNumber?: string; - txHash?: string; + blockNumber: string; + logIndex: number; + txHash: Hex; + source: string; payload: Record; - category: ActivityCategory; - label: string; + label?: string; + owner?: Address; + account?: Address; + subAccountIndex?: number; + vault?: Address; + vaultType?: ActivityVaultType; + actor?: Address; + counterparty?: Address; + assets?: ActivityAssetAmount[]; + change?: ActivityChange; + valuation?: ActivityValuation; + groupId?: string; +} + +export interface ActivityChainCoverage { + chainId: number; + status: ActivityCoverageStatus; + indexedFromBlock?: string; + indexedToBlock?: string; + missingCategories: ActivityCategory[]; + reason?: string; +} + +export interface ActivityCoverage { + status: ActivityCoverageStatus; + chains: ActivityChainCoverage[]; + missingCategories: ActivityCategory[]; + reason?: string; } export interface ActivityEventsMeta { - hasMore?: boolean; - offset?: number; + hasMore: boolean; + nextCursor: string | null; + source: string; + coverage: ActivityCoverage; limit?: number; - total?: number; - timestamp?: string; - chainId?: string | number; + timestamp: string; } export interface ActivityEventsPage { data: ActivityEvent[]; - meta?: ActivityEventsMeta; + meta: ActivityEventsMeta; } export interface ActivityEventsQuery { - chainId: number; - from: number; - to: number; - type?: string; - offset?: number; + /** Unix timestamp bounds. The backend applies its supported maximum window. */ + from?: number; + to?: number; + /** Normalized UI categories sent through the `category` server filter. */ + categories?: readonly ActivityCategory[]; + /** Normalized event `type` values sent through the `eventType` server filter. */ + eventTypes?: readonly string[]; + /** Opaque cursor returned by the backend. Maximum length is 2,048 characters. */ + cursor?: string; + /** Page size accepted by the backend, from 1 through 100. */ limit?: number; } export interface FetchAccountActivityEventsArgs extends ActivityEventsQuery { - account: Address; + owner: Address; + /** One chain ID or at most 20 unique chain IDs. */ + chainId: number | readonly number[]; } export interface FetchVaultActivityEventsArgs extends ActivityEventsQuery { vault: Address; + chainId: number; + vaultType: ActivityVaultType; } export interface ActivityServiceConfig { - /** V3 API endpoint URL. */ + /** V3 API endpoint. Existing endpoint path segments are preserved. */ endpoint: string; /** Optional API key sent as `X-API-Key` for V3 backend requests. */ apiKey?: string; } -export interface IActivityService { +export type ActivityCapabilityUnavailableReason = + | "v3-disabled" + | "source-not-configured"; + +export type ActivityScopeSupport = "supported" | "unsupported" | "unknown"; + +export type ActivityScope = + | { kind: "account"; chainId: number } + | { + kind: "vault"; + chainId: number; + vaultType: ActivityVaultType; + }; + +export interface ActivityCapabilities { + /** Whether an adapter is configured and can accept activity requests. */ + configured: boolean; + adapter: string | null; + /** Whether this adapter implements the account activity route shape. */ + canQueryAccount: boolean; + /** Vault route shapes this adapter can request. Runtime coverage is response metadata. */ + requestableVaultTypes: readonly ActivityVaultType[]; + reason?: ActivityCapabilityUnavailableReason; +} + +export interface IActivityAdapter { + getCapabilities(): ActivityCapabilities; + /** + * Reports authoritative scoped support when known. `unknown` means the + * request is valid but response coverage remains the source of truth. + */ + getScopeSupport(scope: ActivityScope): ActivityScopeSupport; fetchAccountActivityEvents( args: FetchAccountActivityEventsArgs, ): Promise; @@ -70,3 +186,5 @@ export interface IActivityService { args: FetchVaultActivityEventsArgs, ): Promise; } + +export interface IActivityService extends IActivityAdapter {} diff --git a/packages/euler-v2-sdk/src/services/activityService/adapters/activityV3Adapter.ts b/packages/euler-v2-sdk/src/services/activityService/adapters/activityV3Adapter.ts new file mode 100644 index 00000000..0b03b4d2 --- /dev/null +++ b/packages/euler-v2-sdk/src/services/activityService/adapters/activityV3Adapter.ts @@ -0,0 +1,223 @@ +import { getAddress } from "viem"; +import { + ACTIVITY_CATEGORY_VALUES, + ACTIVITY_VAULT_TYPES, + ActivityResponseValidationError, + normalizeActivityEventsResponse, + validateAccountActivityEventsPage, + validateVaultActivityEventsPage, +} from "../activityEvent.js"; +import type { + ActivityCapabilities, + ActivityEventsPage, + ActivityEventsQuery, + ActivityScope, + ActivityScopeSupport, + ActivityServiceConfig, + FetchAccountActivityEventsArgs, + FetchVaultActivityEventsArgs, + IActivityAdapter, +} from "../activityServiceTypes.js"; + +const ACTIVITY_V3_SOURCE = "v3-ponder"; +const MAX_ACTIVITY_CHAIN_IDS = 20; +const MAX_ACTIVITY_CURSOR_LENGTH = 2_048; +const MAX_ACTIVITY_LIMIT = 100; + +const uniqueSorted = (values: readonly T[]): T[] => + [...new Set(values)].sort((left, right) => + String(left).localeCompare(String(right)), + ); + +const assertPositiveInteger = (value: number, name: string): void => { + if (!Number.isSafeInteger(value) || value <= 0) { + throw new Error(`${name} must be a positive safe integer`); + } +}; + +const assertOptionalTimestamp = ( + value: number | undefined, + name: string, +): void => { + if ( + value !== undefined && + (!Number.isInteger(value) || value < 0 || !Number.isSafeInteger(value)) + ) { + throw new Error(`${name} must be a non-negative safe integer`); + } +}; + +const appendCommonQuery = ( + params: URLSearchParams, + args: ActivityEventsQuery, +): void => { + assertOptionalTimestamp(args.from, "from"); + assertOptionalTimestamp(args.to, "to"); + if (args.from !== undefined && args.to !== undefined && args.from > args.to) { + throw new Error("from must be less than or equal to to"); + } + if (args.from !== undefined) params.set("from", String(args.from)); + if (args.to !== undefined) params.set("to", String(args.to)); + + if (args.categories !== undefined) { + const categories = uniqueSorted(args.categories); + if ( + categories.length === 0 || + categories.some( + (category) => !ACTIVITY_CATEGORY_VALUES.includes(category), + ) + ) { + throw new Error("categories must contain supported category values"); + } + params.set("category", categories.join(",")); + } + + if (args.eventTypes !== undefined) { + const eventTypes = uniqueSorted( + args.eventTypes.map((eventType) => eventType.trim().toLowerCase()), + ); + if ( + eventTypes.length === 0 || + eventTypes.some((eventType) => !eventType || eventType.includes(",")) + ) { + throw new Error("eventTypes must contain non-empty, comma-free values"); + } + params.set("eventType", eventTypes.join(",")); + } + + if (args.cursor !== undefined) { + if (!args.cursor.trim()) throw new Error("cursor must not be empty"); + if (args.cursor.length > MAX_ACTIVITY_CURSOR_LENGTH) { + throw new Error( + `cursor must not exceed ${MAX_ACTIVITY_CURSOR_LENGTH} characters`, + ); + } + params.set("cursor", args.cursor); + } + if (args.limit !== undefined) { + assertPositiveInteger(args.limit, "limit"); + if (args.limit > MAX_ACTIVITY_LIMIT) { + throw new Error(`limit must not exceed ${MAX_ACTIVITY_LIMIT}`); + } + params.set("limit", String(args.limit)); + } +}; + +export const joinActivityEndpointPath = ( + endpoint: string, + path: string, +): string => { + const trimmedEndpoint = endpoint.trim(); + if (!trimmedEndpoint) + throw new Error("Activity V3 endpoint must not be empty"); + const pathSuffix = path.replace(/^\/+/, ""); + + if (/^https?:\/\//i.test(trimmedEndpoint)) { + const url = new URL(trimmedEndpoint); + url.pathname = `${url.pathname.replace(/\/+$/, "")}/${pathSuffix}`; + url.search = ""; + url.hash = ""; + return url.toString(); + } + + return `${trimmedEndpoint.replace(/\/+$/, "")}/${pathSuffix}`; +}; + +export class ActivityV3Adapter implements IActivityAdapter { + constructor(private readonly config: ActivityServiceConfig) { + if (!config.endpoint.trim()) { + throw new Error("Activity V3 endpoint must not be empty"); + } + } + + getCapabilities(): ActivityCapabilities { + return { + configured: true, + adapter: "v3", + canQueryAccount: true, + requestableVaultTypes: ["evk", "earn", "securitize"], + }; + } + + getScopeSupport(scope: ActivityScope): ActivityScopeSupport { + if ( + scope.kind === "vault" && + !ACTIVITY_VAULT_TYPES.includes(scope.vaultType) + ) { + return "unsupported"; + } + return "unknown"; + } + + async fetchAccountActivityEvents( + args: FetchAccountActivityEventsArgs, + ): Promise { + const chainIds = [ + ...new Set(Array.isArray(args.chainId) ? args.chainId : [args.chainId]), + ].sort((left, right) => left - right); + if (chainIds.length === 0) { + throw new Error("chainId must contain at least one chain"); + } + if (chainIds.length > MAX_ACTIVITY_CHAIN_IDS) { + throw new Error( + `chainId must not contain more than ${MAX_ACTIVITY_CHAIN_IDS} unique chains`, + ); + } + for (const chainId of chainIds) { + assertPositiveInteger(chainId, "chainId"); + } + + const params = new URLSearchParams({ chainId: chainIds.join(",") }); + appendCommonQuery(params, args); + const path = `/v3/activity/accounts/${getAddress(args.owner)}/events`; + const page = await this.fetchEvents(this.buildUrl(path, params)); + return validateAccountActivityEventsPage(page, args); + } + + async fetchVaultActivityEvents( + args: FetchVaultActivityEventsArgs, + ): Promise { + assertPositiveInteger(args.chainId, "chainId"); + if (!ACTIVITY_VAULT_TYPES.includes(args.vaultType)) { + throw new Error("vaultType must be evk, earn, or securitize"); + } + const params = new URLSearchParams({ vaultType: args.vaultType }); + appendCommonQuery(params, args); + const path = `/v3/activity/vaults/${args.chainId}/${getAddress(args.vault)}/events`; + const page = await this.fetchEvents(this.buildUrl(path, params)); + return validateVaultActivityEventsPage(page, args); + } + + private async fetchEvents(url: string): Promise { + const response = await fetch(url, { + method: "GET", + headers: this.getHeaders(), + }); + const body = await response.text(); + if (!response.ok) { + throw new Error( + `Activity V3 request failed (${response.status} ${response.statusText}): ${body.slice(0, 200)}`, + ); + } + + const page = normalizeActivityEventsResponse(body); + if (page.meta.source !== ACTIVITY_V3_SOURCE) { + throw new ActivityResponseValidationError( + `expected ${ACTIVITY_V3_SOURCE}; received ${page.meta.source}`, + "$.meta.source", + ); + } + return page; + } + + private getHeaders(): Record { + return { + Accept: "application/json", + ...(this.config.apiKey ? { "X-API-Key": this.config.apiKey } : {}), + }; + } + + private buildUrl(path: string, params: URLSearchParams): string { + return `${joinActivityEndpointPath(this.config.endpoint, path)}?${params.toString()}`; + } +} diff --git a/packages/euler-v2-sdk/src/services/activityService/index.ts b/packages/euler-v2-sdk/src/services/activityService/index.ts index 7c5aa594..5af9f0f7 100644 --- a/packages/euler-v2-sdk/src/services/activityService/index.ts +++ b/packages/euler-v2-sdk/src/services/activityService/index.ts @@ -1,22 +1,48 @@ export { ACTIVITY_CATEGORIES, - ActivityService, + ACTIVITY_CATEGORY_VALUES, + ACTIVITY_VAULT_TYPES, + ActivityResponseValidationError, getActivityAccount, getActivityCaller, getActivityPayloadString, getActivityTargetContract, normalizeActivityEvent, normalizeActivityEventsResponse, +} from "./activityEvent.js"; +export { + ActivityService, + ActivityUnavailableError, + UnavailableActivityAdapter, } from "./activityService.js"; export type { + ActivityAssetAmount, + ActivityAssetKind, + ActivityCapabilities, + ActivityCapabilityUnavailableReason, ActivityCategory, ActivityCategoryOption, + ActivityChainCoverage, + ActivityChange, + ActivityChangeValue, + ActivityCoverage, + ActivityCoverageStatus, ActivityEvent, ActivityEventsMeta, ActivityEventsPage, ActivityEventsQuery, + ActivityScope, + ActivityScopeSupport, ActivityServiceConfig, + ActivityValuation, + ActivityValueChange, + ActivityVaultType, FetchAccountActivityEventsArgs, FetchVaultActivityEventsArgs, + IActivityAdapter, IActivityService, } from "./activityServiceTypes.js"; +export { + ActivityV3Adapter, + joinActivityEndpointPath, +} from "./adapters/activityV3Adapter.js"; diff --git a/packages/euler-v2-sdk/test/activityService.test.ts b/packages/euler-v2-sdk/test/activityService.test.ts index 5c1bcff7..c2e4208e 100644 --- a/packages/euler-v2-sdk/test/activityService.test.ts +++ b/packages/euler-v2-sdk/test/activityService.test.ts @@ -1,22 +1,46 @@ import { afterEach, describe, expect, it, vi } from "vitest"; -import { getAddress } from "viem"; -import type { Address } from "viem"; +import { getAddress, type Address } from "viem"; import { buildEulerSDK } from "../src/sdk/buildSDK.js"; import { EulerSDK } from "../src/sdk/sdk.js"; import type { IDeploymentService } from "../src/services/deploymentService/index.js"; import { + ACTIVITY_CATEGORIES, + ActivityResponseValidationError, ActivityService, + ActivityUnavailableError, getActivityAccount, getActivityCaller, getActivityTargetContract, + joinActivityEndpointPath, normalizeActivityEvent, + normalizeActivityEventsResponse, + type ActivityEventsPage, + type IActivityAdapter, } from "../src/services/activityService/index.js"; +import { createQueryCacheBuildQuery } from "../src/utils/buildQuery.js"; -const ACCOUNT = getAddress( +const OWNER = getAddress( "0xee5b5c82a365d75e9f8a1e982687fb5b6ceb606c", ) as Address; -const VAULT = "0xD8b27CF359b7D15710a5BE299AF6e7Bf904984C2" as Address; -const CALLER = "0x0000000000000000000000000000000000000001" as Address; +const OTHER_OWNER = getAddress( + "0x00000000000000000000000000000000000000aa", +) as Address; +const ACCOUNT = getAddress( + "0xee5b5c82a365d75e9f8a1e982687fb5b6ceb606d", +) as Address; +const VAULT = getAddress( + "0xd8b27cf359b7d15710a5be299af6e7bf904984c2", +) as Address; +const OTHER_VAULT = getAddress( + "0x00000000000000000000000000000000000000bb", +) as Address; +const CALLER = getAddress( + "0x0000000000000000000000000000000000000001", +) as Address; +const TX_HASH = + "0x2162b1e4c31ccb11d1d84a08e517f3509d4ee74022f0a4670e4502b461b191f6"; +const EVENT_ID = "v3-ponder:1:deposit:1:tx:7"; +const RESPONSE_TIMESTAMP = "2026-07-13T10:00:01.000Z"; const deploymentService: IDeploymentService = { getDeploymentChainIds: () => [], @@ -26,147 +50,972 @@ const deploymentService: IDeploymentService = { addDeployment: () => {}, }; +const event = (overrides: Record = {}) => ({ + id: EVENT_ID, + chainId: 1, + type: "deposit", + category: "lending", + timestamp: "2026-07-13T10:00:00.000Z", + blockNumber: "22910000", + logIndex: 7, + txHash: TX_HASH, + source: "v3-ponder", + owner: OWNER, + account: ACCOUNT, + subAccountIndex: 1, + vault: VAULT, + vaultType: "evk", + actor: CALLER, + payload: { + target_contract: VAULT, + on_behalf_of_account: ACCOUNT, + caller: CALLER, + }, + ...overrides, +}); + +const page = ( + events: unknown[] = [event()], + metaOverrides: Record = {}, +) => { + const coverageOverride = + (metaOverrides.coverage as Record | undefined) ?? {}; + const rawChains = (coverageOverride.chains as unknown[] | undefined) ?? [ + { + chainId: 1, + status: "complete", + indexedFromBlock: "22000000", + indexedToBlock: "22910000", + }, + ]; + const chains = rawChains.map((chain) => ({ + missingCategories: [], + ...(chain as Record), + })); + const coverage = { + status: "complete", + missingCategories: [], + ...coverageOverride, + chains, + }; + + return { + data: events, + meta: { + hasMore: false, + nextCursor: null, + source: "v3-ponder", + timestamp: RESPONSE_TIMESTAMP, + ...metaOverrides, + coverage, + }, + }; +}; + +const stubActivityResponse = (body: unknown) => { + const fetchMock = vi.fn( + async () => new Response(JSON.stringify(body), { status: 200 }), + ); + vi.stubGlobal("fetch", fetchMock); + return fetchMock; +}; + afterEach(() => { vi.unstubAllGlobals(); + vi.unstubAllEnvs(); }); describe("ActivityService", () => { - it("fetches and normalizes account activity from a relative V3 endpoint", async () => { + it("fetches normalized owner activity with distinct category and event type filters", async () => { const requests: Array<{ url: string; headers: HeadersInit | undefined }> = []; vi.stubGlobal( "fetch", vi.fn(async (url: string, init?: RequestInit) => { requests.push({ url, headers: init?.headers }); return new Response( - JSON.stringify({ - data: [ - { - chainId: 1, - type: "call_with_context", - timestamp: "2024-09-04T15:04:35.000Z", - blockNumber: "20700000", - txHash: - "0x2162b1e4c31ccb11d1d84a08e517f3509d4ee74022f0a4670e4502b461b191f6", - payload: { - caller: CALLER, - selector: "0x6e553f65", - target_contract: VAULT, - on_behalf_of_account: ACCOUNT, - }, + JSON.stringify( + page([event()], { + coverage: { + status: "partial", + chains: [ + { + chainId: 1, + status: "complete", + indexedFromBlock: "22000000", + indexedToBlock: "22910000", + missingCategories: [], + }, + { + chainId: 10, + status: "unsupported", + missingCategories: ["account", "lending"], + reason: "Activity is unavailable for this scope", + }, + ], }, - ], - meta: { hasMore: false, offset: 0, limit: 25 }, - }), - { status: 200, headers: { "content-type": "application/json" } }, + }), + ), + { status: 200 }, ); }), ); const service = new ActivityService({ - endpoint: "/api", + endpoint: "/api/internal", apiKey: "secret", }); - const page = await service.fetchAccountActivityEvents({ - account: ACCOUNT, - chainId: 1, - from: 1725148800, - to: 1726358400, - offset: 0, + const result = await service.fetchAccountActivityEvents({ + owner: OWNER, + chainId: [10, 1, 10], + from: 1782864000, + to: 1783987200, + categories: ["lending", "account", "lending"], + eventTypes: [" Deposit ", "BORROW", "deposit"], + cursor: "opaque:cursor", limit: 25, }); expect(requests[0]?.url).toBe( - `/api/v3/evc/accounts/${getAddress(ACCOUNT)}/events?chainId=1&from=1725148800&to=1726358400&offset=0&limit=25`, + `/api/internal/v3/activity/accounts/${OWNER}/events?chainId=1%2C10&from=1782864000&to=1783987200&category=account%2Clending&eventType=borrow%2Cdeposit&cursor=opaque%3Acursor&limit=25`, ); expect(requests[0]?.headers).toMatchObject({ Accept: "application/json", "X-API-Key": "secret", }); - expect(page.meta?.hasMore).toBe(false); - expect(page.data[0]).toMatchObject({ + expect(result.data[0]).toMatchObject({ + id: EVENT_ID, + type: "deposit", + rawType: "deposit", category: "lending", - label: "Deposit", - blockNumber: "20700000", + logIndex: 7, + source: "v3-ponder", }); - expect(getActivityTargetContract(page.data[0]!)).toBe(VAULT); - expect(getActivityAccount(page.data[0]!)).toBe(ACCOUNT); - expect(getActivityCaller(page.data[0]!)).toBe(CALLER); + expect(result.meta.coverage.status).toBe("partial"); + expect(getActivityTargetContract(result.data[0]!)).toBe(VAULT); + expect(getActivityAccount(result.data[0]!)).toBe(ACCOUNT); + expect(getActivityCaller(result.data[0]!)).toBe(CALLER); }); - it("fetches vault activity from the V3 vault event endpoint", async () => { + it("preserves an absolute SSR endpoint path and routes by vault type", async () => { const requests: string[] = []; vi.stubGlobal( "fetch", vi.fn(async (url: string) => { requests.push(url); return new Response( - JSON.stringify({ - data: [ + JSON.stringify( + page([ + event({ + type: "set_fee", + category: "governance", + vaultType: "earn", + }), + ]), + ), + { status: 200 }, + ); + }), + ); + + const service = new ActivityService({ + endpoint: "http://localhost:3000/api/internal/", + }); + const result = await service.fetchVaultActivityEvents({ + vault: VAULT, + vaultType: "earn", + chainId: 1, + categories: ["governance"], + limit: 20, + }); + + expect(requests[0]).toBe( + `http://localhost:3000/api/internal/v3/activity/vaults/1/${VAULT}/events?vaultType=earn&category=governance&limit=20`, + ); + expect(result.data[0]?.category).toBe("governance"); + expect(service.getCapabilities()).toEqual({ + configured: true, + adapter: "v3", + canQueryAccount: true, + requestableVaultTypes: ["evk", "earn", "securitize"], + }); + expect( + service.getScopeSupport({ kind: "vault", chainId: 1, vaultType: "earn" }), + ).toBe("unknown"); + }); + + it("rejects malformed rows instead of presenting a partial empty page", () => { + expect(() => + normalizeActivityEventsResponse( + page([event(), { chainId: 1, type: "deposit" }]), + ), + ).toThrowError(ActivityResponseValidationError); + expect(() => + normalizeActivityEventsResponse(page([event({ logIndex: undefined })])), + ).toThrow("$.data[0].logIndex"); + expect(() => normalizeActivityEventsResponse({ data: [] })).toThrow( + "$.meta", + ); + }); + + it("validates cursor and coverage metadata", () => { + expect(() => + normalizeActivityEventsResponse( + page([], { hasMore: true, nextCursor: null }), + ), + ).toThrow("expected a cursor when hasMore is true"); + expect(() => + normalizeActivityEventsResponse( + page([], { + coverage: { + status: "partial", + chains: [ + { chainId: 1, status: "partial" }, + { chainId: 1, status: "complete" }, + ], + }, + }), + ), + ).toThrow("contains duplicate chain 1"); + expect(() => + normalizeActivityEventsResponse( + page([], { hasMore: false, nextCursor: "unexpected-cursor" }), + ), + ).toThrow("expected null when hasMore is false"); + expect(() => + normalizeActivityEventsResponse(page([event(), event()])), + ).toThrow("contains duplicate event id"); + expect(() => + normalizeActivityEventsResponse( + page([], { + coverage: { + status: "complete", + chains: [ + { + chainId: 1, + status: "complete", + indexedFromBlock: "20", + indexedToBlock: "10", + }, + ], + }, + }), + ), + ).toThrow("indexedToBlock to be at or after indexedFromBlock"); + expect(() => + normalizeActivityEventsResponse( + page([], { + coverage: { + status: "partial", + chains: [ + { + chainId: 1, + status: "partial", + missingCategories: ["lending", "lending"], + }, + ], + }, + }), + ), + ).toThrow("contains duplicate category lending"); + expect(() => + normalizeActivityEventsResponse(page([event({ source: "other" })])), + ).toThrow("expected the response source v3-ponder"); + expect(() => + normalizeActivityEventsResponse( + page([], { + coverage: { + status: "complete", + chains: [ { chainId: 1, - type: "borrow", - timestamp: "2024-09-05T10:00:00.000Z", - payload: { vault: VAULT, account: ACCOUNT }, + status: "complete", + missingCategories: ["lending"], }, ], + }, + }), + ), + ).toThrow("no missing categories when chain coverage is complete"); + expect(() => + normalizeActivityEventsResponse( + page([], { + coverage: { + status: "complete", + chains: [{ chainId: 1, status: "complete" }], + missingCategories: ["lending"], + }, + }), + ), + ).toThrow("no missing categories when aggregate coverage is complete"); + }); + + it("requires the V3 response metadata fields declared by the wire contract", () => { + const withoutChainMissingCategories = page(); + delete (withoutChainMissingCategories.meta.coverage.chains[0] as { + missingCategories?: unknown; + }).missingCategories; + expect(() => + normalizeActivityEventsResponse(withoutChainMissingCategories), + ).toThrow("$.meta.coverage.chains[0].missingCategories"); + + const withoutAggregateMissingCategories = page(); + delete (withoutAggregateMissingCategories.meta.coverage as { + missingCategories?: unknown; + }).missingCategories; + expect(() => + normalizeActivityEventsResponse(withoutAggregateMissingCategories), + ).toThrow("$.meta.coverage.missingCategories"); + + const withoutTimestamp = page(); + delete (withoutTimestamp.meta as { timestamp?: unknown }).timestamp; + expect(() => normalizeActivityEventsResponse(withoutTimestamp)).toThrow( + "$.meta.timestamp", + ); + }); + + it("requires RFC 3339 event, response, and valuation timestamps", () => { + expect(() => + normalizeActivityEventsResponse(page([event({ timestamp: "1" })])), + ).toThrow("$.data[0].timestamp"); + expect(() => + normalizeActivityEventsResponse(page([], { timestamp: "2026-02-30T10:00:00Z" })), + ).toThrow("$.meta.timestamp"); + expect(() => + normalizeActivityEvent( + event({ + valuation: { + status: "available", + priceTimestamp: "2026-07-13 10:00:00", + }, + }), + ), + ).toThrow("$.data[].valuation.priceTimestamp"); + + expect( + normalizeActivityEvent(event({ timestamp: "2026-07-13T11:00:00+01:00" })) + .timestamp, + ).toBe("2026-07-13T11:00:00+01:00"); + }); + + it("rejects an inverted requested time range before fetching", async () => { + const fetchMock = vi.fn(); + vi.stubGlobal("fetch", fetchMock); + const service = new ActivityService({ endpoint: "/api/internal" }); + + await expect( + service.fetchAccountActivityEvents({ + owner: OWNER, + chainId: 1, + from: 20, + to: 10, + }), + ).rejects.toThrow("from must be less than or equal to to"); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it("enforces V3 request bounds before fetching", async () => { + const fetchMock = vi.fn(); + vi.stubGlobal("fetch", fetchMock); + const service = new ActivityService({ endpoint: "/api/internal" }); + + await expect( + service.fetchAccountActivityEvents({ + owner: OWNER, + chainId: Array.from({ length: 21 }, (_, index) => index + 1), + }), + ).rejects.toThrow("more than 20 unique chains"); + await expect( + service.fetchAccountActivityEvents({ + owner: OWNER, + chainId: 1, + cursor: "x".repeat(2_049), + }), + ).rejects.toThrow("cursor must not exceed 2048 characters"); + await expect( + service.fetchAccountActivityEvents({ + owner: OWNER, + chainId: 1, + limit: 101, + }), + ).rejects.toThrow("limit must not exceed 100"); + + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it.each([ + { + name: "unsupported when every chain is unsupported", + reported: "partial", + chains: [ + { chainId: 1, status: "unsupported" }, + { chainId: 10, status: "unsupported" }, + ], + expected: "unsupported", + }, + { + name: "partial when one chain is unsupported", + reported: "unsupported", + chains: [ + { chainId: 1, status: "complete" }, + { chainId: 10, status: "unsupported" }, + ], + expected: "partial", + }, + { + name: "syncing when no chain is partial or unsupported", + reported: "complete", + chains: [ + { chainId: 1, status: "complete" }, + { chainId: 10, status: "syncing" }, + ], + expected: "syncing", + }, + { + name: "complete only when every chain is complete", + reported: "partial", + chains: [ + { chainId: 1, status: "complete" }, + { chainId: 10, status: "complete" }, + ], + expected: "complete", + }, + ] as const)("rejects aggregate coverage that is not $name", (testCase) => { + expect(() => + normalizeActivityEventsResponse( + page([], { + coverage: { + status: testCase.reported, + chains: testCase.chains, + }, + }), + ), + ).toThrow(`expected ${testCase.expected}`); + }); + + it("accepts a fully unsupported empty page", async () => { + vi.stubGlobal( + "fetch", + vi.fn( + async () => + new Response( + JSON.stringify( + page([], { + coverage: { + status: "unsupported", + chains: [ + { + chainId: 1, + status: "unsupported", + missingCategories: ["lending"], + }, + ], + }, + }), + ), + { status: 200 }, + ), + ), + ); + const service = new ActivityService({ endpoint: "/api/internal" }); + + await expect( + service.fetchAccountActivityEvents({ + owner: OWNER, + chainId: 1, + categories: ["lending"], + }), + ).resolves.toMatchObject({ + data: [], + meta: { coverage: { status: "unsupported" } }, + }); + }); + + it("rejects rows when aggregate coverage is unsupported", () => { + expect(() => + normalizeActivityEventsResponse( + page([event()], { + coverage: { + status: "unsupported", + chains: [{ chainId: 1, status: "unsupported" }], + }, + }), + ), + ).toThrow("expected no events when coverage is unsupported"); + }); + + it("requires coverage for exactly the requested account chains", async () => { + stubActivityResponse(page()); + const service = new ActivityService({ endpoint: "/api/internal" }); + + await expect( + service.fetchAccountActivityEvents({ + owner: OWNER, + chainId: [1, 10], + }), + ).rejects.toMatchObject({ + code: "INVALID_ACTIVITY_RESPONSE", + path: "$.meta.coverage.chains", + }); + }); + + it("requires account rows to match the requested chain and owner", async () => { + const service = new ActivityService({ endpoint: "/api/internal" }); + + stubActivityResponse(page([event({ chainId: 10 })])); + await expect( + service.fetchAccountActivityEvents({ owner: OWNER, chainId: 1 }), + ).rejects.toMatchObject({ path: "$.data[0].chainId" }); + + stubActivityResponse(page([event({ owner: OTHER_OWNER })])); + await expect( + service.fetchAccountActivityEvents({ owner: OWNER, chainId: 1 }), + ).rejects.toMatchObject({ path: "$.data[0].owner" }); + }); + + it("requires account rows to belong to the owner family with a consistent index", async () => { + const service = new ActivityService({ endpoint: "/api/internal" }); + + stubActivityResponse( + page([event({ account: OTHER_OWNER, subAccountIndex: 0 })]), + ); + await expect( + service.fetchAccountActivityEvents({ owner: OWNER, chainId: 1 }), + ).rejects.toMatchObject({ path: "$.data[0].account" }); + + stubActivityResponse(page([event({ subAccountIndex: 2 })])); + await expect( + service.fetchAccountActivityEvents({ owner: OWNER, chainId: 1 }), + ).rejects.toMatchObject({ path: "$.data[0].subAccountIndex" }); + + stubActivityResponse(page([event({ subAccountIndex: undefined })])); + await expect( + service.fetchAccountActivityEvents({ owner: OWNER, chainId: 1 }), + ).rejects.toMatchObject({ path: "$.data[0].subAccountIndex" }); + }); + + it("requires rows to satisfy supplied category and event type filters", async () => { + const service = new ActivityService({ endpoint: "/api/internal" }); + + stubActivityResponse(page([event({ category: "borrowing" })])); + await expect( + service.fetchAccountActivityEvents({ + owner: OWNER, + chainId: 1, + categories: ["lending"], + }), + ).rejects.toMatchObject({ path: "$.data[0].category" }); + + stubActivityResponse(page([event({ type: "borrow" })])); + await expect( + service.fetchAccountActivityEvents({ + owner: OWNER, + chainId: 1, + eventTypes: ["deposit"], + }), + ).rejects.toMatchObject({ path: "$.data[0].type" }); + }); + + it("requires missing categories to be compatible with the category filter", async () => { + stubActivityResponse( + page([event()], { + coverage: { + status: "partial", + chains: [ + { + chainId: 1, + status: "partial", + missingCategories: ["borrowing"], + }, + ], + }, + }), + ); + const service = new ActivityService({ endpoint: "/api/internal" }); + + await expect( + service.fetchAccountActivityEvents({ + owner: OWNER, + chainId: 1, + categories: ["lending"], + }), + ).rejects.toMatchObject({ + path: "$.meta.coverage.chains[0].missingCategories[0]", + }); + }); + + it("rejects rows attributed to a chain reported as unsupported", async () => { + stubActivityResponse( + page([event({ chainId: 10 })], { + coverage: { + status: "partial", + chains: [ + { chainId: 1, status: "complete" }, + { chainId: 10, status: "unsupported" }, + ], + }, + }), + ); + const service = new ActivityService({ endpoint: "/api/internal" }); + + await expect( + service.fetchAccountActivityEvents({ + owner: OWNER, + chainId: [1, 10], + }), + ).rejects.toThrow("chain 10 is reported as unsupported"); + }); + + it.each([ + { name: "chain", overrides: { chainId: 10 }, path: "$.data[0].chainId" }, + { + name: "vault", + overrides: { vault: OTHER_VAULT }, + path: "$.data[0].vault", + }, + { + name: "vault type", + overrides: { vaultType: "earn" }, + path: "$.data[0].vaultType", + }, + ] as const)("requires vault rows to match the requested $name", async (testCase) => { + stubActivityResponse(page([event(testCase.overrides)])); + const service = new ActivityService({ endpoint: "/api/internal" }); + + await expect( + service.fetchVaultActivityEvents({ + vault: VAULT, + vaultType: "evk", + chainId: 1, + }), + ).rejects.toMatchObject({ path: testCase.path }); + }); + + it("rejects a successful response from the wrong activity source", async () => { + vi.stubGlobal( + "fetch", + vi.fn( + async () => + new Response(JSON.stringify(page([], { source: "unexpected" })), { + status: 200, }), - { status: 200, headers: { "content-type": "application/json" } }, - ); + ), + ); + const service = new ActivityService({ endpoint: "/api/internal" }); + + await expect( + service.fetchAccountActivityEvents({ owner: OWNER, chainId: 1 }), + ).rejects.toMatchObject({ + code: "INVALID_ACTIVITY_RESPONSE", + path: "$.meta.source", + }); + }); + + it("validates and normalizes optional event enrichment", () => { + const result = normalizeActivityEvent( + event({ + rawType: "evk_deposit", + subAccountIndex: 1, + assets: [ + { + kind: "assets", + amountRaw: "1000000000000000000", + address: VAULT, + symbol: "eUSDC", + decimals: 18, + amount: "1", + amountUsd: "1.25", + }, + { kind: "shares", amountRaw: "500000000000000000" }, + ], + change: { + fields: { + feeBefore: "0", + feeAfter: "100", + supplyQueue: [VAULT], + }, + }, + valuation: { + status: "unavailable", + source: "v3-prices", + reason: "Historical price is unavailable", + }, + groupId: "transaction:deposit", }), ); - const service = new ActivityService({ endpoint: "https://v3.example/" }); - const page = await service.fetchVaultActivityEvents({ + expect(result).toMatchObject({ + rawType: "evk_deposit", + subAccountIndex: 1, + assets: [ + { + kind: "assets", + amountRaw: "1000000000000000000", + amount: "1", + amountUsd: "1.25", + }, + { kind: "shares", amountRaw: "500000000000000000" }, + ], + change: { + fields: { + feeBefore: "0", + feeAfter: "100", + supplyQueue: [VAULT], + }, + }, + valuation: { + status: "unavailable", + reason: "Historical price is unavailable", + }, + }); + }); + + it("rejects malformed canonical enrichment", () => { + expect(() => + normalizeActivityEvent( + event({ assets: [{ kind: "assets", address: VAULT }] }), + ), + ).toThrow(".assets[0].amountRaw"); + expect(() => + normalizeActivityEvent( + event({ + assets: [ + { kind: "assets", amountRaw: "1", amountUsd: 1.25 }, + ], + }), + ), + ).toThrow(".assets[0].amountUsd"); + expect(() => + normalizeActivityEvent( + event({ change: { fields: { queue: [VAULT, 1] } } }), + ), + ).toThrow("expected an array of strings"); + }); + + it("keeps activity categories machine-stable", () => { + expect(ACTIVITY_CATEGORIES.map(({ value }) => value)).toEqual([ + "lending", + "borrowing", + "swaps", + "liquidations", + "account", + "rewards", + "governance", + ]); + }); + + it("dedupes semantically equivalent set-like account and vault queries", async () => { + const accountPage = page([event()], { + coverage: { + status: "partial", + chains: [ + { chainId: 1, status: "complete", missingCategories: [] }, + { + chainId: 10, + status: "unsupported", + missingCategories: ["account", "lending"], + }, + ], + }, + }); + const vaultPage = page([ + event({ + type: "set_fee", + category: "governance", + vaultType: "earn", + }), + ]); + const fetchMock = vi.fn(async (url: string) => + new Response( + JSON.stringify(url.includes("/accounts/") ? accountPage : vaultPage), + { status: 200 }, + ), + ); + vi.stubGlobal("fetch", fetchMock); + const service = new ActivityService( + { endpoint: "/api/internal" }, + createQueryCacheBuildQuery({ ttlMs: 60_000, failureTtlMs: 0 }), + ); + + await service.fetchAccountActivityEvents({ + owner: OWNER.toLowerCase() as Address, + chainId: [10, 1, 10], + categories: ["lending", "account", "lending"], + eventTypes: [" Deposit ", "BORROW", "deposit"], + }); + await service.fetchAccountActivityEvents({ + owner: OWNER, + chainId: [1, 10], + categories: ["account", "lending"], + eventTypes: ["borrow", "deposit"], + }); + await service.fetchVaultActivityEvents({ + vault: VAULT.toLowerCase() as Address, + chainId: 1, + vaultType: "earn", + categories: ["governance", "governance"], + eventTypes: [" SET_FEE ", "set_fee"], + }); + await service.fetchVaultActivityEvents({ vault: VAULT, chainId: 1, - from: 1725148800, - to: 1726358400, - type: "borrow", - limit: 1, + vaultType: "earn", + categories: ["governance"], + eventTypes: ["set_fee"], }); - expect(requests[0]).toBe( - "https://v3.example/v3/evk/vaults/1/0xD8b27CF359b7D15710a5BE299AF6e7Bf904984C2/events?chainId=1&from=1725148800&to=1726358400&type=borrow&limit=1", - ); - expect(page.data[0]?.category).toBe("borrowing"); - expect(page.data[0]?.label).toBe("Borrow"); + expect(fetchMock).toHaveBeenCalledTimes(2); }); - it("normalizes labels and filters invalid rows", () => { - const event = normalizeActivityEvent({ - chainId: "1", - type: "reward_claimed", - timestamp: "2024-09-06T12:00:00.000Z", - payload: { label: "Claimed reward" }, + it("supports a custom activity adapter", async () => { + const firstPage = normalizeActivityEventsResponse(page()); + const adapter = (result: ActivityEventsPage): IActivityAdapter => ({ + getCapabilities: () => ({ + configured: true, + adapter: "custom", + canQueryAccount: true, + requestableVaultTypes: ["evk"], + }), + getScopeSupport: () => "supported", + fetchAccountActivityEvents: async () => result, + fetchVaultActivityEvents: async () => result, }); + const service = new ActivityService(adapter(firstPage)); - expect(event).toMatchObject({ - category: "rewards", - label: "Claimed reward", - }); - expect(normalizeActivityEvent({ chainId: 1, type: "deposit" })).toBeNull(); + expect( + (await service.fetchAccountActivityEvents({ owner: OWNER, chainId: 1 })) + .data[0]?.logIndex, + ).toBe(7); + expect( + (await service.fetchVaultActivityEvents({ + vault: VAULT, + vaultType: "evk", + chainId: 1, + })).data[0]?.logIndex, + ).toBe(7); }); - it("exposes activityService from buildEulerSDK with V3 config precedence", async () => { + it("uses activity-specific V3 config precedence", async () => { + const requests: Array<{ url: string; headers?: HeadersInit }> = []; + vi.stubGlobal( + "fetch", + vi.fn(async (url: string, init?: RequestInit) => { + requests.push({ url, headers: init?.headers }); + return new Response(JSON.stringify(page()), { status: 200 }); + }), + ); const sdk = await buildEulerSDK({ v3ApiKey: "shared-key", activityServiceConfig: { - endpoint: "https://activity.example", + endpoint: "https://activity.example/base", apiKey: "activity-key", }, servicesOverrides: { deploymentService }, }); - expect(sdk.activityService).toBeInstanceOf(ActivityService); - expect((sdk.activityService as ActivityService as any).endpoint).toBe( - "https://activity.example", + await sdk.activityService.fetchAccountActivityEvents({ + owner: OWNER, + chainId: 1, + }); + expect(requests[0]?.url).toBe( + `https://activity.example/base/v3/activity/accounts/${OWNER}/events?chainId=1`, + ); + expect(requests[0]?.headers).toMatchObject({ + "X-API-Key": "activity-key", + }); + }); + + it("uses activity-specific V3 environment overrides", async () => { + vi.stubEnv("EULER_SDK_V3_API_URL", "https://shared.example"); + vi.stubEnv("EULER_SDK_V3_API_KEY", "shared-key"); + vi.stubEnv( + "EULER_SDK_ACTIVITY_V3_API_URL", + "https://activity-env.example/base", + ); + vi.stubEnv("EULER_SDK_ACTIVITY_V3_API_KEY", "activity-env-key"); + const requests: Array<{ url: string; headers?: HeadersInit }> = []; + vi.stubGlobal( + "fetch", + vi.fn(async (url: string, init?: RequestInit) => { + requests.push({ url, headers: init?.headers }); + return new Response(JSON.stringify(page()), { status: 200 }); + }), ); - expect((sdk.activityService as ActivityService as any).apiKey).toBe( - "activity-key", + const sdk = await buildEulerSDK({ + servicesOverrides: { deploymentService }, + }); + + await sdk.activityService.fetchAccountActivityEvents({ + owner: OWNER, + chainId: 1, + }); + expect(requests[0]?.url).toBe( + `https://activity-env.example/base/v3/activity/accounts/${OWNER}/events?chainId=1`, ); + expect(requests[0]?.headers).toMatchObject({ + "X-API-Key": "activity-env-key", + }); }); - it("keeps direct EulerSDK construction compatible without an activity service", () => { + it("lets SDK activity V3 config override shared and explicit V3 config", async () => { + const requests: Array<{ url: string; headers?: HeadersInit }> = []; + vi.stubGlobal( + "fetch", + vi.fn(async (url: string, init?: RequestInit) => { + requests.push({ url, headers: init?.headers }); + return new Response(JSON.stringify(page()), { status: 200 }); + }), + ); + const sdk = await buildEulerSDK({ + activityServiceConfig: { + endpoint: "https://explicit.example", + apiKey: "explicit-key", + }, + config: { + v3ApiUrl: "https://shared.example", + v3ApiKey: "shared-key", + activityV3ApiUrl: "https://activity.example", + activityV3ApiKey: "activity-key", + }, + servicesOverrides: { deploymentService }, + }); + + await sdk.activityService.fetchAccountActivityEvents({ + owner: OWNER, + chainId: 1, + }); + expect(requests[0]?.url).toBe( + `https://activity.example/v3/activity/accounts/${OWNER}/events?chainId=1`, + ); + expect(requests[0]?.headers).toMatchObject({ + "X-API-Key": "activity-key", + }); + }); + + it("reports Activity unavailable when buildEulerSDK disables V3", async () => { + const sdk = await buildEulerSDK({ + config: { disableV3: true }, + servicesOverrides: { deploymentService }, + }); + + expect(sdk.activityService.getCapabilities()).toEqual({ + configured: false, + adapter: null, + canQueryAccount: false, + requestableVaultTypes: [], + reason: "v3-disabled", + }); + expect( + sdk.activityService.getScopeSupport({ kind: "account", chainId: 1 }), + ).toBe("unsupported"); + await expect( + sdk.activityService.fetchAccountActivityEvents({ + owner: OWNER, + chainId: 1, + }), + ).rejects.toBeInstanceOf(ActivityUnavailableError); + }); + + it("keeps direct EulerSDK construction compatible without claiming a source", async () => { const sdk = new EulerSDK({ accountService: {} as never, portfolioService: {} as never, @@ -188,27 +1037,33 @@ describe("ActivityService", () => { oracleAdapterService: {} as never, feeFlowService: {} as never, reulLockService: {} as never, + positionMigrationService: {} as never, }); expect(sdk.activityService).toBeInstanceOf(ActivityService); - }); - - it("lets activity V3 config override the shared V3 endpoint and API key", async () => { - const sdk = await buildEulerSDK({ - config: { - v3ApiUrl: "https://shared-v3.example", - v3ApiKey: "shared-config-key", - activityV3ApiUrl: "https://activity-v3.example", - activityV3ApiKey: "activity-v3-key", - }, - servicesOverrides: { deploymentService }, + expect(sdk.activityService.getCapabilities()).toEqual({ + configured: false, + adapter: null, + canQueryAccount: false, + requestableVaultTypes: [], + reason: "source-not-configured", }); + await expect( + sdk.activityService.fetchAccountActivityEvents({ + owner: OWNER, + chainId: 1, + }), + ).rejects.toBeInstanceOf(ActivityUnavailableError); + }); - expect((sdk.activityService as ActivityService as any).endpoint).toBe( - "https://activity-v3.example", - ); - expect((sdk.activityService as ActivityService as any).apiKey).toBe( - "activity-v3-key", + it("preserves endpoint path segments when joining URLs", () => { + expect( + joinActivityEndpointPath( + "https://lite.example/api/internal?ignored=1#fragment", + "/v3/activity/accounts/test/events", + ), + ).toBe( + "https://lite.example/api/internal/v3/activity/accounts/test/events", ); }); }); diff --git a/packages/euler-v2-sdk/test/sdkConfig.test.ts b/packages/euler-v2-sdk/test/sdkConfig.test.ts index bf8b24cb..d769a1ef 100644 --- a/packages/euler-v2-sdk/test/sdkConfig.test.ts +++ b/packages/euler-v2-sdk/test/sdkConfig.test.ts @@ -22,6 +22,8 @@ describe("SDK env config", () => { EULER_SDK_RPC_URL_8453: "https://base.example", EULER_SDK_V3_API_URL: "https://v3.example", EULER_SDK_V3_API_KEY: "secret", + EULER_SDK_ACTIVITY_V3_API_URL: "https://activity.example", + EULER_SDK_ACTIVITY_V3_API_KEY: "activity-secret", EULER_SDK_ACCOUNT_SERVICE_ADAPTER: "onchain", EULER_SDK_EVAULT_V3_BATCH_SIZE: "42", EULER_SDK_REWARDS_ENABLE_MERKL: "false", @@ -40,6 +42,8 @@ describe("SDK env config", () => { }, v3ApiUrl: "https://v3.example", v3ApiKey: "secret", + activityV3ApiUrl: "https://activity.example", + activityV3ApiKey: "activity-secret", accountServiceAdapter: "onchain", eVaultV3BatchSize: 42, rewardsEnableMerkl: false, From ac91b88a78d70d7e5c8327e71369b4256c558c1d Mon Sep 17 00:00:00 2001 From: Seranged <80223622+Seranged@users.noreply.github.com> Date: Mon, 13 Jul 2026 13:23:06 +0100 Subject: [PATCH 05/11] fix: harden activity service transport --- packages/euler-v2-sdk/src/sdk/buildSDK.ts | 6 +- .../services/activityService/activityEvent.ts | 3 + .../adapters/activityV3Adapter.ts | 23 +++++++- .../euler-v2-sdk/test/activityService.test.ts | 58 +++++++++++++++++++ 4 files changed, 88 insertions(+), 2 deletions(-) diff --git a/packages/euler-v2-sdk/src/sdk/buildSDK.ts b/packages/euler-v2-sdk/src/sdk/buildSDK.ts index a89bf6e4..fc6a8116 100644 --- a/packages/euler-v2-sdk/src/sdk/buildSDK.ts +++ b/packages/euler-v2-sdk/src/sdk/buildSDK.ts @@ -1557,12 +1557,16 @@ export async function buildEulerSDK< configApiKey: config?.activityV3ApiKey, }, ); + const canBuildActivityV3 = + resolvedActivityV3Config.endpoint.trim().length > 0; const activityService = servicesOverrides?.activityService ?? new ActivityService( disableV3 ? new UnavailableActivityAdapter("v3-disabled") - : new ActivityV3Adapter(resolvedActivityV3Config), + : canBuildActivityV3 + ? new ActivityV3Adapter(resolvedActivityV3Config) + : new UnavailableActivityAdapter("source-not-configured"), resolvedBuildQuery, ); diff --git a/packages/euler-v2-sdk/src/services/activityService/activityEvent.ts b/packages/euler-v2-sdk/src/services/activityService/activityEvent.ts index 43022ee7..377bec93 100644 --- a/packages/euler-v2-sdk/src/services/activityService/activityEvent.ts +++ b/packages/euler-v2-sdk/src/services/activityService/activityEvent.ts @@ -518,6 +518,9 @@ const readMeta = (value: unknown, path: string): ActivityEventsMeta => { record.nextCursor === null ? null : readString(record.nextCursor, `${path}.nextCursor`); + if (nextCursor !== null && nextCursor.length > 2_048) { + fail(`${path}.nextCursor`, "expected at most 2048 characters"); + } if (hasMore && nextCursor === null) { fail(`${path}.nextCursor`, "expected a cursor when hasMore is true"); } diff --git a/packages/euler-v2-sdk/src/services/activityService/adapters/activityV3Adapter.ts b/packages/euler-v2-sdk/src/services/activityService/adapters/activityV3Adapter.ts index 0b03b4d2..e484863a 100644 --- a/packages/euler-v2-sdk/src/services/activityService/adapters/activityV3Adapter.ts +++ b/packages/euler-v2-sdk/src/services/activityService/adapters/activityV3Adapter.ts @@ -23,6 +23,7 @@ const ACTIVITY_V3_SOURCE = "v3-ponder"; const MAX_ACTIVITY_CHAIN_IDS = 20; const MAX_ACTIVITY_CURSOR_LENGTH = 2_048; const MAX_ACTIVITY_LIMIT = 100; +const DEFAULT_REQUEST_TIMEOUT_MS = 10_000; const uniqueSorted = (values: readonly T[]): T[] => [...new Set(values)].sort((left, right) => @@ -189,7 +190,7 @@ export class ActivityV3Adapter implements IActivityAdapter { } private async fetchEvents(url: string): Promise { - const response = await fetch(url, { + const response = await this.fetchWithTimeout(url, { method: "GET", headers: this.getHeaders(), }); @@ -210,6 +211,26 @@ export class ActivityV3Adapter implements IActivityAdapter { return page; } + private async fetchWithTimeout( + url: string, + init: RequestInit, + ): Promise { + const controller = new AbortController(); + const timeout = setTimeout( + () => controller.abort(), + DEFAULT_REQUEST_TIMEOUT_MS, + ); + + try { + return await fetch(url, { + ...init, + signal: controller.signal, + }); + } finally { + clearTimeout(timeout); + } + } + private getHeaders(): Record { return { Accept: "application/json", diff --git a/packages/euler-v2-sdk/test/activityService.test.ts b/packages/euler-v2-sdk/test/activityService.test.ts index c2e4208e..cef91284 100644 --- a/packages/euler-v2-sdk/test/activityService.test.ts +++ b/packages/euler-v2-sdk/test/activityService.test.ts @@ -123,6 +123,7 @@ const stubActivityResponse = (body: unknown) => { afterEach(() => { vi.unstubAllGlobals(); vi.unstubAllEnvs(); + vi.useRealTimers(); }); describe("ActivityService", () => { @@ -281,6 +282,11 @@ describe("ActivityService", () => { page([], { hasMore: false, nextCursor: "unexpected-cursor" }), ), ).toThrow("expected null when hasMore is false"); + expect(() => + normalizeActivityEventsResponse( + page([], { hasMore: true, nextCursor: "x".repeat(2_049) }), + ), + ).toThrow("expected at most 2048 characters"); expect(() => normalizeActivityEventsResponse(page([event(), event()])), ).toThrow("contains duplicate event id"); @@ -924,6 +930,58 @@ describe("ActivityService", () => { }); }); + it("reports Activity unavailable when its resolved V3 endpoint is empty", async () => { + const fetchMock = vi.fn(); + vi.stubGlobal("fetch", fetchMock); + const sdk = await buildEulerSDK({ + activityServiceConfig: { endpoint: " " }, + servicesOverrides: { deploymentService }, + }); + + expect(sdk.activityService.getCapabilities()).toEqual({ + configured: false, + adapter: null, + canQueryAccount: false, + requestableVaultTypes: [], + reason: "source-not-configured", + }); + await expect( + sdk.activityService.fetchAccountActivityEvents({ + owner: OWNER, + chainId: 1, + }), + ).rejects.toBeInstanceOf(ActivityUnavailableError); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it("aborts stalled V3 activity requests after ten seconds", async () => { + vi.useFakeTimers(); + const fetchMock = vi.fn( + async (_url: string, init?: RequestInit): Promise => + new Promise((_resolve, reject) => { + init?.signal?.addEventListener( + "abort", + () => reject(new DOMException("The operation was aborted", "AbortError")), + { once: true }, + ); + }), + ); + vi.stubGlobal("fetch", fetchMock); + const service = new ActivityService({ endpoint: "/api/internal" }); + + const result = service + .fetchAccountActivityEvents({ owner: OWNER, chainId: 1 }) + .catch((error: unknown) => error); + await vi.advanceTimersByTimeAsync(10_000); + + expect(await result).toMatchObject({ name: "AbortError" }); + expect(fetchMock).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ signal: expect.any(AbortSignal) }), + ); + expect(vi.getTimerCount()).toBe(0); + }); + it("uses activity-specific V3 environment overrides", async () => { vi.stubEnv("EULER_SDK_V3_API_URL", "https://shared.example"); vi.stubEnv("EULER_SDK_V3_API_KEY", "shared-key"); From 7d8dff5429ab66d561a02fc87dac2fb036b9ee96 Mon Sep 17 00:00:00 2001 From: Seranged <80223622+Seranged@users.noreply.github.com> Date: Mon, 13 Jul 2026 13:45:11 +0100 Subject: [PATCH 06/11] fix: harden activity response handling --- .../services/activityService/activityEvent.ts | 36 +++++--- .../adapters/activityV3Adapter.ts | 55 ++++++++++-- .../euler-v2-sdk/test/activityService.test.ts | 90 +++++++++++++++++++ 3 files changed, 166 insertions(+), 15 deletions(-) diff --git a/packages/euler-v2-sdk/src/services/activityService/activityEvent.ts b/packages/euler-v2-sdk/src/services/activityService/activityEvent.ts index 377bec93..65cce01e 100644 --- a/packages/euler-v2-sdk/src/services/activityService/activityEvent.ts +++ b/packages/euler-v2-sdk/src/services/activityService/activityEvent.ts @@ -1,4 +1,8 @@ import { type Address, getAddress, type Hex, isAddress } from "viem"; +import { + getSubAccountId, + SUB_ACCOUNT_MAX_ID, +} from "../../utils/subAccounts.js"; import type { ActivityAssetAmount, ActivityAssetKind, @@ -439,8 +443,11 @@ export const normalizeActivityEvent = ( record.subAccountIndex, `${path}.subAccountIndex`, ); - if (subAccountIndex !== undefined && subAccountIndex > 255) { - fail(`${path}.subAccountIndex`, "expected an integer no greater than 255"); + if (subAccountIndex !== undefined && subAccountIndex > SUB_ACCOUNT_MAX_ID) { + fail( + `${path}.subAccountIndex`, + `expected an integer no greater than ${SUB_ACCOUNT_MAX_ID}`, + ); } const vault = readOptionalAddress(record.vault, `${path}.vault`); const vaultType = @@ -605,6 +612,18 @@ const sameNumberList = (left: readonly number[], right: readonly number[]) => left.length === right.length && left.every((value, index) => value === right[index]); +const readActivitySubAccountIndex = ( + owner: Address, + account: Address, + path: string, +): number => { + try { + return getSubAccountId(owner, account); + } catch { + return fail(path, "expected an account in the owner's family"); + } +}; + const validateMissingCategoriesForRequest = ( categories: readonly ActivityCategory[] | undefined, path: string, @@ -689,14 +708,11 @@ const validateActivityEventsPageForRequest = ( fail(`${path}.owner`, "expected the requested owner"); } if (event.account !== undefined) { - const owner = request.owner.toLowerCase(); - const account = event.account.toLowerCase(); - if (!account.startsWith(owner.slice(0, 40))) { - fail(`${path}.account`, "expected an account in the owner's family"); - } - const derivedSubAccountIndex = - Number.parseInt(owner.slice(-2), 16) ^ - Number.parseInt(account.slice(-2), 16); + const derivedSubAccountIndex = readActivitySubAccountIndex( + request.owner, + event.account, + `${path}.account`, + ); if (event.subAccountIndex !== derivedSubAccountIndex) { fail( `${path}.subAccountIndex`, diff --git a/packages/euler-v2-sdk/src/services/activityService/adapters/activityV3Adapter.ts b/packages/euler-v2-sdk/src/services/activityService/adapters/activityV3Adapter.ts index e484863a..8c7cd800 100644 --- a/packages/euler-v2-sdk/src/services/activityService/adapters/activityV3Adapter.ts +++ b/packages/euler-v2-sdk/src/services/activityService/adapters/activityV3Adapter.ts @@ -23,8 +23,52 @@ const ACTIVITY_V3_SOURCE = "v3-ponder"; const MAX_ACTIVITY_CHAIN_IDS = 20; const MAX_ACTIVITY_CURSOR_LENGTH = 2_048; const MAX_ACTIVITY_LIMIT = 100; +const MAX_ACTIVITY_RESPONSE_BYTES = 2 * 1_024 * 1_024; const DEFAULT_REQUEST_TIMEOUT_MS = 10_000; +const responseSizeError = (): Error => + new Error( + `Activity V3 response body must not exceed ${MAX_ACTIVITY_RESPONSE_BYTES} bytes`, + ); + +const readActivityResponseBody = async ( + response: Response, +): Promise => { + const contentLength = response.headers.get("content-length"); + if ( + contentLength !== null && + Number.isFinite(Number(contentLength)) && + Number(contentLength) > MAX_ACTIVITY_RESPONSE_BYTES + ) { + throw responseSizeError(); + } + + if (!response.body) return ""; + + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + let body = ""; + let receivedBytes = 0; + + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + + receivedBytes += value.byteLength; + if (receivedBytes > MAX_ACTIVITY_RESPONSE_BYTES) { + await reader.cancel().catch(() => undefined); + throw responseSizeError(); + } + body += decoder.decode(value, { stream: true }); + } + body += decoder.decode(); + return body; + } finally { + reader.releaseLock(); + } +}; + const uniqueSorted = (values: readonly T[]): T[] => [...new Set(values)].sort((left, right) => String(left).localeCompare(String(right)), @@ -190,11 +234,10 @@ export class ActivityV3Adapter implements IActivityAdapter { } private async fetchEvents(url: string): Promise { - const response = await this.fetchWithTimeout(url, { + const { response, body } = await this.fetchResponseBodyWithTimeout(url, { method: "GET", headers: this.getHeaders(), }); - const body = await response.text(); if (!response.ok) { throw new Error( `Activity V3 request failed (${response.status} ${response.statusText}): ${body.slice(0, 200)}`, @@ -211,10 +254,10 @@ export class ActivityV3Adapter implements IActivityAdapter { return page; } - private async fetchWithTimeout( + private async fetchResponseBodyWithTimeout( url: string, init: RequestInit, - ): Promise { + ): Promise<{ response: Response; body: string }> { const controller = new AbortController(); const timeout = setTimeout( () => controller.abort(), @@ -222,10 +265,12 @@ export class ActivityV3Adapter implements IActivityAdapter { ); try { - return await fetch(url, { + const response = await fetch(url, { ...init, signal: controller.signal, }); + const body = await readActivityResponseBody(response); + return { response, body }; } finally { clearTimeout(timeout); } diff --git a/packages/euler-v2-sdk/test/activityService.test.ts b/packages/euler-v2-sdk/test/activityService.test.ts index cef91284..0cf47d11 100644 --- a/packages/euler-v2-sdk/test/activityService.test.ts +++ b/packages/euler-v2-sdk/test/activityService.test.ts @@ -18,6 +18,10 @@ import { type IActivityAdapter, } from "../src/services/activityService/index.js"; import { createQueryCacheBuildQuery } from "../src/utils/buildQuery.js"; +import { + getSubAccountAddress, + SUB_ACCOUNT_MAX_ID, +} from "../src/utils/subAccounts.js"; const OWNER = getAddress( "0xee5b5c82a365d75e9f8a1e982687fb5b6ceb606c", @@ -599,6 +603,35 @@ describe("ActivityService", () => { ).rejects.toMatchObject({ path: "$.data[0].subAccountIndex" }); }); + it("accepts the exported maximum sub-account ID", async () => { + const maxSubAccount = getSubAccountAddress(OWNER, SUB_ACCOUNT_MAX_ID); + stubActivityResponse( + page([ + event({ + account: maxSubAccount, + subAccountIndex: SUB_ACCOUNT_MAX_ID, + }), + ]), + ); + const service = new ActivityService({ endpoint: "/api/internal" }); + + await expect( + service.fetchAccountActivityEvents({ owner: OWNER, chainId: 1 }), + ).resolves.toMatchObject({ + data: [ + { + account: maxSubAccount, + subAccountIndex: SUB_ACCOUNT_MAX_ID, + }, + ], + }); + expect(() => + normalizeActivityEvent( + event({ subAccountIndex: SUB_ACCOUNT_MAX_ID + 1 }), + ), + ).toThrow(`no greater than ${SUB_ACCOUNT_MAX_ID}`); + }); + it("requires rows to satisfy supplied category and event type filters", async () => { const service = new ActivityService({ endpoint: "/api/internal" }); @@ -982,6 +1015,63 @@ describe("ActivityService", () => { expect(vi.getTimerCount()).toBe(0); }); + it("keeps the V3 timeout active while consuming a stalled response body", async () => { + vi.useFakeTimers(); + let requestSignal: AbortSignal | undefined; + let bodyController: ReadableStreamDefaultController | undefined; + vi.stubGlobal( + "fetch", + vi.fn(async (_url: string, init?: RequestInit) => { + requestSignal = init?.signal ?? undefined; + const body = new ReadableStream({ + start(controller) { + bodyController = controller; + controller.enqueue(new TextEncoder().encode('{"data":')); + requestSignal?.addEventListener( + "abort", + () => + controller.error( + new DOMException("The operation was aborted", "AbortError"), + ), + { once: true }, + ); + }, + }); + return new Response(body, { status: 200 }); + }), + ); + const service = new ActivityService({ endpoint: "/api/internal" }); + + const result = service + .fetchAccountActivityEvents({ owner: OWNER, chainId: 1 }) + .catch((error: unknown) => error); + await vi.advanceTimersByTimeAsync(10_000); + if (!requestSignal?.aborted) { + bodyController?.error(new Error("test cleanup: request was not aborted")); + } + + expect(requestSignal?.aborted).toBe(true); + expect(await result).toMatchObject({ name: "AbortError" }); + expect(vi.getTimerCount()).toBe(0); + }); + + it("rejects a V3 activity response body larger than two MiB", async () => { + vi.stubGlobal( + "fetch", + vi.fn( + async () => + new Response(new Uint8Array(2 * 1_024 * 1_024 + 1), { + status: 200, + }), + ), + ); + const service = new ActivityService({ endpoint: "/api/internal" }); + + await expect( + service.fetchAccountActivityEvents({ owner: OWNER, chainId: 1 }), + ).rejects.toThrow("response body must not exceed 2097152 bytes"); + }); + it("uses activity-specific V3 environment overrides", async () => { vi.stubEnv("EULER_SDK_V3_API_URL", "https://shared.example"); vi.stubEnv("EULER_SDK_V3_API_KEY", "shared-key"); From 284bc5f1c4c5dfc04d97dc1f2298124b7350b6f9 Mon Sep 17 00:00:00 2001 From: Seranged <80223622+Seranged@users.noreply.github.com> Date: Mon, 13 Jul 2026 15:14:42 +0100 Subject: [PATCH 07/11] fix: cancel failed activity responses --- .../adapters/activityV3Adapter.ts | 15 +++++- .../euler-v2-sdk/test/activityService.test.ts | 53 +++++++++++++++++-- 2 files changed, 61 insertions(+), 7 deletions(-) diff --git a/packages/euler-v2-sdk/src/services/activityService/adapters/activityV3Adapter.ts b/packages/euler-v2-sdk/src/services/activityService/adapters/activityV3Adapter.ts index 8c7cd800..65bdede1 100644 --- a/packages/euler-v2-sdk/src/services/activityService/adapters/activityV3Adapter.ts +++ b/packages/euler-v2-sdk/src/services/activityService/adapters/activityV3Adapter.ts @@ -40,6 +40,9 @@ const readActivityResponseBody = async ( Number.isFinite(Number(contentLength)) && Number(contentLength) > MAX_ACTIVITY_RESPONSE_BYTES ) { + if (response.body) { + await response.body.cancel().catch(() => undefined); + } throw responseSizeError(); } @@ -57,13 +60,15 @@ const readActivityResponseBody = async ( receivedBytes += value.byteLength; if (receivedBytes > MAX_ACTIVITY_RESPONSE_BYTES) { - await reader.cancel().catch(() => undefined); throw responseSizeError(); } body += decoder.decode(value, { stream: true }); } body += decoder.decode(); return body; + } catch (error) { + await reader.cancel(error).catch(() => undefined); + throw error; } finally { reader.releaseLock(); } @@ -269,7 +274,13 @@ export class ActivityV3Adapter implements IActivityAdapter { ...init, signal: controller.signal, }); - const body = await readActivityResponseBody(response); + let body: string; + try { + body = await readActivityResponseBody(response); + } catch (error) { + controller.abort(); + throw error; + } return { response, body }; } finally { clearTimeout(timeout); diff --git a/packages/euler-v2-sdk/test/activityService.test.ts b/packages/euler-v2-sdk/test/activityService.test.ts index 0cf47d11..62dee70d 100644 --- a/packages/euler-v2-sdk/test/activityService.test.ts +++ b/packages/euler-v2-sdk/test/activityService.test.ts @@ -1056,20 +1056,63 @@ describe("ActivityService", () => { }); it("rejects a V3 activity response body larger than two MiB", async () => { + let requestSignal: AbortSignal | undefined; + let bodyCancelled = false; vi.stubGlobal( "fetch", - vi.fn( - async () => - new Response(new Uint8Array(2 * 1_024 * 1_024 + 1), { - status: 200, + vi.fn(async (_url: string, init?: RequestInit) => { + requestSignal = init?.signal ?? undefined; + return new Response( + new ReadableStream({ + start(controller) { + controller.enqueue(new Uint8Array(2 * 1_024 * 1_024 + 1)); + }, + cancel() { + bodyCancelled = true; + }, }), - ), + { status: 200 }, + ); + }), + ); + const service = new ActivityService({ endpoint: "/api/internal" }); + + await expect( + service.fetchAccountActivityEvents({ owner: OWNER, chainId: 1 }), + ).rejects.toThrow("response body must not exceed 2097152 bytes"); + expect(requestSignal?.aborted).toBe(true); + expect(bodyCancelled).toBe(true); + }); + + it("cancels an oversized content-length response before clearing its timeout", async () => { + vi.useFakeTimers(); + let requestSignal: AbortSignal | undefined; + let bodyCancelled = false; + vi.stubGlobal( + "fetch", + vi.fn(async (_url: string, init?: RequestInit) => { + requestSignal = init?.signal ?? undefined; + return new Response( + new ReadableStream({ + cancel() { + bodyCancelled = true; + }, + }), + { + status: 200, + headers: { "Content-Length": String(2 * 1_024 * 1_024 + 1) }, + }, + ); + }), ); const service = new ActivityService({ endpoint: "/api/internal" }); await expect( service.fetchAccountActivityEvents({ owner: OWNER, chainId: 1 }), ).rejects.toThrow("response body must not exceed 2097152 bytes"); + expect(requestSignal?.aborted).toBe(true); + expect(bodyCancelled).toBe(true); + expect(vi.getTimerCount()).toBe(0); }); it("uses activity-specific V3 environment overrides", async () => { From fafc100412af0fee482bc79604423a566e91fb9d Mon Sep 17 00:00:00 2001 From: Seranged <80223622+Seranged@users.noreply.github.com> Date: Mon, 13 Jul 2026 15:45:06 +0100 Subject: [PATCH 08/11] fix: reject activity redirects --- .../adapters/activityV3Adapter.ts | 1 + .../euler-v2-sdk/test/activityService.test.ts | 61 +++++++++++++++++++ 2 files changed, 62 insertions(+) diff --git a/packages/euler-v2-sdk/src/services/activityService/adapters/activityV3Adapter.ts b/packages/euler-v2-sdk/src/services/activityService/adapters/activityV3Adapter.ts index 65bdede1..fd4dae9e 100644 --- a/packages/euler-v2-sdk/src/services/activityService/adapters/activityV3Adapter.ts +++ b/packages/euler-v2-sdk/src/services/activityService/adapters/activityV3Adapter.ts @@ -272,6 +272,7 @@ export class ActivityV3Adapter implements IActivityAdapter { try { const response = await fetch(url, { ...init, + redirect: "error", signal: controller.signal, }); let body: string; diff --git a/packages/euler-v2-sdk/test/activityService.test.ts b/packages/euler-v2-sdk/test/activityService.test.ts index 62dee70d..f1cfc9ef 100644 --- a/packages/euler-v2-sdk/test/activityService.test.ts +++ b/packages/euler-v2-sdk/test/activityService.test.ts @@ -1,3 +1,4 @@ +import { createServer, type Server } from "node:http"; import { afterEach, describe, expect, it, vi } from "vitest"; import { getAddress, type Address } from "viem"; import { buildEulerSDK } from "../src/sdk/buildSDK.js"; @@ -124,6 +125,29 @@ const stubActivityResponse = (body: unknown) => { return fetchMock; }; +const listenOnLoopback = async (server: Server): Promise => { + await new Promise((resolve, reject) => { + const onError = (error: Error) => reject(error); + server.once("error", onError); + server.listen(0, "127.0.0.1", () => { + server.off("error", onError); + resolve(); + }); + }); + const address = server.address(); + if (!address || typeof address === "string") { + throw new Error("Expected a TCP address for the Activity test server"); + } + return `http://127.0.0.1:${address.port}`; +}; + +const closeServer = async (server: Server): Promise => { + if (!server.listening) return; + await new Promise((resolve, reject) => { + server.close((error) => (error ? reject(error) : resolve())); + }); +}; + afterEach(() => { vi.unstubAllGlobals(); vi.unstubAllEnvs(); @@ -1115,6 +1139,43 @@ describe("ActivityService", () => { expect(vi.getTimerCount()).toBe(0); }); + it("rejects cross-origin redirects without forwarding the API key", async () => { + const targetRequests: Array = []; + const redirectRequests: Array = []; + const targetServer = createServer((request, response) => { + targetRequests.push(request.headers["x-api-key"]); + response.writeHead(200, { "Content-Type": "application/json" }); + response.end(JSON.stringify(page())); + }); + const targetOrigin = await listenOnLoopback(targetServer); + const redirectServer = createServer((request, response) => { + redirectRequests.push(request.headers["x-api-key"]); + response.writeHead(302, { + Location: `${targetOrigin}/captured-activity-request`, + }); + response.end(); + }); + + try { + const redirectOrigin = await listenOnLoopback(redirectServer); + const service = new ActivityService({ + endpoint: redirectOrigin, + apiKey: "activity-secret", + }); + + await expect( + service.fetchAccountActivityEvents({ owner: OWNER, chainId: 1 }), + ).rejects.toThrow(); + expect(redirectRequests).toEqual(["activity-secret"]); + expect(targetRequests).toEqual([]); + } finally { + await Promise.all([ + closeServer(redirectServer), + closeServer(targetServer), + ]); + } + }); + it("uses activity-specific V3 environment overrides", async () => { vi.stubEnv("EULER_SDK_V3_API_URL", "https://shared.example"); vi.stubEnv("EULER_SDK_V3_API_KEY", "shared-key"); From 7f955a88a5d6a3022ba5f469efb78daae458c246 Mon Sep 17 00:00:00 2001 From: Seranged <80223622+Seranged@users.noreply.github.com> Date: Fri, 17 Jul 2026 09:10:16 +0100 Subject: [PATCH 09/11] fix: align activity event types Expose the normalized V3 event type union and reject unsupported request or response values before they reach consumers. --- .../services/activityService/activityEvent.ts | 8 +- .../activityService/activityServiceTypes.ts | 89 ++++++++++++++++++- .../adapters/activityV3Adapter.ts | 8 +- .../src/services/activityService/index.ts | 2 + .../euler-v2-sdk/test/activityService.test.ts | 30 ++++++- 5 files changed, 126 insertions(+), 11 deletions(-) diff --git a/packages/euler-v2-sdk/src/services/activityService/activityEvent.ts b/packages/euler-v2-sdk/src/services/activityService/activityEvent.ts index 65cce01e..28b3ca38 100644 --- a/packages/euler-v2-sdk/src/services/activityService/activityEvent.ts +++ b/packages/euler-v2-sdk/src/services/activityService/activityEvent.ts @@ -15,12 +15,14 @@ import type { ActivityEvent, ActivityEventsMeta, ActivityEventsPage, + ActivityEventType, ActivityValuation, ActivityValueChange, ActivityVaultType, FetchAccountActivityEventsArgs, FetchVaultActivityEventsArgs, } from "./activityServiceTypes.js"; +import { ACTIVITY_EVENT_TYPES } from "./activityServiceTypes.js"; export const ACTIVITY_CATEGORY_VALUES = [ "lending", @@ -428,7 +430,7 @@ export const normalizeActivityEvent = ( path = "$.data[]", ): ActivityEvent => { const record = readRecord(raw, path); - const type = readString(record.type, `${path}.type`); + const type = readEnum(record.type, ACTIVITY_EVENT_TYPES, `${path}.type`); const rawType = record.rawType === undefined ? type @@ -594,7 +596,7 @@ type ActivityResponseRequest = chainIds: readonly number[]; owner: Address; categories?: readonly ActivityCategory[]; - eventTypes?: readonly string[]; + eventTypes?: readonly ActivityEventType[]; } | { kind: "vault"; @@ -602,7 +604,7 @@ type ActivityResponseRequest = vault: Address; vaultType: ActivityVaultType; categories?: readonly ActivityCategory[]; - eventTypes?: readonly string[]; + eventTypes?: readonly ActivityEventType[]; }; const sortedUniqueNumbers = (values: readonly number[]): number[] => diff --git a/packages/euler-v2-sdk/src/services/activityService/activityServiceTypes.ts b/packages/euler-v2-sdk/src/services/activityService/activityServiceTypes.ts index d61e22d2..8ba79fa0 100644 --- a/packages/euler-v2-sdk/src/services/activityService/activityServiceTypes.ts +++ b/packages/euler-v2-sdk/src/services/activityService/activityServiceTypes.ts @@ -11,6 +11,89 @@ export type ActivityCategory = export type ActivityVaultType = "evk" | "earn" | "securitize"; +export const ACTIVITY_EVENT_TYPES = [ + "deposit", + "withdraw", + "transfer", + "mint", + "burn", + "borrow", + "repay", + "swap", + "interest_accrued", + "debt_socialized", + "pull_debt", + "liquidation", + "approval", + "balance_forwarder_status", + "convert_fees", + "set_caps", + "set_ltv", + "set_governor_admin", + "set_config_flags", + "set_fee_receiver", + "set_hook_config", + "set_interest_fee", + "set_interest_rate_model", + "set_liquidation_cool_off_time", + "set_max_liquidation_discount", + "accrue_interest", + "update_last_total_assets", + "update_lost_assets", + "reallocate_supply", + "reallocate_withdraw", + "set_name", + "set_symbol", + "set_fee", + "set_fee_recipient", + "set_curator", + "set_guardian", + "set_timelock", + "set_cap", + "set_is_allocator", + "set_supply_queue", + "set_withdraw_queue", + "submit_cap", + "submit_guardian", + "submit_timelock", + "submit_market_removal", + "revoke_pending_cap", + "revoke_pending_guardian", + "revoke_pending_timelock", + "revoke_pending_market_removal", + "ownership_transfer_started", + "ownership_transferred", + "frozen", + "unfrozen", + "paused", + "unpaused", + "seized", + "set_controller_perspective", + "set_supply_cap", + "owner_registered", + "collateral_status", + "operator_status", + "controller_status", + "lockdown_mode_status", + "nonce_status", + "nonce_used", + "permit_disabled_mode_status", + "reward_lock_created", + "reward_lock_removed", + "reward_transfer", + "reward_whitelist_status", + "public_reallocate_to", + "public_withdrawal", + "set_admin", + "set_allocation_fee", + "set_flow_caps", + "transfer_allocation_fee", + "buy", + "terms_of_use_signed", +] as const; + +export type ActivityEventType = (typeof ACTIVITY_EVENT_TYPES)[number]; + export type ActivityCoverageStatus = | "complete" | "partial" @@ -59,8 +142,8 @@ export interface ActivityEvent { /** Stable identifier supplied by the activity source. */ id: string; chainId: number; - /** Event type supplied by the activity source. */ - type: string; + /** Normalized event type supplied by the activity source. */ + type: ActivityEventType; /** Original indexed type when it differs from `type`; otherwise equal to `type`. */ rawType: string; category: ActivityCategory; @@ -121,7 +204,7 @@ export interface ActivityEventsQuery { /** Normalized UI categories sent through the `category` server filter. */ categories?: readonly ActivityCategory[]; /** Normalized event `type` values sent through the `eventType` server filter. */ - eventTypes?: readonly string[]; + eventTypes?: readonly ActivityEventType[]; /** Opaque cursor returned by the backend. Maximum length is 2,048 characters. */ cursor?: string; /** Page size accepted by the backend, from 1 through 100. */ diff --git a/packages/euler-v2-sdk/src/services/activityService/adapters/activityV3Adapter.ts b/packages/euler-v2-sdk/src/services/activityService/adapters/activityV3Adapter.ts index fd4dae9e..7c3dce57 100644 --- a/packages/euler-v2-sdk/src/services/activityService/adapters/activityV3Adapter.ts +++ b/packages/euler-v2-sdk/src/services/activityService/adapters/activityV3Adapter.ts @@ -18,8 +18,12 @@ import type { FetchVaultActivityEventsArgs, IActivityAdapter, } from "../activityServiceTypes.js"; +import { ACTIVITY_EVENT_TYPES } from "../activityServiceTypes.js"; const ACTIVITY_V3_SOURCE = "v3-ponder"; +const ACTIVITY_EVENT_TYPE_SET: ReadonlySet = new Set( + ACTIVITY_EVENT_TYPES, +); const MAX_ACTIVITY_CHAIN_IDS = 20; const MAX_ACTIVITY_CURSOR_LENGTH = 2_048; const MAX_ACTIVITY_LIMIT = 100; @@ -128,9 +132,9 @@ const appendCommonQuery = ( ); if ( eventTypes.length === 0 || - eventTypes.some((eventType) => !eventType || eventType.includes(",")) + eventTypes.some((eventType) => !ACTIVITY_EVENT_TYPE_SET.has(eventType)) ) { - throw new Error("eventTypes must contain non-empty, comma-free values"); + throw new Error("eventTypes must contain supported event type values"); } params.set("eventType", eventTypes.join(",")); } diff --git a/packages/euler-v2-sdk/src/services/activityService/index.ts b/packages/euler-v2-sdk/src/services/activityService/index.ts index 5af9f0f7..4a3d894d 100644 --- a/packages/euler-v2-sdk/src/services/activityService/index.ts +++ b/packages/euler-v2-sdk/src/services/activityService/index.ts @@ -31,6 +31,7 @@ export type { ActivityEventsMeta, ActivityEventsPage, ActivityEventsQuery, + ActivityEventType, ActivityScope, ActivityScopeSupport, ActivityServiceConfig, @@ -42,6 +43,7 @@ export type { IActivityAdapter, IActivityService, } from "./activityServiceTypes.js"; +export { ACTIVITY_EVENT_TYPES } from "./activityServiceTypes.js"; export { ActivityV3Adapter, joinActivityEndpointPath, diff --git a/packages/euler-v2-sdk/test/activityService.test.ts b/packages/euler-v2-sdk/test/activityService.test.ts index f1cfc9ef..2f0688cb 100644 --- a/packages/euler-v2-sdk/test/activityService.test.ts +++ b/packages/euler-v2-sdk/test/activityService.test.ts @@ -6,6 +6,7 @@ import { EulerSDK } from "../src/sdk/sdk.js"; import type { IDeploymentService } from "../src/services/deploymentService/index.js"; import { ACTIVITY_CATEGORIES, + ACTIVITY_EVENT_TYPES, ActivityResponseValidationError, ActivityService, ActivityUnavailableError, @@ -16,6 +17,7 @@ import { normalizeActivityEvent, normalizeActivityEventsResponse, type ActivityEventsPage, + type ActivityEventType, type IActivityAdapter, } from "../src/services/activityService/index.js"; import { createQueryCacheBuildQuery } from "../src/utils/buildQuery.js"; @@ -199,7 +201,7 @@ describe("ActivityService", () => { from: 1782864000, to: 1783987200, categories: ["lending", "account", "lending"], - eventTypes: [" Deposit ", "BORROW", "deposit"], + eventTypes: ["deposit", "borrow", "deposit"], cursor: "opaque:cursor", limit: 25, }); @@ -678,6 +680,28 @@ describe("ActivityService", () => { ).rejects.toMatchObject({ path: "$.data[0].type" }); }); + it("rejects event types outside the normalized V3 contract", async () => { + const fetchMock = vi.fn(); + vi.stubGlobal("fetch", fetchMock); + const service = new ActivityService({ endpoint: "/api/internal" }); + + await expect( + service.fetchAccountActivityEvents({ + owner: OWNER, + chainId: 1, + eventTypes: ["not_real"] as unknown as readonly ActivityEventType[], + }), + ).rejects.toThrow("eventTypes must contain supported event type values"); + expect(fetchMock).not.toHaveBeenCalled(); + expect(ACTIVITY_EVENT_TYPES).toContain("terms_of_use_signed"); + }); + + it("rejects response event types outside the normalized contract", () => { + expect(() => normalizeActivityEvent(event({ type: "not_real" }))).toThrow( + "Invalid activity response at $.data[].type", + ); + }); + it("requires missing categories to be compatible with the category filter", async () => { stubActivityResponse( page([event()], { @@ -903,7 +927,7 @@ describe("ActivityService", () => { owner: OWNER.toLowerCase() as Address, chainId: [10, 1, 10], categories: ["lending", "account", "lending"], - eventTypes: [" Deposit ", "BORROW", "deposit"], + eventTypes: ["deposit", "borrow", "deposit"], }); await service.fetchAccountActivityEvents({ owner: OWNER, @@ -916,7 +940,7 @@ describe("ActivityService", () => { chainId: 1, vaultType: "earn", categories: ["governance", "governance"], - eventTypes: [" SET_FEE ", "set_fee"], + eventTypes: ["set_fee", "set_fee"], }); await service.fetchVaultActivityEvents({ vault: VAULT, From 02db958bb3ae78d00410e298ad73aef2aa661a48 Mon Sep 17 00:00:00 2001 From: Seranged <80223622+Seranged@users.noreply.github.com> Date: Fri, 17 Jul 2026 10:10:53 +0100 Subject: [PATCH 10/11] fix: enforce activity response bounds - reject rows outside requested time and page limits - bypass default caching for cursor-based activity pages --- .../services/activityService/activityEvent.ts | 66 ++++++--- .../activityService/activityService.ts | 2 + packages/euler-v2-sdk/src/utils/buildQuery.ts | 5 +- .../euler-v2-sdk/test/activityService.test.ts | 125 ++++++++++++++++++ .../euler-v2-sdk/test/readPathInfra.test.ts | 13 ++ 5 files changed, 193 insertions(+), 18 deletions(-) diff --git a/packages/euler-v2-sdk/src/services/activityService/activityEvent.ts b/packages/euler-v2-sdk/src/services/activityService/activityEvent.ts index 28b3ca38..6b612abf 100644 --- a/packages/euler-v2-sdk/src/services/activityService/activityEvent.ts +++ b/packages/euler-v2-sdk/src/services/activityService/activityEvent.ts @@ -590,22 +590,30 @@ export const normalizeActivityEventsResponse = ( return { data, meta }; }; -type ActivityResponseRequest = - | { - kind: "account"; - chainIds: readonly number[]; - owner: Address; - categories?: readonly ActivityCategory[]; - eventTypes?: readonly ActivityEventType[]; - } - | { - kind: "vault"; - chainIds: readonly [number]; - vault: Address; - vaultType: ActivityVaultType; - categories?: readonly ActivityCategory[]; - eventTypes?: readonly ActivityEventType[]; - }; +type ActivityResponseBounds = { + from?: number; + to?: number; + limit?: number; +}; + +type ActivityResponseRequest = ActivityResponseBounds & + ( + | { + kind: "account"; + chainIds: readonly number[]; + owner: Address; + categories?: readonly ActivityCategory[]; + eventTypes?: readonly ActivityEventType[]; + } + | { + kind: "vault"; + chainIds: readonly [number]; + vault: Address; + vaultType: ActivityVaultType; + categories?: readonly ActivityCategory[]; + eventTypes?: readonly ActivityEventType[]; + } + ); const sortedUniqueNumbers = (values: readonly number[]): number[] => [...new Set(values)].sort((left, right) => left - right); @@ -646,6 +654,13 @@ const validateActivityEventsPageForRequest = ( page: ActivityEventsPage, request: ActivityResponseRequest, ): ActivityEventsPage => { + if (request.limit !== undefined && page.data.length > request.limit) { + fail( + "$.data", + `expected at most the requested limit of ${request.limit} rows`, + ); + } + const requestedChainIds = sortedUniqueNumbers(request.chainIds); const coveredChainIds = sortedUniqueNumbers( page.meta.coverage.chains.map((chain) => chain.chainId), @@ -683,6 +698,19 @@ const validateActivityEventsPageForRequest = ( for (const [index, event] of page.data.entries()) { const path = `$.data[${index}]`; + const eventTimestamp = Math.floor(Date.parse(event.timestamp) / 1_000); + if (request.from !== undefined && eventTimestamp < request.from) { + fail( + `${path}.timestamp`, + `timestamp is before the requested from value ${request.from}`, + ); + } + if (request.to !== undefined && eventTimestamp > request.to) { + fail( + `${path}.timestamp`, + `timestamp is after the requested to value ${request.to}`, + ); + } if (!requestedChainIds.includes(event.chainId)) { fail(`${path}.chainId`, `chain ${event.chainId} was not requested`); } @@ -744,6 +772,9 @@ export const validateAccountActivityEventsPage = ( kind: "account", chainIds: Array.isArray(args.chainId) ? args.chainId : [args.chainId], owner: getAddress(args.owner) as Address, + ...(args.from !== undefined ? { from: args.from } : {}), + ...(args.to !== undefined ? { to: args.to } : {}), + ...(args.limit !== undefined ? { limit: args.limit } : {}), ...(args.categories !== undefined ? { categories: args.categories } : {}), ...(args.eventTypes !== undefined ? { eventTypes: args.eventTypes } : {}), }); @@ -757,6 +788,9 @@ export const validateVaultActivityEventsPage = ( chainIds: [args.chainId], vault: getAddress(args.vault) as Address, vaultType: args.vaultType, + ...(args.from !== undefined ? { from: args.from } : {}), + ...(args.to !== undefined ? { to: args.to } : {}), + ...(args.limit !== undefined ? { limit: args.limit } : {}), ...(args.categories !== undefined ? { categories: args.categories } : {}), ...(args.eventTypes !== undefined ? { eventTypes: args.eventTypes } : {}), }); diff --git a/packages/euler-v2-sdk/src/services/activityService/activityService.ts b/packages/euler-v2-sdk/src/services/activityService/activityService.ts index 0827a55d..fbefafd4 100644 --- a/packages/euler-v2-sdk/src/services/activityService/activityService.ts +++ b/packages/euler-v2-sdk/src/services/activityService/activityService.ts @@ -99,6 +99,7 @@ export class ActivityService implements IActivityService { getQueryKeyAccountActivityEvents( args: FetchAccountActivityEventsArgs, ): string | null { + if (args.cursor !== undefined) return null; const chainIds = Array.isArray(args.chainId) ? [...args.chainId] : [args.chainId]; @@ -130,6 +131,7 @@ export class ActivityService implements IActivityService { getQueryKeyVaultActivityEvents( args: FetchVaultActivityEventsArgs, ): string | null { + if (args.cursor !== undefined) return null; return serializeQueryArgs([ { ...args, diff --git a/packages/euler-v2-sdk/src/utils/buildQuery.ts b/packages/euler-v2-sdk/src/utils/buildQuery.ts index fb6bdcb6..63668d5e 100644 --- a/packages/euler-v2-sdk/src/utils/buildQuery.ts +++ b/packages/euler-v2-sdk/src/utils/buildQuery.ts @@ -131,8 +131,9 @@ export function createQueryCacheBuildQuery( >(); const wrapped = (async (...args: Parameters) => { - const cacheKey = - context?.getCacheKey(args) ?? getEulerSdkQueryKey(queryName, args); + const cacheKey = context + ? context.getCacheKey(args) + : getEulerSdkQueryKey(queryName, args); if (cacheKey === null) { return fn(...args); } diff --git a/packages/euler-v2-sdk/test/activityService.test.ts b/packages/euler-v2-sdk/test/activityService.test.ts index 2f0688cb..54140642 100644 --- a/packages/euler-v2-sdk/test/activityService.test.ts +++ b/packages/euler-v2-sdk/test/activityService.test.ts @@ -48,6 +48,7 @@ const TX_HASH = "0x2162b1e4c31ccb11d1d84a08e517f3509d4ee74022f0a4670e4502b461b191f6"; const EVENT_ID = "v3-ponder:1:deposit:1:tx:7"; const RESPONSE_TIMESTAMP = "2026-07-13T10:00:01.000Z"; +const EVENT_TIMESTAMP_SECONDS = Date.parse("2026-07-13T10:00:00.000Z") / 1_000; const deploymentService: IDeploymentService = { getDeploymentChainIds: () => [], @@ -478,6 +479,70 @@ describe("ActivityService", () => { expect(fetchMock).not.toHaveBeenCalled(); }); + it.each(["account", "vault"] as const)( + "rejects %s activity responses outside requested time and page bounds", + async (scope) => { + const fetchScopedPage = (responsePage: unknown) => { + stubActivityResponse(responsePage); + const service = new ActivityService({ endpoint: "/api/internal" }); + const bounds = { + from: EVENT_TIMESTAMP_SECONDS, + to: EVENT_TIMESTAMP_SECONDS, + limit: 1, + }; + return scope === "account" + ? service.fetchAccountActivityEvents({ + owner: OWNER, + chainId: 1, + ...bounds, + }) + : service.fetchVaultActivityEvents({ + vault: VAULT, + vaultType: "evk", + chainId: 1, + ...bounds, + }); + }; + + await expect(fetchScopedPage(page([event()]))).resolves.toMatchObject({ + data: [expect.objectContaining({ id: EVENT_ID })], + }); + await expect( + fetchScopedPage( + page([ + event({ + timestamp: new Date( + (EVENT_TIMESTAMP_SECONDS - 1) * 1_000, + ).toISOString(), + }), + ]), + ), + ).rejects.toThrow("timestamp is before the requested from value"); + await expect( + fetchScopedPage( + page([ + event({ + timestamp: new Date( + (EVENT_TIMESTAMP_SECONDS + 1) * 1_000, + ).toISOString(), + }), + ]), + ), + ).rejects.toThrow("timestamp is after the requested to value"); + await expect( + fetchScopedPage( + page([ + event(), + event({ + id: "v3-ponder:1:deposit:1:tx:8", + logIndex: 8, + }), + ]), + ), + ).rejects.toThrow("expected at most the requested limit of 1 rows"); + }, + ); + it.each([ { name: "unsupported when every chain is unsupported", @@ -953,6 +1018,66 @@ describe("ActivityService", () => { expect(fetchMock).toHaveBeenCalledTimes(2); }); + it("bypasses the default cache for cursor pages while retaining first-page dedupe", async () => { + const result = normalizeActivityEventsResponse(page()); + let accountRuns = 0; + let vaultRuns = 0; + const adapter: IActivityAdapter = { + getCapabilities: () => ({ + configured: true, + adapter: "custom", + canQueryAccount: true, + requestableVaultTypes: ["evk"], + }), + getScopeSupport: () => "supported", + fetchAccountActivityEvents: async () => { + accountRuns += 1; + return result; + }, + fetchVaultActivityEvents: async () => { + vaultRuns += 1; + return result; + }, + }; + const service = new ActivityService( + adapter, + createQueryCacheBuildQuery({ ttlMs: 60_000 }), + ); + const cursorCount = 25; + + for (let index = 0; index < cursorCount; index += 1) { + for (let repeat = 0; repeat < 2; repeat += 1) { + await service.fetchAccountActivityEvents({ + owner: OWNER, + chainId: 1, + cursor: `account:${index}`, + }); + await service.fetchVaultActivityEvents({ + vault: VAULT, + vaultType: "evk", + chainId: 1, + cursor: `vault:${index}`, + }); + } + } + + await service.fetchAccountActivityEvents({ owner: OWNER, chainId: 1 }); + await service.fetchAccountActivityEvents({ owner: OWNER, chainId: 1 }); + await service.fetchVaultActivityEvents({ + vault: VAULT, + vaultType: "evk", + chainId: 1, + }); + await service.fetchVaultActivityEvents({ + vault: VAULT, + vaultType: "evk", + chainId: 1, + }); + + expect(accountRuns).toBe(cursorCount * 2 + 1); + expect(vaultRuns).toBe(cursorCount * 2 + 1); + }); + it("supports a custom activity adapter", async () => { const firstPage = normalizeActivityEventsResponse(page()); const adapter = (result: ActivityEventsPage): IActivityAdapter => ({ diff --git a/packages/euler-v2-sdk/test/readPathInfra.test.ts b/packages/euler-v2-sdk/test/readPathInfra.test.ts index fd390028..63e32317 100644 --- a/packages/euler-v2-sdk/test/readPathInfra.test.ts +++ b/packages/euler-v2-sdk/test/readPathInfra.test.ts @@ -180,6 +180,19 @@ test("buildQuery cache dedupes, short-caches failures, and decorate query method circular.self = circular; assert.equal(await uncachedFn(circular), "unserializable"); + let nullKeyRuns = 0; + const nullKeyFn = createQueryCacheBuildQuery({ ttlMs: 60_000 })( + "queryNullKey", + async () => { + nullKeyRuns += 1; + return nullKeyRuns; + }, + {}, + { getCacheKey: () => null }, + ); + assert.equal(await nullKeyFn("same"), 1); + assert.equal(await nullKeyFn("same"), 2); + class QueryContainer { queryAlpha = async () => "alpha"; notAQuery = async () => "skip"; From 9889988265d25018bcb729c5cb95fc81ff60f432 Mon Sep 17 00:00:00 2001 From: Seranged <80223622+Seranged@users.noreply.github.com> Date: Fri, 17 Jul 2026 10:31:44 +0100 Subject: [PATCH 11/11] fix: preserve activity cursor cache bypass --- .../docs/caching-external-data-queries.md | 22 ++++- .../src/queries/sdkQueries.ts | 18 ++-- packages/euler-v2-sdk/src/utils/buildQuery.ts | 4 + .../euler-v2-sdk/test/activityService.test.ts | 82 ++++++++++++++++++- .../euler-sdk/rules/sdk-caching-buildquery.md | 22 +++-- 5 files changed, 133 insertions(+), 15 deletions(-) diff --git a/packages/euler-v2-sdk/docs/caching-external-data-queries.md b/packages/euler-v2-sdk/docs/caching-external-data-queries.md index ce64e520..d4a5aea5 100644 --- a/packages/euler-v2-sdk/docs/caching-external-data-queries.md +++ b/packages/euler-v2-sdk/docs/caching-external-data-queries.md @@ -27,10 +27,17 @@ type BuildQueryFn = Promise>( queryName: string, fn: T, target: object, + // null is an explicit instruction to call fn without caching context?: { getCacheKey: (args: unknown[]) => string | null }, ) => T; ``` +`context.getCacheKey(args)` returns a deterministic string for cacheable calls +and `null` for calls that must bypass caching. `null` is a semantic no-cache +signal, not a missing key: custom builders must call `fn` directly instead of +replacing it with a generic serialized key. Activity cursor pages use this +contract so pagination does not grow a long-lived cache entry per cursor. + Pass `buildQuery` once when building the SDK and it propagates to every service and adapter: ```typescript @@ -93,12 +100,18 @@ export const queryClient = new QueryClient({ export const sdkBuildQuery: BuildQueryFn = (queryName, fn, _target, context) => { const staleTime = STALE_TIMES[queryName] ?? DEFAULT_STALE_TIME; - const wrapped = (...args: unknown[]) => - queryClient.fetchQuery({ - queryKey: ["sdk", queryName, context?.getCacheKey(args) ?? serializeQueryArgs(args)], + const wrapped = (...args: unknown[]) => { + const cacheKey = context + ? context.getCacheKey(args) + : serializeQueryArgs(args); + if (cacheKey === null) return fn(...args); + + return queryClient.fetchQuery({ + queryKey: ["sdk", queryName, cacheKey], queryFn: () => fn(...args), staleTime, }); + }; return wrapped as typeof fn; }; @@ -106,6 +119,9 @@ export const sdkBuildQuery: BuildQueryFn = (queryName, fn, _target, context) => Each `query*` call becomes a `fetchQuery` with a deterministic cache key derived from the query name and the SDK-provided `context.getCacheKey(args)` helper. Generic keys remove provider-object noise and normalize address casing. Query-owned `getQueryKeyX` methods handle query-specific semantics such as unordered feed sets or asset filters while ordered payloads remain order-sensitive. If the cached value is fresh (within `staleTime`), no network call is made. +Calls whose key helper returns `null` invoke the underlying fetcher directly and +do not create a react-query cache entry. + ### Central stale time settings ```typescript diff --git a/packages/euler-v2-sdk/examples/react-sdk-example/src/queries/sdkQueries.ts b/packages/euler-v2-sdk/examples/react-sdk-example/src/queries/sdkQueries.ts index fe764365..5bafaba1 100644 --- a/packages/euler-v2-sdk/examples/react-sdk-example/src/queries/sdkQueries.ts +++ b/packages/euler-v2-sdk/examples/react-sdk-example/src/queries/sdkQueries.ts @@ -179,11 +179,19 @@ export const sdkBuildQuery: BuildQueryFn = (queryName, fn, _target, context) => const wrapped = async (...args: unknown[]) => { recordExecution(queryName); - const queryKey = [ - "sdk", - queryName, - context?.getCacheKey(args) ?? serializeQueryArgs(args), - ] as QueryKey; + const cacheKey = context + ? context.getCacheKey(args) + : serializeQueryArgs(args); + if (cacheKey === null) { + try { + return await interceptedFetcher(...args); + } catch (error) { + recordFailure(queryName); + throw error; + } + } + + const queryKey = ["sdk", queryName, cacheKey] as QueryKey; const { disableCache, fetchQueryOptions: overrides } = getQueryBuildOverrides(); const fetchOptions: FetchQueryOptions = { diff --git a/packages/euler-v2-sdk/src/utils/buildQuery.ts b/packages/euler-v2-sdk/src/utils/buildQuery.ts index 63668d5e..c02238f6 100644 --- a/packages/euler-v2-sdk/src/utils/buildQuery.ts +++ b/packages/euler-v2-sdk/src/utils/buildQuery.ts @@ -7,6 +7,10 @@ * @returns A wrapped version of the query function */ export interface BuildQueryContext { + /** + * Returns a deterministic cache key, or `null` when this invocation must run + * without caching. Custom builders must preserve `null` as a no-cache signal. + */ getCacheKey: (args: unknown[]) => string | null; } diff --git a/packages/euler-v2-sdk/test/activityService.test.ts b/packages/euler-v2-sdk/test/activityService.test.ts index 54140642..12af6fd0 100644 --- a/packages/euler-v2-sdk/test/activityService.test.ts +++ b/packages/euler-v2-sdk/test/activityService.test.ts @@ -20,7 +20,11 @@ import { type ActivityEventType, type IActivityAdapter, } from "../src/services/activityService/index.js"; -import { createQueryCacheBuildQuery } from "../src/utils/buildQuery.js"; +import { + createQueryCacheBuildQuery, + serializeQueryArgs, + type BuildQueryFn, +} from "../src/utils/buildQuery.js"; import { getSubAccountAddress, SUB_ACCOUNT_MAX_ID, @@ -1078,6 +1082,82 @@ describe("ActivityService", () => { expect(vaultRuns).toBe(cursorCount * 2 + 1); }); + it("preserves cursor cache bypass in custom buildQuery integrations", async () => { + const result = normalizeActivityEventsResponse(page()); + let accountRuns = 0; + let vaultRuns = 0; + const adapter: IActivityAdapter = { + getCapabilities: () => ({ + configured: true, + adapter: "custom", + canQueryAccount: true, + requestableVaultTypes: ["evk"], + }), + getScopeSupport: () => "supported", + fetchAccountActivityEvents: async () => { + accountRuns += 1; + return result; + }, + fetchVaultActivityEvents: async () => { + vaultRuns += 1; + return result; + }, + }; + const cache = new Map>(); + const customBuildQuery: BuildQueryFn = ( + queryName, + fn, + _target, + context, + ) => + (async (...args: unknown[]) => { + const cacheKey = context + ? context.getCacheKey(args) + : serializeQueryArgs(args); + if (cacheKey === null) return fn(...args); + + const key = `${queryName}:${cacheKey}`; + const cached = cache.get(key); + if (cached) return cached; + + const pending = fn(...args); + cache.set(key, pending); + return pending; + }) as typeof fn; + const service = new ActivityService(adapter, customBuildQuery); + + for (let repeat = 0; repeat < 2; repeat += 1) { + await service.fetchAccountActivityEvents({ + owner: OWNER, + chainId: 1, + cursor: "account:cursor", + }); + await service.fetchVaultActivityEvents({ + vault: VAULT, + vaultType: "evk", + chainId: 1, + cursor: "vault:cursor", + }); + } + + await service.fetchAccountActivityEvents({ owner: OWNER, chainId: 1 }); + await service.fetchAccountActivityEvents({ owner: OWNER, chainId: 1 }); + await service.fetchVaultActivityEvents({ + vault: VAULT, + vaultType: "evk", + chainId: 1, + }); + await service.fetchVaultActivityEvents({ + vault: VAULT, + vaultType: "evk", + chainId: 1, + }); + + expect(accountRuns).toBe(3); + expect(vaultRuns).toBe(3); + expect(cache.size).toBe(2); + }); + it("supports a custom activity adapter", async () => { const firstPage = normalizeActivityEventsResponse(page()); const adapter = (result: ActivityEventsPage): IActivityAdapter => ({ diff --git a/skills/euler-sdk/rules/sdk-caching-buildquery.md b/skills/euler-sdk/rules/sdk-caching-buildquery.md index 417032f1..cd104f5c 100644 --- a/skills/euler-sdk/rules/sdk-caching-buildquery.md +++ b/skills/euler-sdk/rules/sdk-caching-buildquery.md @@ -14,21 +14,31 @@ When adding a new RPC/API dependency, expose it as a `query*` method on the adap ```typescript import { QueryClient } from "@tanstack/react-query"; -import type { BuildQueryFn } from "euler-v2-sdk"; +import { serializeQueryArgs, type BuildQueryFn } from "euler-v2-sdk"; const queryClient = new QueryClient(); -const buildQuery: BuildQueryFn = (queryName, fn, _target) => { +const buildQuery: BuildQueryFn = (queryName, fn, _target, context) => { const staleTime = queryName.startsWith("querySwap") ? 10_000 : 60_000; - return ((...args: unknown[]) => - queryClient.fetchQuery({ - queryKey: ["sdk", queryName, ...args], + return ((...args: unknown[]) => { + const cacheKey = context + ? context.getCacheKey(args) + : serializeQueryArgs(args); + if (cacheKey === null) return fn(...args); + + return queryClient.fetchQuery({ + queryKey: ["sdk", queryName, cacheKey], queryFn: () => fn(...args), staleTime, - })) as typeof fn; + }); + }) as typeof fn; }; ``` +Treat `context.getCacheKey(args) === null` as an explicit no-cache signal and +call the underlying fetcher directly. Do not replace it with a generic key; +cursor-based pagination uses this contract to avoid retaining every page. + Recommended stale-time strategy: - hours (e.g. 12-24h): deployments, ABI, token list, static labels