From 927310147463c8d19dc3a6e2348e931f1e7db95a Mon Sep 17 00:00:00 2001 From: Seranged <80223622+Seranged@users.noreply.github.com> Date: Thu, 23 Jul 2026 11:33:16 +0100 Subject: [PATCH 1/7] feat: fetch historical liquidation valuations from the v3 liquidations endpoint --- .../services/activityService/activityEvent.ts | 172 +++++++++++- .../activityService/activityService.ts | 38 +++ .../activityService/activityServiceTypes.ts | 69 ++++- .../adapters/activityV3Adapter.ts | 53 ++++ .../src/services/activityService/index.ts | 5 + .../euler-v2-sdk/test/activityService.test.ts | 253 +++++++++++++++++- 6 files changed, 585 insertions(+), 5 deletions(-) diff --git a/packages/euler-v2-sdk/src/services/activityService/activityEvent.ts b/packages/euler-v2-sdk/src/services/activityService/activityEvent.ts index 6b612abf..40166e57 100644 --- a/packages/euler-v2-sdk/src/services/activityService/activityEvent.ts +++ b/packages/euler-v2-sdk/src/services/activityService/activityEvent.ts @@ -21,6 +21,9 @@ import type { ActivityVaultType, FetchAccountActivityEventsArgs, FetchVaultActivityEventsArgs, + LiquidationRecord, + LiquidationsMeta, + LiquidationsPage, } from "./activityServiceTypes.js"; import { ACTIVITY_EVENT_TYPES } from "./activityServiceTypes.js"; @@ -204,6 +207,44 @@ const readOptionalAddress = ( ): Address | undefined => value === undefined ? undefined : readAddress(value, path); +const readOptionalNullableDecimalString = ( + value: unknown, + path: string, +): string | null | undefined => + value === undefined || value === null + ? value + : readDecimalString(value, path); + +const readOptionalUsdValue = ( + value: unknown, + path: string, +): string | number | undefined => { + if (value === undefined || value === null) return undefined; + if (typeof value === "string") return readString(value, path); + if (typeof value !== "number") { + return fail(path, "expected a non-negative finite number, string, or null"); + } + if (!Number.isFinite(value) || value < 0) { + return fail(path, "expected a non-negative finite number, string, or null"); + } + return value; +}; + +const readOptionalFiniteNumber = ( + value: unknown, + path: string, + options: { allowNegative?: boolean } = {}, +): number | undefined => { + if (value === undefined || value === null) return undefined; + if (typeof value !== "number" || !Number.isFinite(value)) { + return fail(path, "expected a finite number or null"); + } + if (!options.allowNegative && value < 0) { + return fail(path, "expected a non-negative finite number or null"); + } + return value; +}; + const readTxHash = (value: unknown, path: string): Hex => { const txHash = readString(value, path); if (!TX_HASH_PATTERN.test(txHash)) { @@ -254,7 +295,28 @@ const readAsset = (value: unknown, path: string): ActivityAssetAmount => { fail(`${path}.amountRaw`, "expected a non-negative decimal integer string"); } const amount = readOptionalString(record.amount, `${path}.amount`); - const amountUsd = readOptionalString(record.amountUsd, `${path}.amountUsd`); + const amountUnderlyingRaw = readOptionalNullableDecimalString( + record.amountUnderlyingRaw, + `${path}.amountUnderlyingRaw`, + ); + const underlyingAddress = readOptionalAddress( + record.underlyingAddress, + `${path}.underlyingAddress`, + ); + const underlyingDecimals = + record.underlyingDecimals === undefined + ? undefined + : readNonNegativeInteger( + record.underlyingDecimals, + `${path}.underlyingDecimals`, + ); + if (underlyingDecimals !== undefined && underlyingDecimals > 255) { + fail( + `${path}.underlyingDecimals`, + "expected an integer no greater than 255", + ); + } + const amountUsd = readOptionalUsdValue(record.amountUsd, `${path}.amountUsd`); return { kind: readEnum(record.kind, ASSET_KINDS, `${path}.kind`), @@ -263,6 +325,9 @@ const readAsset = (value: unknown, path: string): ActivityAssetAmount => { ...(symbol !== undefined ? { symbol } : {}), ...(decimals !== undefined ? { decimals } : {}), ...(amount !== undefined ? { amount } : {}), + ...(amountUnderlyingRaw !== undefined ? { amountUnderlyingRaw } : {}), + ...(underlyingAddress !== undefined ? { underlyingAddress } : {}), + ...(underlyingDecimals !== undefined ? { underlyingDecimals } : {}), ...(amountUsd !== undefined ? { amountUsd } : {}), }; }; @@ -847,3 +912,108 @@ export const getActivityCaller = ( event: Pick, ): Address | undefined => event.actor ?? readPayloadAddress(event, ["caller", "sender", "owner"]); + +const readLiquidationRecord = ( + value: unknown, + path: string, +): LiquidationRecord => { + const record = readRecord(value, path); + const debtAssetPriceUsd = readOptionalFiniteNumber( + record.debtAssetPriceUsd, + `${path}.debtAssetPriceUsd`, + ); + const repayAssetsUsd = readOptionalFiniteNumber( + record.repayAssetsUsd, + `${path}.repayAssetsUsd`, + ); + const collateralAssetPriceUsd = readOptionalFiniteNumber( + record.collateralAssetPriceUsd, + `${path}.collateralAssetPriceUsd`, + ); + const collateralAssets = readOptionalNullableDecimalString( + record.collateralAssets, + `${path}.collateralAssets`, + ); + const collateralAssetsUsd = readOptionalFiniteNumber( + record.collateralAssetsUsd, + `${path}.collateralAssetsUsd`, + ); + // A liquidation can be unprofitable, so the bonus may be negative. + const bonusUsd = readOptionalFiniteNumber( + record.bonusUsd, + `${path}.bonusUsd`, + { + allowNegative: true, + }, + ); + + return { + chainId: readPositiveInteger(record.chainId, `${path}.chainId`), + vault: readAddress(record.vault, `${path}.vault`), + violator: readAddress(record.violator, `${path}.violator`), + liquidator: readAddress(record.liquidator, `${path}.liquidator`), + collateral: readAddress(record.collateral, `${path}.collateral`), + repayAssets: readDecimalString(record.repayAssets, `${path}.repayAssets`), + yieldBalance: readDecimalString( + record.yieldBalance, + `${path}.yieldBalance`, + ), + debtAsset: readAddress(record.debtAsset, `${path}.debtAsset`), + debtAssetDecimals: readNonNegativeInteger( + record.debtAssetDecimals, + `${path}.debtAssetDecimals`, + ), + ...(debtAssetPriceUsd !== undefined ? { debtAssetPriceUsd } : {}), + ...(repayAssetsUsd !== undefined ? { repayAssetsUsd } : {}), + collateralAsset: readAddress( + record.collateralAsset, + `${path}.collateralAsset`, + ), + collateralAssetDecimals: readNonNegativeInteger( + record.collateralAssetDecimals, + `${path}.collateralAssetDecimals`, + ), + ...(collateralAssetPriceUsd !== undefined + ? { collateralAssetPriceUsd } + : {}), + ...(collateralAssets != null ? { collateralAssets } : {}), + ...(collateralAssetsUsd !== undefined ? { collateralAssetsUsd } : {}), + ...(bonusUsd !== undefined ? { bonusUsd } : {}), + valuation: readValuation(record.valuation, `${path}.valuation`), + blockNumber: readDecimalString(record.blockNumber, `${path}.blockNumber`), + txHash: readTxHash(record.txHash, `${path}.txHash`), + timestamp: readTimestamp(record.timestamp, `${path}.timestamp`), + }; +}; + +const readLiquidationsMeta = ( + value: unknown, + path: string, +): LiquidationsMeta => { + const record = readRecord(value, path); + return { + total: readNonNegativeInteger(record.total, `${path}.total`), + offset: readNonNegativeInteger(record.offset, `${path}.offset`), + limit: readNonNegativeInteger(record.limit, `${path}.limit`), + timestamp: readTimestamp(record.timestamp, `${path}.timestamp`), + }; +}; + +export const normalizeLiquidationsResponse = ( + raw: unknown, +): LiquidationsPage => { + 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((row, index) => + readLiquidationRecord(row, `$.data[${index}]`), + ); + return { data, meta: readLiquidationsMeta(response.meta, "$.meta") }; +}; diff --git a/packages/euler-v2-sdk/src/services/activityService/activityService.ts b/packages/euler-v2-sdk/src/services/activityService/activityService.ts index fbefafd4..7490a2fe 100644 --- a/packages/euler-v2-sdk/src/services/activityService/activityService.ts +++ b/packages/euler-v2-sdk/src/services/activityService/activityService.ts @@ -13,9 +13,11 @@ import type { ActivityScopeSupport, ActivityServiceConfig, FetchAccountActivityEventsArgs, + FetchLiquidationsArgs, FetchVaultActivityEventsArgs, IActivityAdapter, IActivityService, + LiquidationsPage, } from "./activityServiceTypes.js"; import { ActivityV3Adapter } from "./adapters/activityV3Adapter.js"; @@ -68,6 +70,12 @@ export class UnavailableActivityAdapter implements IActivityAdapter { ): Promise { throw new ActivityUnavailableError(this.reason); } + + async fetchLiquidations( + _args: FetchLiquidationsArgs, + ): Promise { + throw new ActivityUnavailableError(this.reason); + } } export class ActivityService implements IActivityService { @@ -163,4 +171,34 @@ export class ActivityService implements IActivityService { ): Promise { return this.queryVaultActivityEvents(args); } + + queryLiquidations = async ( + args: FetchLiquidationsArgs, + ): Promise => { + if (!this.adapter.fetchLiquidations) { + throw new ActivityUnavailableError("source-not-configured"); + } + return this.adapter.fetchLiquidations(args); + }; + + getQueryKeyLiquidations(args: FetchLiquidationsArgs): string | null { + return serializeQueryArgs([ + { + ...args, + vault: args.vault === undefined ? undefined : getAddress(args.vault), + violator: + args.violator === undefined ? undefined : getAddress(args.violator), + liquidator: + args.liquidator === undefined + ? undefined + : getAddress(args.liquidator), + }, + ]); + } + + async fetchLiquidations( + args: FetchLiquidationsArgs, + ): Promise { + return this.queryLiquidations(args); + } } diff --git a/packages/euler-v2-sdk/src/services/activityService/activityServiceTypes.ts b/packages/euler-v2-sdk/src/services/activityService/activityServiceTypes.ts index 8ba79fa0..dcd0beed 100644 --- a/packages/euler-v2-sdk/src/services/activityService/activityServiceTypes.ts +++ b/packages/euler-v2-sdk/src/services/activityService/activityServiceTypes.ts @@ -119,7 +119,12 @@ export interface ActivityAssetAmount { symbol?: string; decimals?: number; amount?: string; - amountUsd?: string; + /** Underlying-asset native units. Null means the historical conversion is unavailable. */ + amountUnderlyingRaw?: string | null; + underlyingAddress?: Address; + underlyingDecimals?: number; + /** Event-time USD value. The field is omitted when valuation is unavailable. */ + amountUsd?: string | number; } export type ActivityChangeValue = string | number | boolean | string[] | null; @@ -255,6 +260,62 @@ export interface ActivityCapabilities { reason?: ActivityCapabilityUnavailableReason; } +export interface FetchLiquidationsArgs { + chainId: number; + vault?: Address; + /** Matches the violator account exactly — sub-account addresses included. */ + violator?: Address; + liquidator?: Address; + /** Unix timestamp bounds. The backend applies its supported maximum window. */ + from?: number; + to?: number; + /** Page size accepted by the backend, from 1 through 100. */ + limit?: number; + offset?: number; +} + +export interface LiquidationRecord { + chainId: number; + vault: Address; + violator: Address; + liquidator: Address; + /** Collateral vault seized from. */ + collateral: Address; + /** Debt repaid, in debt-asset native units. */ + repayAssets: string; + /** Collateral seized, in collateral vault share units. */ + yieldBalance: string; + debtAsset: Address; + debtAssetDecimals: number; + /** Event-time USD price. Omitted when the historical valuation is unavailable. */ + debtAssetPriceUsd?: number; + repayAssetsUsd?: number; + collateralAsset: Address; + collateralAssetDecimals: number; + collateralAssetPriceUsd?: number; + /** Collateral seized converted to underlying-asset native units. */ + collateralAssets?: string; + collateralAssetsUsd?: number; + /** Liquidator bonus (collateral seized minus debt repaid) in event-time USD. */ + bonusUsd?: number; + valuation: ActivityValuation; + blockNumber: string; + txHash: Hex; + timestamp: string; +} + +export interface LiquidationsMeta { + total: number; + offset: number; + limit: number; + timestamp: string; +} + +export interface LiquidationsPage { + data: LiquidationRecord[]; + meta: LiquidationsMeta; +} + export interface IActivityAdapter { getCapabilities(): ActivityCapabilities; /** @@ -268,6 +329,12 @@ export interface IActivityAdapter { fetchVaultActivityEvents( args: FetchVaultActivityEventsArgs, ): Promise; + /** + * Historical liquidation records with event-time valuations. Optional so + * existing custom adapters keep satisfying the interface; the service + * reports activity-unavailable when the adapter omits it. + */ + fetchLiquidations?(args: FetchLiquidationsArgs): 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 index 7c3dce57..fc218fe4 100644 --- a/packages/euler-v2-sdk/src/services/activityService/adapters/activityV3Adapter.ts +++ b/packages/euler-v2-sdk/src/services/activityService/adapters/activityV3Adapter.ts @@ -4,6 +4,7 @@ import { ACTIVITY_VAULT_TYPES, ActivityResponseValidationError, normalizeActivityEventsResponse, + normalizeLiquidationsResponse, validateAccountActivityEventsPage, validateVaultActivityEventsPage, } from "../activityEvent.js"; @@ -15,8 +16,10 @@ import type { ActivityScopeSupport, ActivityServiceConfig, FetchAccountActivityEventsArgs, + FetchLiquidationsArgs, FetchVaultActivityEventsArgs, IActivityAdapter, + LiquidationsPage, } from "../activityServiceTypes.js"; import { ACTIVITY_EVENT_TYPES } from "../activityServiceTypes.js"; @@ -242,6 +245,56 @@ export class ActivityV3Adapter implements IActivityAdapter { return validateVaultActivityEventsPage(page, args); } + async fetchLiquidations( + args: FetchLiquidationsArgs, + ): Promise { + assertPositiveInteger(args.chainId, "chainId"); + 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"); + } + + const params = new URLSearchParams({ chainId: String(args.chainId) }); + if (args.vault !== undefined) params.set("vault", getAddress(args.vault)); + if (args.violator !== undefined) { + params.set("violator", getAddress(args.violator)); + } + if (args.liquidator !== undefined) { + params.set("liquidator", getAddress(args.liquidator)); + } + if (args.from !== undefined) params.set("from", String(args.from)); + if (args.to !== undefined) params.set("to", String(args.to)); + 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)); + } + if (args.offset !== undefined) { + if (!Number.isSafeInteger(args.offset) || args.offset < 0) { + throw new Error("offset must be a non-negative safe integer"); + } + params.set("offset", String(args.offset)); + } + + const { response, body } = await this.fetchResponseBodyWithTimeout( + this.buildUrl("/v3/liquidations", params), + { method: "GET", headers: this.getHeaders() }, + ); + if (!response.ok) { + throw new Error( + `Liquidations V3 request failed (${response.status} ${response.statusText}): ${body.slice(0, 200)}`, + ); + } + return normalizeLiquidationsResponse(body); + } + private async fetchEvents(url: string): Promise { const { response, body } = await this.fetchResponseBodyWithTimeout(url, { method: "GET", diff --git a/packages/euler-v2-sdk/src/services/activityService/index.ts b/packages/euler-v2-sdk/src/services/activityService/index.ts index 4a3d894d..138256de 100644 --- a/packages/euler-v2-sdk/src/services/activityService/index.ts +++ b/packages/euler-v2-sdk/src/services/activityService/index.ts @@ -9,6 +9,7 @@ export { getActivityTargetContract, normalizeActivityEvent, normalizeActivityEventsResponse, + normalizeLiquidationsResponse, } from "./activityEvent.js"; export { ActivityService, @@ -39,9 +40,13 @@ export type { ActivityValueChange, ActivityVaultType, FetchAccountActivityEventsArgs, + FetchLiquidationsArgs, FetchVaultActivityEventsArgs, IActivityAdapter, IActivityService, + LiquidationRecord, + LiquidationsMeta, + LiquidationsPage, } from "./activityServiceTypes.js"; export { ACTIVITY_EVENT_TYPES } from "./activityServiceTypes.js"; export { diff --git a/packages/euler-v2-sdk/test/activityService.test.ts b/packages/euler-v2-sdk/test/activityService.test.ts index 12af6fd0..ceb43e14 100644 --- a/packages/euler-v2-sdk/test/activityService.test.ts +++ b/packages/euler-v2-sdk/test/activityService.test.ts @@ -16,6 +16,7 @@ import { joinActivityEndpointPath, normalizeActivityEvent, normalizeActivityEventsResponse, + UnavailableActivityAdapter, type ActivityEventsPage, type ActivityEventType, type IActivityAdapter, @@ -881,7 +882,20 @@ describe("ActivityService", () => { amount: "1", amountUsd: "1.25", }, - { kind: "shares", amountRaw: "500000000000000000" }, + { + kind: "collateral", + amountRaw: "500000000000000000", + amountUnderlyingRaw: "495000", + underlyingAddress: OTHER_VAULT, + underlyingDecimals: 6, + amountUsd: 0.5, + }, + { + kind: "collateral", + amountRaw: "1", + amountUnderlyingRaw: null, + amountUsd: null, + }, ], change: { fields: { @@ -909,7 +923,19 @@ describe("ActivityService", () => { amount: "1", amountUsd: "1.25", }, - { kind: "shares", amountRaw: "500000000000000000" }, + { + kind: "collateral", + amountRaw: "500000000000000000", + amountUnderlyingRaw: "495000", + underlyingAddress: OTHER_VAULT, + underlyingDecimals: 6, + amountUsd: 0.5, + }, + { + kind: "collateral", + amountRaw: "1", + amountUnderlyingRaw: null, + }, ], change: { fields: { @@ -923,6 +949,7 @@ describe("ActivityService", () => { reason: "Historical price is unavailable", }, }); + expect(result.assets?.[2]).not.toHaveProperty("amountUsd"); }); it("rejects malformed canonical enrichment", () => { @@ -935,11 +962,24 @@ describe("ActivityService", () => { normalizeActivityEvent( event({ assets: [ - { kind: "assets", amountRaw: "1", amountUsd: 1.25 }, + { kind: "assets", amountRaw: "1", amountUsd: -1 }, ], }), ), ).toThrow(".assets[0].amountUsd"); + expect(() => + normalizeActivityEvent( + event({ + assets: [ + { + kind: "collateral", + amountRaw: "1", + amountUnderlyingRaw: "1.5", + }, + ], + }), + ), + ).toThrow(".assets[0].amountUnderlyingRaw"); expect(() => normalizeActivityEvent( event({ change: { fields: { queue: [VAULT, 1] } } }), @@ -1548,3 +1588,210 @@ describe("ActivityService", () => { ); }); }); + +describe("ActivityService liquidations", () => { + const LIQUIDATION_TX = `0x${"ab".repeat(32)}` as const; + + const liquidationRow = (overrides: Record = {}) => ({ + chainId: 1, + vault: VAULT, + violator: ACCOUNT, + liquidator: OWNER, + collateral: OTHER_VAULT, + repayAssets: "451076", + yieldBalance: "486058", + debtAsset: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + debtAssetDecimals: 6, + debtAssetPriceUsd: 0.9997843, + repayAssetsUsd: 0.4509787029068, + collateralAsset: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + collateralAssetDecimals: 6, + collateralAssetPriceUsd: 0.9997843, + collateralAssets: "530677", + collateralAssetsUsd: 0.5305625329711, + bonusUsd: 0.0795838300643, + valuation: { status: "available", source: "historical-price-snapshots" }, + blockNumber: "25181865", + txHash: LIQUIDATION_TX, + timestamp: "2026-05-26T20:24:23.000Z", + ...overrides, + }); + + const liquidationsPage = ( + rows: unknown[], + meta: Record = {}, + ) => ({ + data: rows, + meta: { + total: rows.length, + offset: 0, + limit: 100, + timestamp: "2026-07-23T10:10:10.649Z", + ...meta, + }, + }); + + it("fetches normalized liquidations with filters and offset pagination", 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(liquidationsPage([liquidationRow()], { total: 42 })), + { status: 200 }, + ); + }), + ); + + const service = new ActivityService({ + endpoint: "/api/internal", + apiKey: "secret", + }); + const result = await service.fetchLiquidations({ + chainId: 1, + vault: VAULT, + violator: ACCOUNT, + liquidator: OWNER, + from: 1782864000, + to: 1783987200, + limit: 25, + offset: 50, + }); + + expect(requests[0]?.url).toBe( + `/api/internal/v3/liquidations?chainId=1&vault=${VAULT}&violator=${ACCOUNT}&liquidator=${OWNER}&from=1782864000&to=1783987200&limit=25&offset=50`, + ); + expect(requests[0]?.headers).toMatchObject({ + Accept: "application/json", + "X-API-Key": "secret", + }); + expect(result.data[0]).toMatchObject({ + vault: VAULT, + violator: ACCOUNT, + liquidator: OWNER, + repayAssets: "451076", + collateralAssets: "530677", + bonusUsd: 0.0795838300643, + valuation: { status: "available" }, + }); + expect(result.meta).toMatchObject({ total: 42, offset: 0, limit: 100 }); + }); + + it("accepts negative bonuses and omitted valuations, rejects malformed rows", async () => { + const respond = (rows: unknown[]) => { + vi.stubGlobal( + "fetch", + vi.fn( + async () => + new Response(JSON.stringify(liquidationsPage(rows)), { + status: 200, + }), + ), + ); + }; + const service = new ActivityService({ endpoint: "/api/internal" }); + + // Unprofitable liquidation and missing historical prices are valid. + respond([ + liquidationRow({ + bonusUsd: -0.25, + debtAssetPriceUsd: null, + repayAssetsUsd: null, + collateralAssetPriceUsd: null, + collateralAssets: null, + collateralAssetsUsd: null, + valuation: { status: "unavailable", reason: "no snapshot" }, + }), + ]); + const tolerant = await service.fetchLiquidations({ chainId: 1 }); + expect(tolerant.data[0]).toMatchObject({ bonusUsd: -0.25 }); + expect(tolerant.data[0]?.collateralAssets).toBeUndefined(); + expect(tolerant.data[0]?.repayAssetsUsd).toBeUndefined(); + + respond([liquidationRow({ violator: "not-an-address" })]); + await expect(service.fetchLiquidations({ chainId: 1 })).rejects.toThrow( + ActivityResponseValidationError, + ); + + respond([liquidationRow({ repayAssetsUsd: -1 })]); + await expect(service.fetchLiquidations({ chainId: 1 })).rejects.toThrow( + ActivityResponseValidationError, + ); + + respond([liquidationRow({ repayAssets: "1.5" })]); + await expect(service.fetchLiquidations({ chainId: 1 })).rejects.toThrow( + ActivityResponseValidationError, + ); + }); + + it("validates liquidation query arguments before requesting", async () => { + const fetchMock = vi.fn(); + vi.stubGlobal("fetch", fetchMock); + const service = new ActivityService({ endpoint: "/api/internal" }); + + await expect(service.fetchLiquidations({ chainId: 0 })).rejects.toThrow( + "chainId must be a positive safe integer", + ); + await expect( + service.fetchLiquidations({ chainId: 1, limit: 101 }), + ).rejects.toThrow("limit must not exceed 100"); + await expect( + service.fetchLiquidations({ chainId: 1, offset: -1 }), + ).rejects.toThrow("offset must be a non-negative safe integer"); + await expect( + service.fetchLiquidations({ chainId: 1, from: 10, to: 5 }), + ).rejects.toThrow("from must be less than or equal to to"); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it("reports liquidations unavailable without adapter support", async () => { + const unavailable = new ActivityService( + new UnavailableActivityAdapter("v3-disabled"), + ); + await expect(unavailable.fetchLiquidations({ chainId: 1 })).rejects.toThrow( + ActivityUnavailableError, + ); + + const legacyAdapter: IActivityAdapter = { + getCapabilities: () => ({ + configured: true, + adapter: "custom", + canQueryAccount: true, + requestableVaultTypes: ["evk"], + }), + getScopeSupport: () => "unknown", + fetchAccountActivityEvents: async () => { + throw new Error("unused"); + }, + fetchVaultActivityEvents: async () => { + throw new Error("unused"); + }, + }; + const service = new ActivityService(legacyAdapter); + await expect(service.fetchLiquidations({ chainId: 1 })).rejects.toThrow( + ActivityUnavailableError, + ); + }); + + it("builds a stable liquidations query key with checksummed addresses", () => { + const service = new ActivityService({ endpoint: "/api/internal" }); + const key = service.getQueryKeyLiquidations({ + chainId: 1, + vault: VAULT.toLowerCase() as Address, + violator: ACCOUNT.toLowerCase() as Address, + }); + // The serializer lowercases values; stability across input casing is + // the invariant that matters. + expect(key).toContain(VAULT.toLowerCase()); + expect(key).toContain(ACCOUNT.toLowerCase()); + expect(key).toBe( + service.getQueryKeyLiquidations({ + chainId: 1, + vault: VAULT, + violator: ACCOUNT, + }), + ); + }); +}); From 5d351d48547eb6f2e6e95506149cc2b5bfaf2c36 Mon Sep 17 00:00:00 2001 From: Seranged <80223622+Seranged@users.noreply.github.com> Date: Thu, 23 Jul 2026 11:57:56 +0100 Subject: [PATCH 2/7] fix: validate liquidation pages against the request and tolerate null token metadata - Model nullable historical token metadata (debtAsset, collateralAsset and their decimals) as omitted optionals so valid live pages parse - Require fetchLiquidations on IActivityService while keeping it optional on IActivityAdapter for existing custom adapters - Keep ActivityAssetAmount.amountUsd a string by normalizing numeric wire values to decimal strings, and validate numeric shape on string values - Add validateLiquidationsPage: chain/filter echo, pagination-window echo, clamping direction, row-count/total invariants, and timestamp bounds --- .../services/activityService/activityEvent.ts | 147 +++++++++++++--- .../activityService/activityServiceTypes.ts | 27 ++- .../adapters/activityV3Adapter.ts | 3 +- .../euler-v2-sdk/test/activityService.test.ts | 157 +++++++++++++++++- 4 files changed, 298 insertions(+), 36 deletions(-) diff --git a/packages/euler-v2-sdk/src/services/activityService/activityEvent.ts b/packages/euler-v2-sdk/src/services/activityService/activityEvent.ts index 40166e57..8e26457f 100644 --- a/packages/euler-v2-sdk/src/services/activityService/activityEvent.ts +++ b/packages/euler-v2-sdk/src/services/activityService/activityEvent.ts @@ -21,6 +21,7 @@ import type { ActivityVaultType, FetchAccountActivityEventsArgs, FetchVaultActivityEventsArgs, + FetchLiquidationsArgs, LiquidationRecord, LiquidationsMeta, LiquidationsPage, @@ -215,19 +216,35 @@ const readOptionalNullableDecimalString = ( ? value : readDecimalString(value, path); +/** Expands exponent notation so USD amounts stay plain decimal strings. */ +const usdNumberToDecimalString = (value: number, path: string): string => { + const text = String(value); + if (!text.includes("e") && !text.includes("E")) return text; + const expanded = value + .toFixed(100) + .replace(/(\.\d*?)0+$/, "$1") + .replace(/\.$/, ""); + if (expanded.includes("e") || expanded.includes("E")) { + return fail(path, "expected a USD value expressible as a decimal string"); + } + return expanded; +}; + const readOptionalUsdValue = ( value: unknown, path: string, -): string | number | undefined => { +): string | undefined => { if (value === undefined || value === null) return undefined; - if (typeof value === "string") return readString(value, path); - if (typeof value !== "number") { - return fail(path, "expected a non-negative finite number, string, or null"); + if (typeof value === "string") { + if (!/^\d+(?:\.\d+)?$/.test(value)) { + return fail(path, "expected a non-negative decimal USD string"); + } + return value; } - if (!Number.isFinite(value) || value < 0) { + if (typeof value !== "number" || !Number.isFinite(value) || value < 0) { return fail(path, "expected a non-negative finite number, string, or null"); } - return value; + return usdNumberToDecimalString(value, path); }; const readOptionalFiniteNumber = ( @@ -913,11 +930,42 @@ export const getActivityCaller = ( ): Address | undefined => event.actor ?? readPayloadAddress(event, ["caller", "sender", "owner"]); +/** Historical token metadata can be null when unavailable at the event. */ +const readNullableMetadataAddress = ( + value: unknown, + path: string, +): Address | undefined => + value === undefined || value === null ? undefined : readAddress(value, path); + +const readNullableMetadataDecimals = ( + value: unknown, + path: string, +): number | undefined => + value === undefined || value === null + ? undefined + : readNonNegativeInteger(value, path); + const readLiquidationRecord = ( value: unknown, path: string, ): LiquidationRecord => { const record = readRecord(value, path); + const debtAsset = readNullableMetadataAddress( + record.debtAsset, + `${path}.debtAsset`, + ); + const debtAssetDecimals = readNullableMetadataDecimals( + record.debtAssetDecimals, + `${path}.debtAssetDecimals`, + ); + const collateralAsset = readNullableMetadataAddress( + record.collateralAsset, + `${path}.collateralAsset`, + ); + const collateralAssetDecimals = readNullableMetadataDecimals( + record.collateralAssetDecimals, + `${path}.collateralAssetDecimals`, + ); const debtAssetPriceUsd = readOptionalFiniteNumber( record.debtAssetPriceUsd, `${path}.debtAssetPriceUsd`, @@ -958,21 +1006,14 @@ const readLiquidationRecord = ( record.yieldBalance, `${path}.yieldBalance`, ), - debtAsset: readAddress(record.debtAsset, `${path}.debtAsset`), - debtAssetDecimals: readNonNegativeInteger( - record.debtAssetDecimals, - `${path}.debtAssetDecimals`, - ), + ...(debtAsset !== undefined ? { debtAsset } : {}), + ...(debtAssetDecimals !== undefined ? { debtAssetDecimals } : {}), ...(debtAssetPriceUsd !== undefined ? { debtAssetPriceUsd } : {}), ...(repayAssetsUsd !== undefined ? { repayAssetsUsd } : {}), - collateralAsset: readAddress( - record.collateralAsset, - `${path}.collateralAsset`, - ), - collateralAssetDecimals: readNonNegativeInteger( - record.collateralAssetDecimals, - `${path}.collateralAssetDecimals`, - ), + ...(collateralAsset !== undefined ? { collateralAsset } : {}), + ...(collateralAssetDecimals !== undefined + ? { collateralAssetDecimals } + : {}), ...(collateralAssetPriceUsd !== undefined ? { collateralAssetPriceUsd } : {}), @@ -1017,3 +1058,71 @@ export const normalizeLiquidationsResponse = ( ); return { data, meta: readLiquidationsMeta(response.meta, "$.meta") }; }; + +/** + * Rejects structurally valid pages that do not answer the request, mirroring + * the request-aware validation on the account/vault activity routes. + */ +export const validateLiquidationsPage = ( + page: LiquidationsPage, + args: FetchLiquidationsArgs, +): LiquidationsPage => { + const requestedVault = + args.vault === undefined ? undefined : getAddress(args.vault); + const requestedViolator = + args.violator === undefined ? undefined : getAddress(args.violator); + const requestedLiquidator = + args.liquidator === undefined ? undefined : getAddress(args.liquidator); + const requestedOffset = args.offset ?? 0; + + if (page.meta.offset !== requestedOffset) { + fail("$.meta.offset", `expected the requested offset ${requestedOffset}`); + } + // The endpoint clamps oversized page sizes; it never grows them. + if (args.limit !== undefined && page.meta.limit > args.limit) { + fail( + "$.meta.limit", + `expected at most the requested limit of ${args.limit}`, + ); + } + if (page.data.length > page.meta.limit) { + fail("$.data", `expected at most ${page.meta.limit} rows`); + } + if (page.meta.offset + page.data.length > page.meta.total) { + fail("$.data", "expected row count consistent with the reported total"); + } + + for (const [index, row] of page.data.entries()) { + const path = `$.data[${index}]`; + if (row.chainId !== args.chainId) { + fail(`${path}.chainId`, `chain ${row.chainId} was not requested`); + } + if (requestedVault !== undefined && row.vault !== requestedVault) { + fail(`${path}.vault`, "expected the requested vault"); + } + if (requestedViolator !== undefined && row.violator !== requestedViolator) { + fail(`${path}.violator`, "expected the requested violator"); + } + if ( + requestedLiquidator !== undefined && + row.liquidator !== requestedLiquidator + ) { + fail(`${path}.liquidator`, "expected the requested liquidator"); + } + const rowTimestamp = Math.floor(Date.parse(row.timestamp) / 1_000); + if (args.from !== undefined && rowTimestamp < args.from) { + fail( + `${path}.timestamp`, + `timestamp is before the requested from value ${args.from}`, + ); + } + if (args.to !== undefined && rowTimestamp > args.to) { + fail( + `${path}.timestamp`, + `timestamp is after the requested to value ${args.to}`, + ); + } + } + + return page; +}; diff --git a/packages/euler-v2-sdk/src/services/activityService/activityServiceTypes.ts b/packages/euler-v2-sdk/src/services/activityService/activityServiceTypes.ts index dcd0beed..8ba9b6c9 100644 --- a/packages/euler-v2-sdk/src/services/activityService/activityServiceTypes.ts +++ b/packages/euler-v2-sdk/src/services/activityService/activityServiceTypes.ts @@ -123,8 +123,12 @@ export interface ActivityAssetAmount { amountUnderlyingRaw?: string | null; underlyingAddress?: Address; underlyingDecimals?: number; - /** Event-time USD value. The field is omitted when valuation is unavailable. */ - amountUsd?: string | number; + /** + * Event-time USD value as a decimal string; numeric wire values are + * normalized during parsing. The field is omitted when valuation is + * unavailable. + */ + amountUsd?: string; } export type ActivityChangeValue = string | number | boolean | string[] | null; @@ -285,13 +289,15 @@ export interface LiquidationRecord { repayAssets: string; /** Collateral seized, in collateral vault share units. */ yieldBalance: string; - debtAsset: Address; - debtAssetDecimals: number; + /** Omitted when historical token metadata is unavailable for the vault. */ + debtAsset?: Address; + debtAssetDecimals?: number; /** Event-time USD price. Omitted when the historical valuation is unavailable. */ debtAssetPriceUsd?: number; repayAssetsUsd?: number; - collateralAsset: Address; - collateralAssetDecimals: number; + /** Omitted when historical token metadata is unavailable for the collateral vault. */ + collateralAsset?: Address; + collateralAssetDecimals?: number; collateralAssetPriceUsd?: number; /** Collateral seized converted to underlying-asset native units. */ collateralAssets?: string; @@ -337,4 +343,11 @@ export interface IActivityAdapter { fetchLiquidations?(args: FetchLiquidationsArgs): Promise; } -export interface IActivityService extends IActivityAdapter {} +export interface IActivityService extends IActivityAdapter { + /** + * Required on the service even though adapters may omit it: the service + * always exposes the method and reports activity-unavailable for adapters + * without liquidations support. + */ + fetchLiquidations(args: FetchLiquidationsArgs): Promise; +} 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 fc218fe4..d6ba870a 100644 --- a/packages/euler-v2-sdk/src/services/activityService/adapters/activityV3Adapter.ts +++ b/packages/euler-v2-sdk/src/services/activityService/adapters/activityV3Adapter.ts @@ -6,6 +6,7 @@ import { normalizeActivityEventsResponse, normalizeLiquidationsResponse, validateAccountActivityEventsPage, + validateLiquidationsPage, validateVaultActivityEventsPage, } from "../activityEvent.js"; import type { @@ -292,7 +293,7 @@ export class ActivityV3Adapter implements IActivityAdapter { `Liquidations V3 request failed (${response.status} ${response.statusText}): ${body.slice(0, 200)}`, ); } - return normalizeLiquidationsResponse(body); + return validateLiquidationsPage(normalizeLiquidationsResponse(body), args); } private async fetchEvents(url: string): Promise { diff --git a/packages/euler-v2-sdk/test/activityService.test.ts b/packages/euler-v2-sdk/test/activityService.test.ts index ceb43e14..14959775 100644 --- a/packages/euler-v2-sdk/test/activityService.test.ts +++ b/packages/euler-v2-sdk/test/activityService.test.ts @@ -20,6 +20,7 @@ import { type ActivityEventsPage, type ActivityEventType, type IActivityAdapter, + type IActivityService, } from "../src/services/activityService/index.js"; import { createQueryCacheBuildQuery, @@ -929,7 +930,8 @@ describe("ActivityService", () => { amountUnderlyingRaw: "495000", underlyingAddress: OTHER_VAULT, underlyingDecimals: 6, - amountUsd: 0.5, + // Numeric wire values normalize to the established string type. + amountUsd: "0.5", }, { kind: "collateral", @@ -967,6 +969,17 @@ describe("ActivityService", () => { }), ), ).toThrow(".assets[0].amountUsd"); + for (const malformedUsd of ["-1", "abc", "1e3", "0x12", ""]) { + expect(() => + normalizeActivityEvent( + event({ + assets: [ + { kind: "assets", amountRaw: "1", amountUsd: malformedUsd }, + ], + }), + ), + ).toThrow(".assets[0].amountUsd"); + } expect(() => normalizeActivityEvent( event({ @@ -1639,7 +1652,15 @@ describe("ActivityService liquidations", () => { vi.fn(async (url: string, init?: RequestInit) => { requests.push({ url, headers: init?.headers }); return new Response( - JSON.stringify(liquidationsPage([liquidationRow()], { total: 42 })), + JSON.stringify( + // The endpoint echoes the requested page window and clamps + // only downwards; the response must answer the request. + liquidationsPage([liquidationRow()], { + total: 142, + offset: 50, + limit: 25, + }), + ), { status: 200 }, ); }), @@ -1654,14 +1675,14 @@ describe("ActivityService liquidations", () => { vault: VAULT, violator: ACCOUNT, liquidator: OWNER, - from: 1782864000, - to: 1783987200, + from: 1779000000, + to: 1780000000, limit: 25, offset: 50, }); expect(requests[0]?.url).toBe( - `/api/internal/v3/liquidations?chainId=1&vault=${VAULT}&violator=${ACCOUNT}&liquidator=${OWNER}&from=1782864000&to=1783987200&limit=25&offset=50`, + `/api/internal/v3/liquidations?chainId=1&vault=${VAULT}&violator=${ACCOUNT}&liquidator=${OWNER}&from=1779000000&to=1780000000&limit=25&offset=50`, ); expect(requests[0]?.headers).toMatchObject({ Accept: "application/json", @@ -1676,7 +1697,7 @@ describe("ActivityService liquidations", () => { bonusUsd: 0.0795838300643, valuation: { status: "available" }, }); - expect(result.meta).toMatchObject({ total: 42, offset: 0, limit: 100 }); + expect(result.meta).toMatchObject({ total: 142, offset: 50, limit: 25 }); }); it("accepts negative bonuses and omitted valuations, rejects malformed rows", async () => { @@ -1726,6 +1747,120 @@ describe("ActivityService liquidations", () => { ); }); + it("accepts live rows with null historical token metadata", async () => { + // Mirrors production pages where the conversion is unavailable at the + // event (e.g. mainnet tx 0xef7d…200d): every metadata and USD field + // is null while the raw amounts remain present. + const row = liquidationRow({ + debtAsset: null, + debtAssetDecimals: null, + debtAssetPriceUsd: null, + repayAssetsUsd: null, + collateralAsset: null, + collateralAssetDecimals: null, + collateralAssetPriceUsd: null, + collateralAssets: null, + collateralAssetsUsd: null, + bonusUsd: null, + valuation: { + status: "unavailable", + source: "historical-price-snapshots", + reason: + "Collateral share conversion is unavailable at the liquidation event", + }, + }); + vi.stubGlobal( + "fetch", + vi.fn( + async () => + new Response(JSON.stringify(liquidationsPage([row])), { + status: 200, + }), + ), + ); + + const service = new ActivityService({ endpoint: "/api/internal" }); + const page = await service.fetchLiquidations({ chainId: 1 }); + expect(page.data[0]).toMatchObject({ + repayAssets: "451076", + yieldBalance: "486058", + valuation: { status: "unavailable" }, + }); + for (const field of [ + "debtAsset", + "debtAssetDecimals", + "collateralAsset", + "collateralAssetDecimals", + "collateralAssets", + "bonusUsd", + ]) { + expect(page.data[0]).not.toHaveProperty(field); + } + }); + + it("rejects structurally valid pages that do not answer the request", async () => { + const respond = (rows: unknown[], meta: Record = {}) => { + vi.stubGlobal( + "fetch", + vi.fn( + async () => + new Response(JSON.stringify(liquidationsPage(rows, meta)), { + status: 200, + }), + ), + ); + }; + const service = new ActivityService({ endpoint: "/api/internal" }); + + // Wrong chain. + respond([liquidationRow({ chainId: 8453 })]); + await expect(service.fetchLiquidations({ chainId: 1 })).rejects.toThrow( + "chain 8453 was not requested", + ); + + // Wrong vault for the supplied filter. + respond([liquidationRow({ vault: OTHER_VAULT })]); + await expect( + service.fetchLiquidations({ chainId: 1, vault: VAULT }), + ).rejects.toThrow("expected the requested vault"); + + // Wrong violator for the supplied filter. + respond([liquidationRow({ violator: OWNER })]); + await expect( + service.fetchLiquidations({ chainId: 1, violator: ACCOUNT }), + ).rejects.toThrow("expected the requested violator"); + + // Pagination metadata that ignores the request. + respond([liquidationRow()], { offset: 0, limit: 100 }); + await expect( + service.fetchLiquidations({ chainId: 1, limit: 1, offset: 50 }), + ).rejects.toThrow("expected the requested offset 50"); + + // The endpoint clamps page sizes down, never up. + respond([liquidationRow()], { limit: 100 }); + await expect( + service.fetchLiquidations({ chainId: 1, limit: 1 }), + ).rejects.toThrow("expected at most the requested limit of 1"); + + // More rows than the echoed page size allows. + respond([liquidationRow(), liquidationRow()], { limit: 1, total: 2 }); + await expect(service.fetchLiquidations({ chainId: 1 })).rejects.toThrow( + "expected at most 1 rows", + ); + + // Rows beyond the reported total. + respond([liquidationRow()], { total: 0 }); + await expect(service.fetchLiquidations({ chainId: 1 })).rejects.toThrow( + "expected row count consistent with the reported total", + ); + + // Rows outside the requested time window. + respond([liquidationRow({ timestamp: "2026-01-01T00:00:00.000Z" })]); + await expect( + service.fetchLiquidations({ chainId: 1, from: 1779000000 }), + ).rejects.toThrow("before the requested from value"); + }); + it("validates liquidation query arguments before requesting", async () => { const fetchMock = vi.fn(); vi.stubGlobal("fetch", fetchMock); @@ -1770,9 +1905,13 @@ describe("ActivityService liquidations", () => { }, }; const service = new ActivityService(legacyAdapter); - await expect(service.fetchLiquidations({ chainId: 1 })).rejects.toThrow( - ActivityUnavailableError, - ); + // The method is required on the public service type, so strict + // consumers can call it without narrowing; legacy adapters surface as + // a runtime unavailable error instead of a type hole. + const publicService: IActivityService = service; + await expect( + publicService.fetchLiquidations({ chainId: 1 }), + ).rejects.toThrow(ActivityUnavailableError); }); it("builds a stable liquidations query key with checksummed addresses", () => { From f62b7e6c4e355a78faa595854d9cc14add9c3197 Mon Sep 17 00:00:00 2001 From: Seranged <80223622+Seranged@users.noreply.github.com> Date: Thu, 23 Jul 2026 14:38:36 +0100 Subject: [PATCH 3/7] fix: exact USD exponent expansion, valid overshoot pages, and a split service contract - Expand exponent-form USD numbers textually from the serialized mantissa instead of toFixed, which re-rounded the value and floored at 100 decimals (1e-101 previously collapsed to "0") - Allow empty pages for offsets beyond the reported total while still rejecting positive rows past the remaining count - Restore IActivityService to its previous shape so existing custom service overrides keep compiling, and expose the built-in guarantee as IActivityServiceWithLiquidations (implemented by ActivityService) with downstream compile fixtures for both boundaries --- .../services/activityService/activityEvent.ts | 34 +++++++--- .../activityService/activityService.ts | 4 +- .../activityService/activityServiceTypes.ts | 21 +++++-- .../src/services/activityService/index.ts | 1 + .../euler-v2-sdk/test/activityService.test.ts | 63 +++++++++++++++++-- 5 files changed, 100 insertions(+), 23 deletions(-) diff --git a/packages/euler-v2-sdk/src/services/activityService/activityEvent.ts b/packages/euler-v2-sdk/src/services/activityService/activityEvent.ts index 8e26457f..6130e252 100644 --- a/packages/euler-v2-sdk/src/services/activityService/activityEvent.ts +++ b/packages/euler-v2-sdk/src/services/activityService/activityEvent.ts @@ -216,18 +216,30 @@ const readOptionalNullableDecimalString = ( ? value : readDecimalString(value, path); -/** Expands exponent notation so USD amounts stay plain decimal strings. */ +/** + * Expands exponent notation textually — shifting the decimal point through + * the serialized mantissa digits — so USD amounts become plain decimal + * strings without re-rounding the underlying number. + */ const usdNumberToDecimalString = (value: number, path: string): string => { const text = String(value); - if (!text.includes("e") && !text.includes("E")) return text; - const expanded = value - .toFixed(100) - .replace(/(\.\d*?)0+$/, "$1") - .replace(/\.$/, ""); - if (expanded.includes("e") || expanded.includes("E")) { - return fail(path, "expected a USD value expressible as a decimal string"); + const match = /^(\d+)(?:\.(\d+))?[eE]([+-]?\d+)$/.exec(text); + if (!match) { + if (!/^\d+(?:\.\d+)?$/.test(text)) { + return fail(path, "expected a USD value expressible as a decimal string"); + } + return text; + } + const [, integerPart = "", fractionPart = "", exponentPart = "0"] = match; + const digits = `${integerPart}${fractionPart}`; + const pointIndex = integerPart.length + Number(exponentPart); + if (pointIndex <= 0) { + return `0.${"0".repeat(-pointIndex)}${digits}`; + } + if (pointIndex >= digits.length) { + return `${digits}${"0".repeat(pointIndex - digits.length)}`; } - return expanded; + return `${digits.slice(0, pointIndex)}.${digits.slice(pointIndex)}`; }; const readOptionalUsdValue = ( @@ -1088,7 +1100,9 @@ export const validateLiquidationsPage = ( if (page.data.length > page.meta.limit) { fail("$.data", `expected at most ${page.meta.limit} rows`); } - if (page.meta.offset + page.data.length > page.meta.total) { + // An offset beyond the total is a valid request that returns an empty + // page; only positive rows past the remaining count are inconsistent. + if (page.data.length > Math.max(0, page.meta.total - page.meta.offset)) { fail("$.data", "expected row count consistent with the reported total"); } diff --git a/packages/euler-v2-sdk/src/services/activityService/activityService.ts b/packages/euler-v2-sdk/src/services/activityService/activityService.ts index 7490a2fe..8919fb58 100644 --- a/packages/euler-v2-sdk/src/services/activityService/activityService.ts +++ b/packages/euler-v2-sdk/src/services/activityService/activityService.ts @@ -16,7 +16,7 @@ import type { FetchLiquidationsArgs, FetchVaultActivityEventsArgs, IActivityAdapter, - IActivityService, + IActivityServiceWithLiquidations, LiquidationsPage, } from "./activityServiceTypes.js"; import { ActivityV3Adapter } from "./adapters/activityV3Adapter.js"; @@ -78,7 +78,7 @@ export class UnavailableActivityAdapter implements IActivityAdapter { } } -export class ActivityService implements IActivityService { +export class ActivityService implements IActivityServiceWithLiquidations { private adapter: IActivityAdapter; constructor( diff --git a/packages/euler-v2-sdk/src/services/activityService/activityServiceTypes.ts b/packages/euler-v2-sdk/src/services/activityService/activityServiceTypes.ts index 8ba9b6c9..593925bd 100644 --- a/packages/euler-v2-sdk/src/services/activityService/activityServiceTypes.ts +++ b/packages/euler-v2-sdk/src/services/activityService/activityServiceTypes.ts @@ -343,11 +343,20 @@ export interface IActivityAdapter { fetchLiquidations?(args: FetchLiquidationsArgs): Promise; } -export interface IActivityService extends IActivityAdapter { - /** - * Required on the service even though adapters may omit it: the service - * always exposes the method and reports activity-unavailable for adapters - * without liquidations support. - */ +/** + * Override-facing service contract: custom services supplied through SDK + * build options may omit liquidations support, exactly like adapters, so + * adding capabilities here would break existing overrides. + */ +export interface IActivityService extends IActivityAdapter {} + +/** + * Guarantee of the built-in `ActivityService`: `fetchLiquidations` is always + * callable and reports activity-unavailable at runtime for adapters without + * liquidations support. Consumers constructing the service directly (rather + * than receiving an arbitrary `IActivityService` override) can rely on this + * contract without narrowing. + */ +export interface IActivityServiceWithLiquidations extends IActivityService { fetchLiquidations(args: FetchLiquidationsArgs): Promise; } diff --git a/packages/euler-v2-sdk/src/services/activityService/index.ts b/packages/euler-v2-sdk/src/services/activityService/index.ts index 138256de..22ff2d0e 100644 --- a/packages/euler-v2-sdk/src/services/activityService/index.ts +++ b/packages/euler-v2-sdk/src/services/activityService/index.ts @@ -44,6 +44,7 @@ export type { FetchVaultActivityEventsArgs, IActivityAdapter, IActivityService, + IActivityServiceWithLiquidations, LiquidationRecord, LiquidationsMeta, LiquidationsPage, diff --git a/packages/euler-v2-sdk/test/activityService.test.ts b/packages/euler-v2-sdk/test/activityService.test.ts index 14959775..42cbd996 100644 --- a/packages/euler-v2-sdk/test/activityService.test.ts +++ b/packages/euler-v2-sdk/test/activityService.test.ts @@ -21,6 +21,7 @@ import { type ActivityEventType, type IActivityAdapter, type IActivityService, + type IActivityServiceWithLiquidations, } from "../src/services/activityService/index.js"; import { createQueryCacheBuildQuery, @@ -1000,6 +1001,23 @@ describe("ActivityService", () => { ).toThrow("expected an array of strings"); }); + it("expands exponent-form USD numbers exactly, without re-rounding", () => { + const read = (amountUsd: number) => + normalizeActivityEvent( + event({ assets: [{ kind: "assets", amountRaw: "1", amountUsd }] }), + ).assets?.[0]?.amountUsd; + + expect(read(0.5)).toBe("0.5"); + expect(read(1e-7)).toBe("0.0000001"); + expect(read(1.25e-9)).toBe("0.00000000125"); + // Extreme exponents keep the serialized mantissa verbatim — the + // smallest denormal must not underflow to "0". + expect(read(1e-101)).toBe(`0.${"0".repeat(100)}1`); + expect(read(5e-324)).toBe(`0.${"0".repeat(323)}5`); + expect(read(1e21)).toBe(`1${"0".repeat(21)}`); + expect(read(1.25e22)).toBe(`125${"0".repeat(20)}`); + }); + it("keeps activity categories machine-stable", () => { expect(ACTIVITY_CATEGORIES.map(({ value }) => value)).toEqual([ "lending", @@ -1854,6 +1872,13 @@ describe("ActivityService liquidations", () => { "expected row count consistent with the reported total", ); + // A row returned past the remaining count is inconsistent even when + // the empty-overshoot case is allowed. + respond([liquidationRow()], { total: 2374, offset: 999999, limit: 1 }); + await expect( + service.fetchLiquidations({ chainId: 1, limit: 1, offset: 999999 }), + ).rejects.toThrow("expected row count consistent with the reported total"); + // Rows outside the requested time window. respond([liquidationRow({ timestamp: "2026-01-01T00:00:00.000Z" })]); await expect( @@ -1861,6 +1886,31 @@ describe("ActivityService liquidations", () => { ).rejects.toThrow("before the requested from value"); }); + it("accepts a valid empty page for an offset beyond the total", async () => { + // Live shape: the endpoint answers an overshooting offset with an + // empty page while still reporting the overall total. + vi.stubGlobal( + "fetch", + vi.fn( + async () => + new Response( + JSON.stringify( + liquidationsPage([], { total: 2374, offset: 999999, limit: 1 }), + ), + { status: 200 }, + ), + ), + ); + const service = new ActivityService({ endpoint: "/api/internal" }); + const page = await service.fetchLiquidations({ + chainId: 1, + limit: 1, + offset: 999999, + }); + expect(page.data).toEqual([]); + expect(page.meta).toMatchObject({ total: 2374, offset: 999999 }); + }); + it("validates liquidation query arguments before requesting", async () => { const fetchMock = vi.fn(); vi.stubGlobal("fetch", fetchMock); @@ -1905,12 +1955,15 @@ describe("ActivityService liquidations", () => { }, }; const service = new ActivityService(legacyAdapter); - // The method is required on the public service type, so strict - // consumers can call it without narrowing; legacy adapters surface as - // a runtime unavailable error instead of a type hole. - const publicService: IActivityService = service; + // Downstream compile fixtures. A legacy custom service override that + // predates liquidations must stay assignable to the override-facing + // contract, while the built-in service carries the stronger guarantee + // so its consumers can call fetchLiquidations without narrowing. + const legacyServiceOverride: IActivityService = legacyAdapter; + expect(legacyServiceOverride.fetchLiquidations).toBeUndefined(); + const builtInService: IActivityServiceWithLiquidations = service; await expect( - publicService.fetchLiquidations({ chainId: 1 }), + builtInService.fetchLiquidations({ chainId: 1 }), ).rejects.toThrow(ActivityUnavailableError); }); From d7979a8759d412fe766d84d12ab9c18b2c36ecc1 Mon Sep 17 00:00:00 2001 From: Seranged <80223622+Seranged@users.noreply.github.com> Date: Thu, 23 Jul 2026 16:36:58 +0100 Subject: [PATCH 4/7] fix: expose the built-in liquidations guarantee on the public SDK type EulerSDK.activityService is now IActivityServiceWithLiquidations: overrides that already expose fetchLiquidations pass through by identity, and legacy overrides without it are wrapped in an ActivityService that delegates every call and reports activity-unavailable at runtime. Both boundaries are enforced by type fixtures (vitest typecheck mode, scoped to test-d files): a strict consumer calling fetchLiquidations on the built SDK without narrowing, and a pre-liquidations override staying assignable to the SDK options. --- packages/euler-v2-sdk/src/sdk/sdk.ts | 18 +++-- .../activityService/activityService.ts | 16 +++++ .../src/services/activityService/index.ts | 1 + .../euler-v2-sdk/test/activityService.test.ts | 71 +++++++++++++++++++ .../test/activityServicePublicTypes.test-d.ts | 39 ++++++++++ packages/euler-v2-sdk/tsconfig.typetest.json | 7 ++ packages/euler-v2-sdk/vitest.config.ts | 5 ++ 7 files changed, 152 insertions(+), 5 deletions(-) create mode 100644 packages/euler-v2-sdk/test/activityServicePublicTypes.test-d.ts create mode 100644 packages/euler-v2-sdk/tsconfig.typetest.json diff --git a/packages/euler-v2-sdk/src/sdk/sdk.ts b/packages/euler-v2-sdk/src/sdk/sdk.ts index 06ccb6d2..6b1695ef 100644 --- a/packages/euler-v2-sdk/src/sdk/sdk.ts +++ b/packages/euler-v2-sdk/src/sdk/sdk.ts @@ -25,8 +25,10 @@ import type { IREULLockService } from "../services/reulLockService/index.js"; import type { IPositionMigrationService } from "../services/positionMigrationService/index.js"; import { ActivityService, + ensureActivityLiquidationsSupport, UnavailableActivityAdapter, type IActivityService, + type IActivityServiceWithLiquidations, } from "../services/activityService/index.js"; import type { EulerPlugin, PluginPrefetchData } from "../plugins/types.js"; import type { TransactionPlan } from "../services/executionService/executionServiceTypes.js"; @@ -82,7 +84,12 @@ export class EulerSDK { public readonly feeFlowService: IFeeFlowService; public readonly reulLockService: IREULLockService; public readonly positionMigrationService: IPositionMigrationService; - public readonly activityService: IActivityService; + /** + * Always exposes the built-in liquidations guarantee: custom overrides + * without `fetchLiquidations` are wrapped so the method stays callable + * and reports activity-unavailable at runtime. + */ + public readonly activityService: IActivityServiceWithLiquidations; public readonly plugins: EulerPlugin[]; constructor(options: EulerSDKOptions) { @@ -107,11 +114,12 @@ export class EulerSDK { this.feeFlowService = options.feeFlowService; this.reulLockService = options.reulLockService; this.positionMigrationService = options.positionMigrationService; - this.activityService = + this.activityService = ensureActivityLiquidationsSupport( options.activityService ?? - new ActivityService( - new UnavailableActivityAdapter("source-not-configured"), - ); + new ActivityService( + new UnavailableActivityAdapter("source-not-configured"), + ), + ); 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 index 8919fb58..d8572606 100644 --- a/packages/euler-v2-sdk/src/services/activityService/activityService.ts +++ b/packages/euler-v2-sdk/src/services/activityService/activityService.ts @@ -16,6 +16,7 @@ import type { FetchLiquidationsArgs, FetchVaultActivityEventsArgs, IActivityAdapter, + IActivityService, IActivityServiceWithLiquidations, LiquidationsPage, } from "./activityServiceTypes.js"; @@ -202,3 +203,18 @@ export class ActivityService implements IActivityServiceWithLiquidations { return this.queryLiquidations(args); } } + +/** + * Carries the built-in liquidations guarantee through the SDK boundary. + * Services that already expose `fetchLiquidations` pass through unchanged + * (including their identity); a legacy override without it is wrapped in an + * `ActivityService` delegating every call to the override, so the method is + * always callable and reports activity-unavailable at runtime instead of + * surfacing as a possibly-undefined property to strict consumers. + */ +export const ensureActivityLiquidationsSupport = ( + service: IActivityService, +): IActivityServiceWithLiquidations => + typeof service.fetchLiquidations === "function" + ? (service as IActivityServiceWithLiquidations) + : new ActivityService(service); diff --git a/packages/euler-v2-sdk/src/services/activityService/index.ts b/packages/euler-v2-sdk/src/services/activityService/index.ts index 22ff2d0e..b325afec 100644 --- a/packages/euler-v2-sdk/src/services/activityService/index.ts +++ b/packages/euler-v2-sdk/src/services/activityService/index.ts @@ -14,6 +14,7 @@ export { export { ActivityService, ActivityUnavailableError, + ensureActivityLiquidationsSupport, UnavailableActivityAdapter, } from "./activityService.js"; export type { diff --git a/packages/euler-v2-sdk/test/activityService.test.ts b/packages/euler-v2-sdk/test/activityService.test.ts index 42cbd996..81020dd4 100644 --- a/packages/euler-v2-sdk/test/activityService.test.ts +++ b/packages/euler-v2-sdk/test/activityService.test.ts @@ -1608,6 +1608,77 @@ describe("ActivityService", () => { ).rejects.toBeInstanceOf(ActivityUnavailableError); }); + it("exposes callable liquidations on the built SDK for default and legacy override services", async () => { + const baseOptions = { + 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, + positionMigrationService: {} as never, + }; + + // Strict downstream fixture: the normal built SDK exposes + // fetchLiquidations directly — no narrowing, no optional call. + const sdk = new EulerSDK(baseOptions); + await expect( + sdk.activityService.fetchLiquidations({ chainId: 1 }), + ).rejects.toBeInstanceOf(ActivityUnavailableError); + + // A legacy custom service without liquidations stays assignable and + // is wrapped: delegation is preserved and the guaranteed method + // reports activity-unavailable at runtime instead of being a type + // hole on the public SDK property. + const legacyService: IActivityService = { + getCapabilities: () => ({ + configured: true, + adapter: "legacy-custom", + canQueryAccount: true, + requestableVaultTypes: ["evk"], + }), + getScopeSupport: () => "unknown", + fetchAccountActivityEvents: async () => { + throw new Error("unused"); + }, + fetchVaultActivityEvents: async () => { + throw new Error("unused"); + }, + }; + const sdkWithLegacyOverride = new EulerSDK({ + ...baseOptions, + activityService: legacyService, + }); + expect(sdkWithLegacyOverride.activityService.getCapabilities().adapter).toBe( + "legacy-custom", + ); + await expect( + sdkWithLegacyOverride.activityService.fetchLiquidations({ chainId: 1 }), + ).rejects.toBeInstanceOf(ActivityUnavailableError); + + // An override that already supports liquidations keeps its identity. + const modernService = new ActivityService({ endpoint: "/api/internal" }); + const sdkWithModernOverride = new EulerSDK({ + ...baseOptions, + activityService: modernService, + }); + expect(sdkWithModernOverride.activityService).toBe(modernService); + }); + it("preserves endpoint path segments when joining URLs", () => { expect( joinActivityEndpointPath( diff --git a/packages/euler-v2-sdk/test/activityServicePublicTypes.test-d.ts b/packages/euler-v2-sdk/test/activityServicePublicTypes.test-d.ts new file mode 100644 index 00000000..d0178630 --- /dev/null +++ b/packages/euler-v2-sdk/test/activityServicePublicTypes.test-d.ts @@ -0,0 +1,39 @@ +import { describe, it } from "vitest"; +import type { EulerSDK, EulerSDKOptions } from "../src/sdk/sdk.js"; +import type { + IActivityService, + LiquidationsPage, +} from "../src/services/activityService/index.js"; + +/** + * Downstream compile fixtures for the public activity surface. This file is + * only typechecked (vitest typecheck mode), never executed — each assignment + * models a strict consumer that must keep compiling. + */ +describe("EulerSDK activity service public types", () => { + it("exposes callable liquidations on the built SDK without narrowing", () => { + const sdk = {} as EulerSDK; + // A strict consumer calls the guaranteed built-in method directly — + // no optional chaining, no narrowing. Regressing the property to the + // override-facing contract makes this TS2722. + const page: Promise = + sdk.activityService.fetchLiquidations({ chainId: 1 }); + void page; + }); + + it("keeps legacy custom-service overrides assignable", () => { + // A pre-liquidations override object, exactly as an integrator wrote + // it against the previous release: no fetchLiquidations. + const legacyOverride = {} as Pick< + IActivityService, + | "getCapabilities" + | "getScopeSupport" + | "fetchAccountActivityEvents" + | "fetchVaultActivityEvents" + >; + const options: Pick = { + activityService: legacyOverride, + }; + void options; + }); +}); diff --git a/packages/euler-v2-sdk/tsconfig.typetest.json b/packages/euler-v2-sdk/tsconfig.typetest.json new file mode 100644 index 00000000..d4583339 --- /dev/null +++ b/packages/euler-v2-sdk/tsconfig.typetest.json @@ -0,0 +1,7 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "noEmit": true + }, + "include": ["src/**/*.ts", "test/*.test-d.ts"] +} diff --git a/packages/euler-v2-sdk/vitest.config.ts b/packages/euler-v2-sdk/vitest.config.ts index 102b5c1f..3332aff7 100644 --- a/packages/euler-v2-sdk/vitest.config.ts +++ b/packages/euler-v2-sdk/vitest.config.ts @@ -4,5 +4,10 @@ export default defineConfig({ test: { environment: "node", include: ["test/*.test.ts"], + typecheck: { + enabled: true, + include: ["test/*.test-d.ts"], + tsconfig: "./tsconfig.typetest.json", + }, }, }); From 3feddd536d71bdee009e93c2d01ab424d66fe331 Mon Sep 17 00:00:00 2001 From: Seranged <80223622+Seranged@users.noreply.github.com> Date: Thu, 23 Jul 2026 17:21:09 +0100 Subject: [PATCH 5/7] fix: enforce the liquidation valuation discriminant against the USD legs The v3 contract couples valuation status to the two USD legs (available = both repayAssetsUsd and collateralAssetsUsd, partial = exactly one, unavailable = neither) and requires the historical-price-snapshots source. The generic valuation reader enforced neither, so a contradictory row could report available while carrying no historical valuation. Covered by a table-driven rejection matrix over every contradictory combination plus the live partial shape as acceptance. --- .../services/activityService/activityEvent.ts | 40 ++++++- .../euler-v2-sdk/test/activityService.test.ts | 105 +++++++++++++++++- 2 files changed, 143 insertions(+), 2 deletions(-) diff --git a/packages/euler-v2-sdk/src/services/activityService/activityEvent.ts b/packages/euler-v2-sdk/src/services/activityService/activityEvent.ts index 6130e252..74c6781f 100644 --- a/packages/euler-v2-sdk/src/services/activityService/activityEvent.ts +++ b/packages/euler-v2-sdk/src/services/activityService/activityEvent.ts @@ -942,6 +942,39 @@ export const getActivityCaller = ( ): Address | undefined => event.actor ?? readPayloadAddress(event, ["caller", "sender", "owner"]); +const LIQUIDATION_VALUATION_SOURCE = "historical-price-snapshots"; + +/** + * The v3 liquidations contract couples the valuation discriminant to the two + * USD legs — available: both `repayAssetsUsd` and `collateralAssetsUsd` + * present, partial: exactly one, unavailable: neither — and requires the + * historical-price-snapshots source. A contradictory row would let consumers + * trust `available` while receiving no historical valuation. + */ +const readLiquidationValuation = ( + value: unknown, + path: string, + presentUsdLegs: number, +): ActivityValuation => { + const valuation = readValuation(value, path); + if (valuation.source !== LIQUIDATION_VALUATION_SOURCE) { + fail(`${path}.source`, `expected ${LIQUIDATION_VALUATION_SOURCE}`); + } + const expectedStatus = + presentUsdLegs === 2 + ? "available" + : presentUsdLegs === 1 + ? "partial" + : "unavailable"; + if (valuation.status !== expectedStatus) { + fail( + `${path}.status`, + `expected ${expectedStatus} with ${presentUsdLegs} valued liquidation leg(s)`, + ); + } + return valuation; +}; + /** Historical token metadata can be null when unavailable at the event. */ const readNullableMetadataAddress = ( value: unknown, @@ -1032,7 +1065,12 @@ const readLiquidationRecord = ( ...(collateralAssets != null ? { collateralAssets } : {}), ...(collateralAssetsUsd !== undefined ? { collateralAssetsUsd } : {}), ...(bonusUsd !== undefined ? { bonusUsd } : {}), - valuation: readValuation(record.valuation, `${path}.valuation`), + valuation: readLiquidationValuation( + record.valuation, + `${path}.valuation`, + (repayAssetsUsd !== undefined ? 1 : 0) + + (collateralAssetsUsd !== undefined ? 1 : 0), + ), blockNumber: readDecimalString(record.blockNumber, `${path}.blockNumber`), txHash: readTxHash(record.txHash, `${path}.txHash`), timestamp: readTimestamp(record.timestamp, `${path}.timestamp`), diff --git a/packages/euler-v2-sdk/test/activityService.test.ts b/packages/euler-v2-sdk/test/activityService.test.ts index 81020dd4..3193b4e1 100644 --- a/packages/euler-v2-sdk/test/activityService.test.ts +++ b/packages/euler-v2-sdk/test/activityService.test.ts @@ -1663,6 +1663,11 @@ describe("ActivityService", () => { ...baseOptions, activityService: legacyService, }); + // Documented boundary: wrapping replaces the override's identity, so + // only the declared IActivityService surface carries through — any + // undeclared custom extensions on a legacy override are not reachable + // via sdk.activityService. + expect(sdkWithLegacyOverride.activityService).not.toBe(legacyService); expect(sdkWithLegacyOverride.activityService.getCapabilities().adapter).toBe( "legacy-custom", ); @@ -1812,7 +1817,11 @@ describe("ActivityService liquidations", () => { collateralAssetPriceUsd: null, collateralAssets: null, collateralAssetsUsd: null, - valuation: { status: "unavailable", reason: "no snapshot" }, + valuation: { + status: "unavailable", + source: "historical-price-snapshots", + reason: "no snapshot", + }, }), ]); const tolerant = await service.fetchLiquidations({ chainId: 1 }); @@ -1887,6 +1896,100 @@ describe("ActivityService liquidations", () => { } }); + it("enforces the valuation discriminant against the USD legs", async () => { + const respond = (rows: unknown[]) => { + vi.stubGlobal( + "fetch", + vi.fn( + async () => + new Response(JSON.stringify(liquidationsPage(rows)), { + status: 200, + }), + ), + ); + }; + const service = new ActivityService({ endpoint: "/api/internal" }); + const legs = ( + repayAssetsUsd: number | null, + collateralAssetsUsd: number | null, + ) => ({ + repayAssetsUsd, + collateralAssetsUsd, + // Keep dependent fields consistent with the missing legs. + ...(repayAssetsUsd === null ? { debtAssetPriceUsd: null } : {}), + ...(collateralAssetsUsd === null + ? { collateralAssetPriceUsd: null, bonusUsd: null } + : {}), + }); + + // The v3 contract: available = both legs, partial = exactly one, + // unavailable = neither, always from historical-price-snapshots. + const contradictions: Array> = [ + // available with no or one valued leg. + liquidationRow({ + ...legs(null, null), + valuation: { status: "available", source: "historical-price-snapshots" }, + }), + liquidationRow({ + ...legs(0.5, null), + valuation: { status: "available", source: "historical-price-snapshots" }, + }), + // partial with neither or both legs. + liquidationRow({ + ...legs(null, null), + valuation: { status: "partial", source: "historical-price-snapshots" }, + }), + liquidationRow({ + valuation: { status: "partial", source: "historical-price-snapshots" }, + }), + // unavailable with one or both legs. + liquidationRow({ + ...legs(0.5, null), + valuation: { + status: "unavailable", + source: "historical-price-snapshots", + }, + }), + liquidationRow({ + valuation: { + status: "unavailable", + source: "historical-price-snapshots", + }, + }), + // Missing or foreign valuation source. + liquidationRow({ valuation: { status: "available" } }), + liquidationRow({ + valuation: { status: "available", source: "v3-prices" }, + }), + ]; + for (const row of contradictions) { + respond([row]); + await expect(service.fetchLiquidations({ chainId: 1 })).rejects.toThrow( + ActivityResponseValidationError, + ); + } + + // The live partial shape — one valued leg — is accepted. + respond([ + liquidationRow({ + ...legs(10.29, null), + collateralAssets: null, + valuation: { + status: "partial", + source: "historical-price-snapshots", + reason: + "Historical USD price or token metadata is unavailable for one liquidation leg", + }, + }), + ]); + const partial = await service.fetchLiquidations({ chainId: 1 }); + expect(partial.data[0]).toMatchObject({ + repayAssetsUsd: 10.29, + valuation: { status: "partial" }, + }); + expect(partial.data[0]?.collateralAssetsUsd).toBeUndefined(); + }); + it("rejects structurally valid pages that do not answer the request", async () => { const respond = (rows: unknown[], meta: Record = {}) => { vi.stubGlobal( From 80ced9b587380bd6b818b1e897d16efc4c092c0b Mon Sep 17 00:00:00 2001 From: Seranged <80223622+Seranged@users.noreply.github.com> Date: Fri, 24 Jul 2026 10:57:28 +0100 Subject: [PATCH 6/7] fix: keep the derived bonus coherent with its legs and bound historical decimals MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The producer emits bonusUsd (collateralAssetsUsd - repayAssetsUsd) exactly when both legs are valued; the parser now rejects a bonus without both inputs and valued legs without their derived bonus. Historical token decimals share the uint8 bound the sibling asset parser enforces — the producer nulls out-of-range values rather than emitting them. --- .../services/activityService/activityEvent.ts | 35 ++++++++++++--- .../euler-v2-sdk/test/activityService.test.ts | 44 +++++++++++++++++-- 2 files changed, 70 insertions(+), 9 deletions(-) diff --git a/packages/euler-v2-sdk/src/services/activityService/activityEvent.ts b/packages/euler-v2-sdk/src/services/activityService/activityEvent.ts index 74c6781f..fc17e2a4 100644 --- a/packages/euler-v2-sdk/src/services/activityService/activityEvent.ts +++ b/packages/euler-v2-sdk/src/services/activityService/activityEvent.ts @@ -985,10 +985,16 @@ const readNullableMetadataAddress = ( const readNullableMetadataDecimals = ( value: unknown, path: string, -): number | undefined => - value === undefined || value === null - ? undefined - : readNonNegativeInteger(value, path); +): number | undefined => { + if (value === undefined || value === null) return undefined; + const decimals = readNonNegativeInteger(value, path); + // Same uint8 bound the sibling asset parser enforces; the producer nulls + // historical decimals outside 0..255 rather than emitting them. + if (decimals > 255) { + fail(path, "expected an integer no greater than 255"); + } + return decimals; +}; const readLiquidationRecord = ( value: unknown, @@ -1039,6 +1045,24 @@ const readLiquidationRecord = ( allowNegative: true, }, ); + // The producer derives the bonus as collateralAssetsUsd - repayAssetsUsd + // and emits it exactly when both legs are valued. A bonus without its + // inputs (or valued legs without their derived bonus) is contradictory. + const presentUsdLegs = + (repayAssetsUsd !== undefined ? 1 : 0) + + (collateralAssetsUsd !== undefined ? 1 : 0); + if (presentUsdLegs === 2 && bonusUsd === undefined) { + fail( + `${path}.bonusUsd`, + "expected a bonus when both liquidation legs are valued", + ); + } + if (presentUsdLegs < 2 && bonusUsd !== undefined) { + fail( + `${path}.bonusUsd`, + "expected no bonus without both valued liquidation legs", + ); + } return { chainId: readPositiveInteger(record.chainId, `${path}.chainId`), @@ -1068,8 +1092,7 @@ const readLiquidationRecord = ( valuation: readLiquidationValuation( record.valuation, `${path}.valuation`, - (repayAssetsUsd !== undefined ? 1 : 0) + - (collateralAssetsUsd !== undefined ? 1 : 0), + presentUsdLegs, ), blockNumber: readDecimalString(record.blockNumber, `${path}.blockNumber`), txHash: readTxHash(record.txHash, `${path}.txHash`), diff --git a/packages/euler-v2-sdk/test/activityService.test.ts b/packages/euler-v2-sdk/test/activityService.test.ts index 3193b4e1..11731d00 100644 --- a/packages/euler-v2-sdk/test/activityService.test.ts +++ b/packages/euler-v2-sdk/test/activityService.test.ts @@ -1808,10 +1808,16 @@ describe("ActivityService liquidations", () => { }; const service = new ActivityService({ endpoint: "/api/internal" }); - // Unprofitable liquidation and missing historical prices are valid. + // Unprofitable liquidation (negative bonus with both valued legs) and + // missing historical prices (no bonus, no legs) are both valid. respond([ liquidationRow({ + repayAssetsUsd: 1.0, + collateralAssetsUsd: 0.75, bonusUsd: -0.25, + }), + liquidationRow({ + bonusUsd: null, debtAssetPriceUsd: null, repayAssetsUsd: null, collateralAssetPriceUsd: null, @@ -1826,8 +1832,9 @@ describe("ActivityService liquidations", () => { ]); const tolerant = await service.fetchLiquidations({ chainId: 1 }); expect(tolerant.data[0]).toMatchObject({ bonusUsd: -0.25 }); - expect(tolerant.data[0]?.collateralAssets).toBeUndefined(); - expect(tolerant.data[0]?.repayAssetsUsd).toBeUndefined(); + expect(tolerant.data[1]?.collateralAssets).toBeUndefined(); + expect(tolerant.data[1]?.repayAssetsUsd).toBeUndefined(); + expect(tolerant.data[1]?.bonusUsd).toBeUndefined(); respond([liquidationRow({ violator: "not-an-address" })]); await expect(service.fetchLiquidations({ chainId: 1 })).rejects.toThrow( @@ -1961,6 +1968,27 @@ describe("ActivityService liquidations", () => { liquidationRow({ valuation: { status: "available", source: "v3-prices" }, }), + // The derived bonus must exist exactly when both legs are valued: + // a P&L figure without its valuation inputs (or valued legs + // without their derived bonus) contradicts the producer. + liquidationRow({ + ...legs(0.5, null), + bonusUsd: 0.1, + valuation: { status: "partial", source: "historical-price-snapshots" }, + }), + liquidationRow({ + ...legs(null, null), + bonusUsd: -0.25, + valuation: { + status: "unavailable", + source: "historical-price-snapshots", + }, + }), + liquidationRow({ bonusUsd: null }), + // Historical token decimals share the uint8 bound the producer + // and the sibling asset parser enforce. + liquidationRow({ debtAssetDecimals: 256 }), + liquidationRow({ collateralAssetDecimals: 256 }), ]; for (const row of contradictions) { respond([row]); @@ -1988,6 +2016,16 @@ describe("ActivityService liquidations", () => { valuation: { status: "partial" }, }); expect(partial.data[0]?.collateralAssetsUsd).toBeUndefined(); + + // The uint8 boundary itself is valid. + respond([ + liquidationRow({ debtAssetDecimals: 255, collateralAssetDecimals: 255 }), + ]); + const boundary = await service.fetchLiquidations({ chainId: 1 }); + expect(boundary.data[0]).toMatchObject({ + debtAssetDecimals: 255, + collateralAssetDecimals: 255, + }); }); it("rejects structurally valid pages that do not answer the request", async () => { From e85b16db9a52704be9fb6d5868332301a0355080 Mon Sep 17 00:00:00 2001 From: Seranged <80223622+Seranged@users.noreply.github.com> Date: Tue, 28 Jul 2026 10:17:51 +0100 Subject: [PATCH 7/7] feat: validate liquidation unit-of-account fallback --- .../services/activityService/activityEvent.ts | 78 ++++++++++++++++++- .../activityService/activityServiceTypes.ts | 19 +++++ .../src/services/activityService/index.ts | 1 + .../euler-v2-sdk/test/activityService.test.ts | 72 +++++++++++++++++ 4 files changed, 168 insertions(+), 2 deletions(-) diff --git a/packages/euler-v2-sdk/src/services/activityService/activityEvent.ts b/packages/euler-v2-sdk/src/services/activityService/activityEvent.ts index fc17e2a4..b62b9d08 100644 --- a/packages/euler-v2-sdk/src/services/activityService/activityEvent.ts +++ b/packages/euler-v2-sdk/src/services/activityService/activityEvent.ts @@ -20,11 +20,12 @@ import type { ActivityValueChange, ActivityVaultType, FetchAccountActivityEventsArgs, - FetchVaultActivityEventsArgs, FetchLiquidationsArgs, + FetchVaultActivityEventsArgs, LiquidationRecord, LiquidationsMeta, LiquidationsPage, + LiquidationUnitOfAccountValuation, } from "./activityServiceTypes.js"; import { ACTIVITY_EVENT_TYPES } from "./activityServiceTypes.js"; @@ -67,6 +68,7 @@ const ASSET_KINDS = [ const TX_HASH_PATTERN = /^0x[0-9a-fA-F]{64}$/; const DECIMAL_INTEGER_PATTERN = /^\d+$/; +const SIGNED_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}))$/; @@ -182,6 +184,14 @@ const readDecimalString = (value: unknown, path: string): string => { return decimal; }; +const readSignedDecimalString = (value: unknown, path: string): string => { + const decimal = readString(value, path); + if (!SIGNED_DECIMAL_INTEGER_PATTERN.test(decimal)) { + fail(path, "expected a signed 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"); @@ -996,11 +1006,69 @@ const readNullableMetadataDecimals = ( return decimals; }; +const LIQUIDATION_UNIT_OF_ACCOUNT_SOURCE = "historical-protocol-oracle"; + +const readLiquidationUnitOfAccountValuation = ( + value: unknown, + path: string, + liquidationBlockNumber: string, +): LiquidationUnitOfAccountValuation | null => { + if (value === null) return null; + const record = readRecord(value, path); + if (record.source !== LIQUIDATION_UNIT_OF_ACCOUNT_SOURCE) { + fail(`${path}.source`, `expected ${LIQUIDATION_UNIT_OF_ACCOUNT_SOURCE}`); + } + let unitOfAccountDecimals: number | null; + if (record.unitOfAccountDecimals === null) { + unitOfAccountDecimals = null; + } else { + const parsedDecimals = readNullableMetadataDecimals( + record.unitOfAccountDecimals, + `${path}.unitOfAccountDecimals`, + ); + unitOfAccountDecimals = + parsedDecimals ?? + fail(`${path}.unitOfAccountDecimals`, "expected an integer or null"); + } + const repayValue = readDecimalString(record.repayValue, `${path}.repayValue`); + const collateralValue = readDecimalString( + record.collateralValue, + `${path}.collateralValue`, + ); + const bonusValue = readSignedDecimalString( + record.bonusValue, + `${path}.bonusValue`, + ); + const blockNumber = readDecimalString( + record.blockNumber, + `${path}.blockNumber`, + ); + if (blockNumber !== liquidationBlockNumber) { + fail(`${path}.blockNumber`, "expected the liquidation block number"); + } + if (BigInt(bonusValue) !== BigInt(collateralValue) - BigInt(repayValue)) { + fail(`${path}.bonusValue`, "expected collateralValue minus repayValue"); + } + return { + source: LIQUIDATION_UNIT_OF_ACCOUNT_SOURCE, + unitOfAccount: readAddress(record.unitOfAccount, `${path}.unitOfAccount`), + unitOfAccountDecimals, + repayValue, + collateralValue, + bonusValue, + blockNumber, + }; +}; + const readLiquidationRecord = ( value: unknown, path: string, ): LiquidationRecord => { const record = readRecord(value, path); + const blockNumber = readDecimalString( + record.blockNumber, + `${path}.blockNumber`, + ); const debtAsset = readNullableMetadataAddress( record.debtAsset, `${path}.debtAsset`, @@ -1063,6 +1131,11 @@ const readLiquidationRecord = ( "expected no bonus without both valued liquidation legs", ); } + const unitOfAccountValuation = readLiquidationUnitOfAccountValuation( + record.unitOfAccountValuation, + `${path}.unitOfAccountValuation`, + blockNumber, + ); return { chainId: readPositiveInteger(record.chainId, `${path}.chainId`), @@ -1089,12 +1162,13 @@ const readLiquidationRecord = ( ...(collateralAssets != null ? { collateralAssets } : {}), ...(collateralAssetsUsd !== undefined ? { collateralAssetsUsd } : {}), ...(bonusUsd !== undefined ? { bonusUsd } : {}), + unitOfAccountValuation, valuation: readLiquidationValuation( record.valuation, `${path}.valuation`, presentUsdLegs, ), - blockNumber: readDecimalString(record.blockNumber, `${path}.blockNumber`), + blockNumber, txHash: readTxHash(record.txHash, `${path}.txHash`), timestamp: readTimestamp(record.timestamp, `${path}.timestamp`), }; diff --git a/packages/euler-v2-sdk/src/services/activityService/activityServiceTypes.ts b/packages/euler-v2-sdk/src/services/activityService/activityServiceTypes.ts index 593925bd..206173d4 100644 --- a/packages/euler-v2-sdk/src/services/activityService/activityServiceTypes.ts +++ b/packages/euler-v2-sdk/src/services/activityService/activityServiceTypes.ts @@ -278,6 +278,20 @@ export interface FetchLiquidationsArgs { offset?: number; } +export interface LiquidationUnitOfAccountValuation { + source: "historical-protocol-oracle"; + unitOfAccount: Address; + unitOfAccountDecimals: number | null; + /** Debt repaid, denominated in the protocol oracle's unit of account. */ + repayValue: string; + /** Collateral seized, denominated in the protocol oracle's unit of account. */ + collateralValue: string; + /** Signed collateral value minus repay value. */ + bonusValue: string; + /** Liquidation block used for the historical protocol-oracle quote. */ + blockNumber: string; +} + export interface LiquidationRecord { chainId: number; vault: Address; @@ -304,6 +318,11 @@ export interface LiquidationRecord { collateralAssetsUsd?: number; /** Liquidator bonus (collateral seized minus debt repaid) in event-time USD. */ bonusUsd?: number; + /** + * Historical protocol-oracle fallback when a USD snapshot cannot value both + * legs. Null when the producer cannot reconstruct a trustworthy quote. + */ + unitOfAccountValuation: LiquidationUnitOfAccountValuation | null; valuation: ActivityValuation; blockNumber: string; txHash: Hex; diff --git a/packages/euler-v2-sdk/src/services/activityService/index.ts b/packages/euler-v2-sdk/src/services/activityService/index.ts index b325afec..cb6804a2 100644 --- a/packages/euler-v2-sdk/src/services/activityService/index.ts +++ b/packages/euler-v2-sdk/src/services/activityService/index.ts @@ -49,6 +49,7 @@ export type { LiquidationRecord, LiquidationsMeta, LiquidationsPage, + LiquidationUnitOfAccountValuation, } from "./activityServiceTypes.js"; export { ACTIVITY_EVENT_TYPES } from "./activityServiceTypes.js"; export { diff --git a/packages/euler-v2-sdk/test/activityService.test.ts b/packages/euler-v2-sdk/test/activityService.test.ts index 11731d00..f23e688b 100644 --- a/packages/euler-v2-sdk/test/activityService.test.ts +++ b/packages/euler-v2-sdk/test/activityService.test.ts @@ -1717,6 +1717,7 @@ describe("ActivityService liquidations", () => { collateralAssets: "530677", collateralAssetsUsd: 0.5305625329711, bonusUsd: 0.0795838300643, + unitOfAccountValuation: null, valuation: { status: "available", source: "historical-price-snapshots" }, blockNumber: "25181865", txHash: LIQUIDATION_TX, @@ -1852,6 +1853,77 @@ describe("ActivityService liquidations", () => { ); }); + it("normalizes nullable historical protocol-oracle valuations", async () => { + const respond = (rows: unknown[]) => { + vi.stubGlobal( + "fetch", + vi.fn( + async () => + new Response(JSON.stringify(liquidationsPage(rows)), { + status: 200, + }), + ), + ); + }; + const service = new ActivityService({ endpoint: "/api/internal" }); + const fallback = { + source: "historical-protocol-oracle", + unitOfAccount: "0x0000000000000000000000000000000000000348", + unitOfAccountDecimals: 18, + repayValue: "1000000000000000000", + collateralValue: "2140000000000000000", + bonusValue: "1140000000000000000", + blockNumber: "25181865", + }; + + respond([ + liquidationRow({ unitOfAccountValuation: fallback }), + liquidationRow({ unitOfAccountValuation: null }), + ]); + const page = await service.fetchLiquidations({ chainId: 1 }); + expect(page.data[0]?.unitOfAccountValuation).toEqual({ + ...fallback, + unitOfAccount: "0x0000000000000000000000000000000000000348", + }); + expect(page.data[1]?.unitOfAccountValuation).toBeNull(); + + const malformed = [ + undefined, + { ...fallback, source: "historical-price-snapshots" }, + { ...fallback, unitOfAccount: "not-an-address" }, + { ...fallback, unitOfAccountDecimals: 256 }, + { ...fallback, unitOfAccountDecimals: undefined }, + { ...fallback, repayValue: "-1" }, + { ...fallback, collateralValue: "1.5" }, + { ...fallback, bonusValue: "+1140000000000000000" }, + { ...fallback, bonusValue: "1139999999999999999" }, + { ...fallback, blockNumber: "25181866" }, + ]; + for (const unitOfAccountValuation of malformed) { + respond([liquidationRow({ unitOfAccountValuation })]); + await expect(service.fetchLiquidations({ chainId: 1 })).rejects.toThrow( + ActivityResponseValidationError, + ); + } + + respond([ + liquidationRow({ + unitOfAccountValuation: { + ...fallback, + unitOfAccountDecimals: null, + repayValue: "2", + collateralValue: "1", + bonusValue: "-1", + }, + }), + ]); + const negative = await service.fetchLiquidations({ chainId: 1 }); + expect(negative.data[0]?.unitOfAccountValuation).toMatchObject({ + unitOfAccountDecimals: null, + bonusValue: "-1", + }); + }); + it("accepts live rows with null historical token metadata", async () => { // Mirrors production pages where the conversion is unavailable at the // event (e.g. mainnet tx 0xef7d…200d): every metadata and USD field