From baaeb7271f4f081b1cdc772ec570be300c51ba4c Mon Sep 17 00:00:00 2001 From: Leonard Euler Date: Tue, 28 Jul 2026 11:23:33 +0000 Subject: [PATCH 1/6] fix(sdk): resolve AccountLens ABI at runtime --- packages/euler-v2-sdk/src/sdk/buildSDK.ts | 8 +- .../src/services/abiService/abiService.ts | 14 +- .../accountOnchainAdapter.ts | 27 +++- .../executionService/executionService.ts | 7 + .../src/services/executionService/simulate.ts | 15 +- .../services/rewardsService/rewardsService.ts | 21 ++- .../test/accountLensAbiService.test.ts | 147 ++++++++++++++++++ packages/euler-v2-sdk/test/simulate.test.ts | 28 ++++ 8 files changed, 253 insertions(+), 14 deletions(-) create mode 100644 packages/euler-v2-sdk/test/accountLensAbiService.test.ts diff --git a/packages/euler-v2-sdk/src/sdk/buildSDK.ts b/packages/euler-v2-sdk/src/sdk/buildSDK.ts index fc6a8116..a77f0e9d 100644 --- a/packages/euler-v2-sdk/src/sdk/buildSDK.ts +++ b/packages/euler-v2-sdk/src/sdk/buildSDK.ts @@ -651,6 +651,7 @@ export async function buildEulerSDK< deploymentService as DeploymentService, accountVaultsAdapter, resolvedBuildQuery, + abiService, ); return accountOnchainAdapter; }; @@ -1419,7 +1420,10 @@ export async function buildEulerSDK< fuulManagerAddress: directAdapter.getFuulManagerAddress(), fuulFactoryAddress: directAdapter.getFuulFactoryAddress(), }, - { isActiveForViewer: rewardsServiceConfig?.isActiveForViewer }, + { + isActiveForViewer: rewardsServiceConfig?.isActiveForViewer, + abiService, + }, ); })(); @@ -1571,6 +1575,7 @@ export async function buildEulerSDK< ); if (executionService instanceof ExecutionService) { + executionService.setABIService(abiService); executionService.setProviderService(providerService as ProviderService); executionService.setVaultMetaService( vaultMetaService as IVaultMetaService, @@ -1611,6 +1616,7 @@ export async function buildEulerSDK< } if (rewardsService instanceof RewardsService) { + rewardsService.setABIService(abiService); rewardsService.setProviderService(providerService as ProviderService); rewardsService.setDeploymentService(deploymentService as DeploymentService); } diff --git a/packages/euler-v2-sdk/src/services/abiService/abiService.ts b/packages/euler-v2-sdk/src/services/abiService/abiService.ts index 496d628a..4a050911 100644 --- a/packages/euler-v2-sdk/src/services/abiService/abiService.ts +++ b/packages/euler-v2-sdk/src/services/abiService/abiService.ts @@ -6,7 +6,7 @@ export interface IABIService { } export class ABIService implements IABIService { - private readonly abis: Record = {}; + private readonly abiRequests: Record> = {}; constructor(buildQuery?: BuildQueryFn) { if (buildQuery) applyBuildQuery(this, buildQuery); @@ -26,10 +26,16 @@ export class ABIService implements IABIService { } async fetchABI(_: number, contract: string): Promise { - if (!this.abis[contract]) { - this.abis[contract] = await this.queryABI(this.getABIURL(_, contract)); + if (!this.abiRequests[contract]) { + const request = this.queryABI(this.getABIURL(_, contract)).catch((error) => { + if (this.abiRequests[contract] === request) { + delete this.abiRequests[contract]; + } + throw error; + }); + this.abiRequests[contract] = request; } - return this.abis[contract]; + return this.abiRequests[contract]; } } diff --git a/packages/euler-v2-sdk/src/services/accountService/adapters/accountOnchainAdapter/accountOnchainAdapter.ts b/packages/euler-v2-sdk/src/services/accountService/adapters/accountOnchainAdapter/accountOnchainAdapter.ts index 06075bf3..f552d353 100644 --- a/packages/euler-v2-sdk/src/services/accountService/adapters/accountOnchainAdapter/accountOnchainAdapter.ts +++ b/packages/euler-v2-sdk/src/services/accountService/adapters/accountOnchainAdapter/accountOnchainAdapter.ts @@ -1,6 +1,7 @@ import type { IAccountAdapter } from "../../accountService.js"; import type { ProviderService } from "../../../providerService/index.js"; import type { DeploymentService } from "../../../deploymentService/index.js"; +import type { IABIService } from "../../../abiService/index.js"; import { type Address, type Abi, encodeFunctionData, getAddress } from "viem"; import type { IAccount, ISubAccount } from "../../../../entities/Account.js"; import { EVault } from "../../../../entities/EVault.js"; @@ -38,12 +39,13 @@ export const getEVCAccountInfoLensBatchItem = ( evc: Address, subAccount: Address, onBehalfOfAccount: Address, + abi: Abi = accountLensAbi, ): EVCBatchItem => ({ targetContract: accountLensAddress, onBehalfOfAccount, value: 0n, data: encodeFunctionData({ - abi: accountLensAbi, + abi, functionName: "getEVCAccountInfo", args: [evc, subAccount], }), @@ -54,12 +56,13 @@ export const getVaultAccountInfoLensBatchItem = ( subAccount: Address, vault: Address, onBehalfOfAccount: Address, + abi: Abi = accountLensAbi, ): EVCBatchItem => ({ targetContract: accountLensAddress, onBehalfOfAccount, value: 0n, data: encodeFunctionData({ - abi: accountLensAbi, + abi, functionName: "getVaultAccountInfo", args: [subAccount, vault], }), @@ -78,6 +81,7 @@ export class AccountOnchainAdapter implements IAccountAdapter { private deploymentService: DeploymentService, private positionsAdapter: IAccountVaultsAdapter, buildQuery?: BuildQueryFn, + private abiService?: IABIService, ) { if (buildQuery) applyBuildQuery(this, buildQuery); } @@ -103,10 +107,11 @@ export class AccountOnchainAdapter implements IAccountAdapter { accountLensAddress: Address, evc: Address, subAccount: Address, + abi: Abi = accountLensAbi, ) => { return provider.readContract({ address: accountLensAddress, - abi: accountLensAbi, + abi, functionName: "getEVCAccountInfo", args: [evc, subAccount], }); @@ -121,10 +126,11 @@ export class AccountOnchainAdapter implements IAccountAdapter { accountLensAddress: Address, subAccount: Address, vault: Address, + abi: Abi = accountLensAbi, ) => { return provider.readContract({ address: accountLensAddress, - abi: accountLensAbi, + abi, functionName: "getVaultAccountInfo", args: [subAccount, vault], }); @@ -243,6 +249,9 @@ export class AccountOnchainAdapter implements IAccountAdapter { const deployment = this.deploymentService.getDeployment(chainId); const accountLensAddress = deployment.addresses.lensAddrs.accountLens; const evc = deployment.addresses.coreAddrs.evc; + const resolvedAccountLensAbi = + (await this.abiService?.fetchABI(chainId, "AccountLens")) ?? + accountLensAbi; // Get EVC account info const evcAccountInfoResult = await this.queryEVCAccountInfo( @@ -250,6 +259,7 @@ export class AccountOnchainAdapter implements IAccountAdapter { accountLensAddress, evc, subAccount, + resolvedAccountLensAbi, ); if (!evcAccountInfoResult) return { result: undefined, errors }; @@ -274,6 +284,7 @@ export class AccountOnchainAdapter implements IAccountAdapter { subAccount, vaults, errors, + resolvedAccountLensAbi, ); } else { vaultAccountInfos = await this.queryVaultAccountInfosGracefully( @@ -283,6 +294,7 @@ export class AccountOnchainAdapter implements IAccountAdapter { subAccount, vaults, errors, + resolvedAccountLensAbi, ); } @@ -357,6 +369,7 @@ export class AccountOnchainAdapter implements IAccountAdapter { subAccount: Address, vaults: Address[], errors: DataIssue[], + resolvedAccountLensAbi: Abi, ): Promise { const deployment = this.deploymentService.getDeployment(chainId); const vaultLensAddress = deployment.addresses.lensAddrs.vaultLens; @@ -397,6 +410,7 @@ export class AccountOnchainAdapter implements IAccountAdapter { subAccount, vaults, errors, + resolvedAccountLensAbi, ); } @@ -411,7 +425,7 @@ export class AccountOnchainAdapter implements IAccountAdapter { prependItems: prepend.items, totalValue: prepend.totalValue, lensAddress: accountLensAddress, - lensAbi: accountLensAbi as unknown as Abi, + lensAbi: resolvedAccountLensAbi, lensFunctionName: "getVaultAccountInfo", lensArgs: [subAccount, vault], }, @@ -428,6 +442,7 @@ export class AccountOnchainAdapter implements IAccountAdapter { accountLensAddress, subAccount, vault, + resolvedAccountLensAbi, ) as Promise; }), ); @@ -448,6 +463,7 @@ export class AccountOnchainAdapter implements IAccountAdapter { subAccount: Address, vaults: Address[], errors: DataIssue[], + resolvedAccountLensAbi: Abi, ): Promise { const results = await Promise.allSettled( vaults.map((vault) => @@ -456,6 +472,7 @@ export class AccountOnchainAdapter implements IAccountAdapter { accountLensAddress, subAccount, vault, + resolvedAccountLensAbi, ), ), ); diff --git a/packages/euler-v2-sdk/src/services/executionService/executionService.ts b/packages/euler-v2-sdk/src/services/executionService/executionService.ts index d7e11d6c..23e0e261 100644 --- a/packages/euler-v2-sdk/src/services/executionService/executionService.ts +++ b/packages/euler-v2-sdk/src/services/executionService/executionService.ts @@ -19,6 +19,7 @@ import type { import type { EulerPlugin, PluginPrefetchData } from "../../plugins/types.js"; import { resolveBorrowCollateralPositions } from "../../utils/accountPositionClassification.js"; import type { IDeploymentService } from "../deploymentService/index.js"; +import type { IABIService } from "../abiService/index.js"; import type { IEulerLabelsService } from "../eulerLabelsService/index.js"; import type { IIntrinsicApyService } from "../intrinsicApyService/index.js"; import type { IPriceService } from "../priceService/index.js"; @@ -764,6 +765,7 @@ export class ExecutionService private rewardsService?: IRewardsService; private intrinsicApyService?: IIntrinsicApyService; private eulerLabelsService?: IEulerLabelsService; + private abiService?: IABIService; private processPlugins?: ProcessPlanPlugins; private prefetchPlugins?: PrefetchPlanPlugins; @@ -814,6 +816,10 @@ export class ExecutionService this.eulerLabelsService = eulerLabelsService; } + setABIService(abiService: IABIService): void { + this.abiService = abiService; + } + setPlugins(plugins: EulerPlugin[]): void { this.plugins = plugins; } @@ -1084,6 +1090,7 @@ export class ExecutionService rewardsService: this.rewardsService, intrinsicApyService: this.intrinsicApyService, eulerLabelsService: this.eulerLabelsService, + abiService: this.abiService, describeBatch: (batch) => this.describeBatch(batch), }; } diff --git a/packages/euler-v2-sdk/src/services/executionService/simulate.ts b/packages/euler-v2-sdk/src/services/executionService/simulate.ts index b5366852..6c2c8b86 100644 --- a/packages/euler-v2-sdk/src/services/executionService/simulate.ts +++ b/packages/euler-v2-sdk/src/services/executionService/simulate.ts @@ -1,4 +1,5 @@ import { + type Abi, type Address, decodeFunctionData, decodeFunctionResult, @@ -124,6 +125,7 @@ import { getEVCAccountInfoLensBatchItem, getVaultAccountInfoLensBatchItem, } from "../accountService/adapters/accountOnchainAdapter/accountOnchainAdapter.js"; +import type { IABIService } from "../abiService/index.js"; import type { IDeploymentService } from "../deploymentService/index.js"; import type { IEulerLabelsService } from "../eulerLabelsService/index.js"; import type { IIntrinsicApyService } from "../intrinsicApyService/index.js"; @@ -309,6 +311,7 @@ export type ExecutionSimulationContext< rewardsService?: IRewardsService; intrinsicApyService?: IIntrinsicApyService; eulerLabelsService?: IEulerLabelsService; + abiService?: IABIService; describeBatch: (batch: readonly EVCBatchItem[]) => BatchItemDescription[]; }; @@ -461,6 +464,8 @@ export async function simulateTransactionPlan< canExecute: false, }; } + const resolvedAccountLensAbi = + (await ctx.abiService?.fetchABI(chainId, "AccountLens")) ?? accountLensAbi; const diagnostics = await fetchSimulationDiagnostics( ctx, chainId, @@ -472,6 +477,7 @@ export async function simulateTransactionPlan< chainId, owner, operations, + resolvedAccountLensAbi, extractBalanceRequirements(transactionPlan, owner).map(([token]) => token), ); @@ -579,6 +585,7 @@ export async function simulateTransactionPlan< chainId, owner, lensMeta, + resolvedAccountLensAbi, (i) => batchResults[slice.lensStart + i], options, ), @@ -754,6 +761,7 @@ async function decodeAccountSnapshot< chainId: number, owner: Address, lensMeta: LensMeta[], + resolvedAccountLensAbi: Abi, resultAt: (index: number) => BatchItemResult | undefined, options?: SimulateBatchOptions, ): Promise<{ @@ -840,7 +848,7 @@ async function decodeAccountSnapshot< if (meta.kind === "evcAccount") { const decodedAccount = decodeFunctionResult({ - abi: accountLensAbi, + abi: resolvedAccountLensAbi, functionName: "getEVCAccountInfo", data: resultItem.result, }) as unknown as EVCAccountInfo; @@ -849,7 +857,7 @@ async function decodeAccountSnapshot< if (meta.kind === "vaultAccount") { const decodedVaultInfo = decodeFunctionResult({ - abi: accountLensAbi, + abi: resolvedAccountLensAbi, functionName: "getVaultAccountInfo", data: resultItem.result, }) as unknown as VaultAccountInfo; @@ -1075,6 +1083,7 @@ async function buildSimulationBatch( chainId: number, owner: Address, operations: SimulationOperation[], + resolvedAccountLensAbi: Abi, requiredWalletBalanceTokens: Address[] = [], ): Promise<{ lensItems: EVCBatchItem[]; @@ -1216,6 +1225,7 @@ async function buildSimulationBatch( evcAddress, subAccount, owner, + resolvedAccountLensAbi, ), { kind: "evcAccount", @@ -1231,6 +1241,7 @@ async function buildSimulationBatch( subAccount, vault, owner, + resolvedAccountLensAbi, ), { kind: "vaultAccount", diff --git a/packages/euler-v2-sdk/src/services/rewardsService/rewardsService.ts b/packages/euler-v2-sdk/src/services/rewardsService/rewardsService.ts index 62663bf1..1815031e 100644 --- a/packages/euler-v2-sdk/src/services/rewardsService/rewardsService.ts +++ b/packages/euler-v2-sdk/src/services/rewardsService/rewardsService.ts @@ -1,4 +1,5 @@ import { + type Abi, type Address, encodeFunctionData, getAddress, @@ -14,6 +15,7 @@ import type { } from "../executionService/index.js"; import type { ProviderService } from "../providerService/index.js"; import type { DeploymentService } from "../deploymentService/index.js"; +import type { IABIService } from "../abiService/index.js"; import { accountLensAbi } from "../accountService/adapters/accountOnchainAdapter/abis/accountLensAbi.js"; import type { VaultAccountInfo } from "../accountService/adapters/accountOnchainAdapter/accountLensTypes.js"; import type { @@ -445,6 +447,7 @@ const uniqueAddresses = ( export class RewardsService implements IRewardsService { private providerService?: ProviderService; private deploymentService?: DeploymentService; + private abiService?: IABIService; private isActiveForViewer: IsActiveForViewerFn; constructor( @@ -455,8 +458,12 @@ export class RewardsService implements IRewardsService { fuulFactoryAddress: Address; rewardStreamsAddress?: Address; }, - options?: { isActiveForViewer?: IsActiveForViewerFn }, + options?: { + isActiveForViewer?: IsActiveForViewerFn; + abiService?: IABIService; + }, ) { + this.abiService = options?.abiService; this.isActiveForViewer = options?.isActiveForViewer ?? defaultIsActiveForViewer; } @@ -473,6 +480,10 @@ export class RewardsService implements IRewardsService { this.deploymentService = deploymentService; } + setABIService(abiService: IABIService): void { + this.abiService = abiService; + } + setIsActiveForViewer(fn: IsActiveForViewerFn): void { this.isActiveForViewer = fn; } @@ -550,10 +561,11 @@ export class RewardsService implements IRewardsService { accountLensAddress: Address, account: Address, vault: Address, + abi: Abi = accountLensAbi, ): Promise => { return provider.readContract({ address: accountLensAddress, - abi: accountLensAbi, + abi, functionName: "getVaultAccountInfo", args: [account, vault], }) as Promise; @@ -580,6 +592,10 @@ export class RewardsService implements IRewardsService { }), ).values(), ); + if (uniquePositions.length === 0) return []; + const resolvedAccountLensAbi = + (await this.abiService?.fetchABI(args.chainId, "AccountLens")) ?? + accountLensAbi; const vaultAccountInfos = await Promise.all( uniquePositions.map((position) => @@ -588,6 +604,7 @@ export class RewardsService implements IRewardsService { accountLensAddress, position.account, position.vault, + resolvedAccountLensAbi, ), ), ); diff --git a/packages/euler-v2-sdk/test/accountLensAbiService.test.ts b/packages/euler-v2-sdk/test/accountLensAbiService.test.ts new file mode 100644 index 00000000..c60026df --- /dev/null +++ b/packages/euler-v2-sdk/test/accountLensAbiService.test.ts @@ -0,0 +1,147 @@ +import { describe, expect, it, vi } from "vitest"; +import type { Abi, Address } from "viem"; +import { AccountOnchainAdapter } from "../src/services/accountService/adapters/accountOnchainAdapter/accountOnchainAdapter.js"; +import { ABIService, type IABIService } from "../src/services/abiService/index.js"; +import { RewardsService } from "../src/services/rewardsService/rewardsService.js"; + +const ACCOUNT = "0x0000000000000000000000000000000000000001" as Address; +const EVC = "0x0000000000000000000000000000000000000002" as Address; +const ACCOUNT_LENS = "0x0000000000000000000000000000000000000003" as Address; +const VAULT = "0x0000000000000000000000000000000000000004" as Address; + +const runtimeAccountLensAbi = [ + { + type: "function", + name: "getEVCAccountInfo", + stateMutability: "view", + inputs: [ + { name: "evc", type: "address" }, + { name: "account", type: "address" }, + ], + outputs: [{ name: "marker", type: "bytes32" }], + }, + { + type: "function", + name: "getVaultAccountInfo", + stateMutability: "view", + inputs: [ + { name: "account", type: "address" }, + { name: "vault", type: "address" }, + ], + outputs: [{ name: "marker", type: "bytes32" }], + }, +] as const satisfies Abi; + +const makeAbiService = () => { + const fetchABI = vi.fn(async () => runtimeAccountLensAbi); + return { + service: { fetchABI } as IABIService, + fetchABI, + }; +}; + +const deploymentService = { + getDeployment: () => ({ + addresses: { + coreAddrs: { evc: EVC }, + lensAddrs: { accountLens: ACCOUNT_LENS }, + }, + }), +}; + +describe("AccountLens ABI service consumers", () => { + it("coalesces concurrent ABI requests and retries after a failed request", async () => { + const abiService = new ABIService(); + let resolveRequest: ((abi: Abi) => void) | undefined; + const queryABI = vi + .fn<() => Promise>() + .mockImplementationOnce( + () => + new Promise((resolve) => { + resolveRequest = resolve; + }), + ) + .mockRejectedValueOnce(new Error("temporary failure")) + .mockResolvedValue(runtimeAccountLensAbi); + abiService.setQueryABI(queryABI); + + const first = abiService.fetchABI(1, "AccountLens"); + const second = abiService.fetchABI(1, "AccountLens"); + expect(queryABI).toHaveBeenCalledOnce(); + resolveRequest?.(runtimeAccountLensAbi); + await expect(Promise.all([first, second])).resolves.toEqual([ + runtimeAccountLensAbi, + runtimeAccountLensAbi, + ]); + + await expect(abiService.fetchABI(1, "VaultLens")).rejects.toThrow( + "temporary failure", + ); + await expect(abiService.fetchABI(1, "VaultLens")).resolves.toBe( + runtimeAccountLensAbi, + ); + }); + + it("uses the ABI service for onchain account reads", async () => { + const { service: abiService, fetchABI } = makeAbiService(); + const readContract = vi.fn(async ({ abi }: { abi: Abi }) => { + expect(abi).toBe(runtimeAccountLensAbi); + return { + timestamp: 1n, + evc: EVC, + account: ACCOUNT, + addressPrefix: "0x00000000000000000000000000000000000000", + owner: ACCOUNT, + isLockdownMode: false, + isPermitDisabledMode: false, + lastAccountStatusCheckTimestamp: 0n, + enabledControllers: [], + enabledCollaterals: [], + }; + }); + const adapter = new AccountOnchainAdapter( + { getProvider: () => ({ readContract }) } as never, + deploymentService as never, + { fetchAccountVaults: vi.fn() } as never, + undefined, + abiService, + ); + + await adapter.fetchSubAccount(1, ACCOUNT); + + expect(fetchABI).toHaveBeenCalledWith(1, "AccountLens"); + expect(readContract).toHaveBeenCalledOnce(); + }); + + it("uses the ABI service for reward-stream account reads", async () => { + const { service: abiService, fetchABI } = makeAbiService(); + const readContract = vi.fn(async ({ abi }: { abi: Abi }) => { + expect(abi).toBe(runtimeAccountLensAbi); + return { + account: ACCOUNT, + vault: VAULT, + enabledRewardsInfo: [], + }; + }); + const rewards = new RewardsService( + { fetchVaultRewards: vi.fn(), fetchChainRewards: vi.fn() } as never, + { + merklDistributorAddress: ACCOUNT, + fuulManagerAddress: ACCOUNT, + fuulFactoryAddress: ACCOUNT, + }, + { abiService }, + ); + rewards.setProviderService({ getProvider: () => ({ readContract }) } as never); + rewards.setDeploymentService(deploymentService as never); + + await rewards.fetchRewardStreams({ + chainId: 1, + account: ACCOUNT, + positions: [{ account: ACCOUNT, vault: VAULT }], + }); + + expect(fetchABI).toHaveBeenCalledWith(1, "AccountLens"); + expect(readContract).toHaveBeenCalledOnce(); + }); +}); diff --git a/packages/euler-v2-sdk/test/simulate.test.ts b/packages/euler-v2-sdk/test/simulate.test.ts index f47b24af..2a462281 100644 --- a/packages/euler-v2-sdk/test/simulate.test.ts +++ b/packages/euler-v2-sdk/test/simulate.test.ts @@ -10,6 +10,7 @@ import { } from "viem"; import { estimateContractGas } from "viem/actions"; import { Account } from "../src/entities/Account.js"; +import type { IABIService } from "../src/services/abiService/index.js"; import { accountLensAbi } from "../src/services/accountService/adapters/accountOnchainAdapter/abis/accountLensAbi.js"; import { ethereumVaultConnectorAbi } from "../src/services/executionService/abis/ethereumVaultConnectorAbi.js"; import { eVaultAbi } from "../src/services/executionService/abis/eVaultAbi.js"; @@ -126,6 +127,7 @@ async function simulateAndCollectVaultAccountReads( vaultTypes: Record = { [getAddress(TARGET)]: VaultType.EVault, }, + abiService?: IABIService, ): Promise> { const simulateContract = vi.fn( async ({ args }: { args: readonly [EVCBatchItem[]] }) => { @@ -174,6 +176,7 @@ async function simulateAndCollectVaultAccountReads( fetchVaultTypes: async () => vaultTypes, } as never, ); + if (abiService) service.setABIService(abiService); await service.simulateTransactionPlan(1, CHECKSUM_ACCOUNT, plan, { stateOverrides: false, @@ -887,6 +890,31 @@ test("simulateTransactionPlan reads both sides of transferFromMax cleanup", asyn ); }); +test("simulateTransactionPlan loads the AccountLens ABI through ABIService", async () => { + const fetchABI = vi.fn(async () => accountLensAbi); + const plan: TransactionPlan = [ + { + type: "evcBatch", + items: [ + { + targetContract: EVC, + onBehalfOfAccount: ACCOUNT, + value: 0n, + data: encodeFunctionData({ + abi: ethereumVaultConnectorAbi, + functionName: "enableCollateral", + args: [ACCOUNT, TARGET], + }), + }, + ], + }, + ]; + + await simulateAndCollectVaultAccountReads(plan, undefined, { fetchABI }); + + assert.deepEqual(fetchABI.mock.calls, [[1, "AccountLens"]]); +}); + test("simulateTransactionPlan reads EVC account-mode candidates", async () => { const subAccount = getSubAccountAddress(CHECKSUM_ACCOUNT, 1); const plan: TransactionPlan = [ From 529effe7610b4a9c2e3f8fba7cb767f4402f94e9 Mon Sep 17 00:00:00 2001 From: dglowinski Date: Tue, 28 Jul 2026 15:56:26 +0200 Subject: [PATCH 2/6] fix(sdk): harden runtime AccountLens ABI handling Reject unsuccessful or malformed ABI responses while keeping failed fetches retryable. Ignore whole-vault AccountLens query failures in account, rewards, and simulation results. --- .../src/services/abiService/abiService.ts | 13 ++- .../accountOnchainAdapter/accountLensTypes.ts | 3 + .../accountOnchainAdapter.ts | 20 +++- .../src/services/executionService/simulate.ts | 1 + .../services/rewardsService/rewardsService.ts | 20 ++-- .../test/accountLensAbiService.test.ts | 103 ++++++++++++++++++ 6 files changed, 149 insertions(+), 11 deletions(-) diff --git a/packages/euler-v2-sdk/src/services/abiService/abiService.ts b/packages/euler-v2-sdk/src/services/abiService/abiService.ts index 4a050911..8629de14 100644 --- a/packages/euler-v2-sdk/src/services/abiService/abiService.ts +++ b/packages/euler-v2-sdk/src/services/abiService/abiService.ts @@ -18,7 +18,18 @@ export class ABIService implements IABIService { queryABI = async (url: string): Promise => { const response = await fetch(url); - return response.json() as Promise; + if (!response.ok) { + throw new Error( + `Failed to fetch ABI (${response.status} ${response.statusText})`, + ); + } + + const abi: unknown = await response.json(); + if (!Array.isArray(abi)) { + throw new Error("Invalid ABI response"); + } + + return abi as Abi; }; setQueryABI(fn: typeof this.queryABI): void { diff --git a/packages/euler-v2-sdk/src/services/accountService/adapters/accountOnchainAdapter/accountLensTypes.ts b/packages/euler-v2-sdk/src/services/accountService/adapters/accountOnchainAdapter/accountLensTypes.ts index 8a8401da..265d6540 100644 --- a/packages/euler-v2-sdk/src/services/accountService/adapters/accountOnchainAdapter/accountLensTypes.ts +++ b/packages/euler-v2-sdk/src/services/accountService/adapters/accountOnchainAdapter/accountLensTypes.ts @@ -35,6 +35,9 @@ export interface AccountLiquidityInfo { } export interface VaultAccountInfo { + /** Present on Account Lens deployments that report whole-vault query failures. */ + queryFailure?: boolean; + queryFailureReason?: Hex; timestamp: bigint; account: Address; vault: Address; diff --git a/packages/euler-v2-sdk/src/services/accountService/adapters/accountOnchainAdapter/accountOnchainAdapter.ts b/packages/euler-v2-sdk/src/services/accountService/adapters/accountOnchainAdapter/accountOnchainAdapter.ts index f552d353..ee449d3b 100644 --- a/packages/euler-v2-sdk/src/services/accountService/adapters/accountOnchainAdapter/accountOnchainAdapter.ts +++ b/packages/euler-v2-sdk/src/services/accountService/adapters/accountOnchainAdapter/accountOnchainAdapter.ts @@ -500,7 +500,25 @@ export class AccountOnchainAdapter implements IAccountAdapter { if (!vault) return; if (result.status === "fulfilled") { - vaultAccountInfos.push(result.value as VaultAccountInfo); + const vaultAccountInfo = result.value as VaultAccountInfo; + if (vaultAccountInfo.queryFailure) { + errors.push({ + code: "SOURCE_UNAVAILABLE", + severity: "warning", + message: `Failed to fetch vault account info for ${getAddress(vault)}.`, + locations: [ + dataIssueLocation( + subAccountDiagnosticOwner(chainId, subAccount), + "$.positions", + ), + ], + source: "accountLens", + originalValue: vaultAccountInfo.queryFailureReason, + }); + return; + } + + vaultAccountInfos.push(vaultAccountInfo); return; } diff --git a/packages/euler-v2-sdk/src/services/executionService/simulate.ts b/packages/euler-v2-sdk/src/services/executionService/simulate.ts index 6c2c8b86..1e15b78c 100644 --- a/packages/euler-v2-sdk/src/services/executionService/simulate.ts +++ b/packages/euler-v2-sdk/src/services/executionService/simulate.ts @@ -861,6 +861,7 @@ async function decodeAccountSnapshot< functionName: "getVaultAccountInfo", data: resultItem.result, }) as unknown as VaultAccountInfo; + if (decodedVaultInfo.queryFailure) continue; const key = getAddress(meta.subAccount); const list = vaultInfosBySub.get(key) ?? []; list.push(decodedVaultInfo); diff --git a/packages/euler-v2-sdk/src/services/rewardsService/rewardsService.ts b/packages/euler-v2-sdk/src/services/rewardsService/rewardsService.ts index 1815031e..5e4c13f8 100644 --- a/packages/euler-v2-sdk/src/services/rewardsService/rewardsService.ts +++ b/packages/euler-v2-sdk/src/services/rewardsService/rewardsService.ts @@ -610,15 +610,17 @@ export class RewardsService implements IRewardsService { ); return vaultAccountInfos.flatMap((vaultAccountInfo) => - (vaultAccountInfo.enabledRewardsInfo ?? []) - .filter((rewardInfo) => rewardInfo.earnedReward > 0n) - .map((rewardInfo) => ({ - account: getAddress(vaultAccountInfo.account) as Address, - vault: getAddress(vaultAccountInfo.vault) as Address, - reward: getAddress(rewardInfo.reward) as Address, - earnedReward: rewardInfo.earnedReward, - earnedRewardRecentIgnored: rewardInfo.earnedRewardRecentIgnored, - })), + vaultAccountInfo.queryFailure + ? [] + : (vaultAccountInfo.enabledRewardsInfo ?? []) + .filter((rewardInfo) => rewardInfo.earnedReward > 0n) + .map((rewardInfo) => ({ + account: getAddress(vaultAccountInfo.account) as Address, + vault: getAddress(vaultAccountInfo.vault) as Address, + reward: getAddress(rewardInfo.reward) as Address, + earnedReward: rewardInfo.earnedReward, + earnedRewardRecentIgnored: rewardInfo.earnedRewardRecentIgnored, + })), ); } diff --git a/packages/euler-v2-sdk/test/accountLensAbiService.test.ts b/packages/euler-v2-sdk/test/accountLensAbiService.test.ts index c60026df..56cb4546 100644 --- a/packages/euler-v2-sdk/test/accountLensAbiService.test.ts +++ b/packages/euler-v2-sdk/test/accountLensAbiService.test.ts @@ -82,6 +82,32 @@ describe("AccountLens ABI service consumers", () => { ); }); + it("rejects unsuccessful and malformed ABI responses", async () => { + const abiService = new ABIService(); + const fetchMock = vi.spyOn(globalThis, "fetch"); + + try { + fetchMock.mockResolvedValueOnce({ + ok: false, + status: 503, + statusText: "Service Unavailable", + } as Response); + await expect( + abiService.queryABI("https://example.test/failure"), + ).rejects.toThrow("Failed to fetch ABI (503 Service Unavailable)"); + + fetchMock.mockResolvedValueOnce({ + ok: true, + json: async () => ({ abi: [] }), + } as Response); + await expect( + abiService.queryABI("https://example.test/invalid"), + ).rejects.toThrow("Invalid ABI response"); + } finally { + fetchMock.mockRestore(); + } + }); + it("uses the ABI service for onchain account reads", async () => { const { service: abiService, fetchABI } = makeAbiService(); const readContract = vi.fn(async ({ abi }: { abi: Abi }) => { @@ -113,6 +139,47 @@ describe("AccountLens ABI service consumers", () => { expect(readContract).toHaveBeenCalledOnce(); }); + it("omits whole-vault query failures from account positions", async () => { + const { service: abiService } = makeAbiService(); + const readContract = vi + .fn() + .mockResolvedValueOnce({ + timestamp: 1n, + evc: EVC, + account: ACCOUNT, + addressPrefix: "0x00000000000000000000000000000000000000", + owner: ACCOUNT, + isLockdownMode: false, + isPermitDisabledMode: false, + lastAccountStatusCheckTimestamp: 0n, + enabledControllers: [], + enabledCollaterals: [], + }) + .mockResolvedValueOnce({ + queryFailure: true, + queryFailureReason: "0x1234", + }); + const adapter = new AccountOnchainAdapter( + { getProvider: () => ({ readContract }) } as never, + deploymentService as never, + { fetchAccountVaults: vi.fn() } as never, + undefined, + abiService, + ); + + const { result, errors } = await adapter.fetchSubAccount(1, ACCOUNT, [ + VAULT, + ]); + + expect(result?.positions).toEqual([]); + expect(errors).toHaveLength(1); + expect(errors[0]).toMatchObject({ + code: "SOURCE_UNAVAILABLE", + source: "accountLens", + originalValue: "0x1234", + }); + }); + it("uses the ABI service for reward-stream account reads", async () => { const { service: abiService, fetchABI } = makeAbiService(); const readContract = vi.fn(async ({ abi }: { abi: Abi }) => { @@ -144,4 +211,40 @@ describe("AccountLens ABI service consumers", () => { expect(fetchABI).toHaveBeenCalledWith(1, "AccountLens"); expect(readContract).toHaveBeenCalledOnce(); }); + + it("omits reward streams from whole-vault query failures", async () => { + const { service: abiService } = makeAbiService(); + const readContract = vi.fn(async () => ({ + queryFailure: true, + queryFailureReason: "0x1234", + account: ACCOUNT, + vault: VAULT, + enabledRewardsInfo: [ + { + reward: EVC, + earnedReward: 1n, + earnedRewardRecentIgnored: 0n, + }, + ], + })); + const rewards = new RewardsService( + { fetchVaultRewards: vi.fn(), fetchChainRewards: vi.fn() } as never, + { + merklDistributorAddress: ACCOUNT, + fuulManagerAddress: ACCOUNT, + fuulFactoryAddress: ACCOUNT, + }, + { abiService }, + ); + rewards.setProviderService({ getProvider: () => ({ readContract }) } as never); + rewards.setDeploymentService(deploymentService as never); + + await expect( + rewards.fetchRewardStreams({ + chainId: 1, + account: ACCOUNT, + positions: [{ account: ACCOUNT, vault: VAULT }], + }), + ).resolves.toEqual([]); + }); }); From 6d98d059d19ff205356ba4664cb38c7e2a96e502 Mon Sep 17 00:00:00 2001 From: dglowinski Date: Tue, 28 Jul 2026 17:43:52 +0200 Subject: [PATCH 3/6] fix(sdk): fall back to the bundled AccountLens ABI when resolution fails Resolving the AccountLens ABI through ABIService made every account read, reward-stream read, and simulation depend on fetching the ABI document: a 404, rate limit, or blocked host rejected before any RPC call, even though a usable bundled ABI ships with the package. Add resolveAccountLensAbi, which degrades to the bundled ABI when the document is unreachable or is missing the functions the SDK calls, and report the fallback where a channel exists (a FALLBACK_USED data issue on account reads, a warning log for rewards and simulation). Also: - keep the resolved ABI out of AccountLens query cache keys, so a ~33KB array is not serialized into every key (and into consumer query keys derived from the same helpers) - key the ABI request cache by resolved URL rather than contract name, so it tracks whatever getABIURL keys on - extract the duplicated SOURCE_UNAVAILABLE data issue in collectSettledVaultAccountInfos and document the two originalValue shapes - document why a whole-vault queryFailure is dropped silently in decodeAccountSnapshot: the EVC batch item succeeded, so it cannot surface via failedBatchItems Co-Authored-By: Claude Opus 5 (1M context) --- .../src/services/abiService/abiService.ts | 31 ++- .../accountOnchainAdapter.ts | 102 +++++--- .../resolveAccountLensAbi.ts | 66 +++++ .../src/services/executionService/simulate.ts | 15 +- .../services/rewardsService/rewardsService.ts | 10 +- .../test/accountLensAbiService.test.ts | 242 +++++++++++++++++- packages/euler-v2-sdk/test/simulate.test.ts | 40 +++ 7 files changed, 450 insertions(+), 56 deletions(-) create mode 100644 packages/euler-v2-sdk/src/services/accountService/adapters/accountOnchainAdapter/resolveAccountLensAbi.ts diff --git a/packages/euler-v2-sdk/src/services/abiService/abiService.ts b/packages/euler-v2-sdk/src/services/abiService/abiService.ts index 8629de14..bfca8d5d 100644 --- a/packages/euler-v2-sdk/src/services/abiService/abiService.ts +++ b/packages/euler-v2-sdk/src/services/abiService/abiService.ts @@ -36,17 +36,24 @@ export class ABIService implements IABIService { this.queryABI = fn; } - async fetchABI(_: number, contract: string): Promise { - if (!this.abiRequests[contract]) { - const request = this.queryABI(this.getABIURL(_, contract)).catch((error) => { - if (this.abiRequests[contract] === request) { - delete this.abiRequests[contract]; - } - throw error; - }); - this.abiRequests[contract] = request; - } - - return this.abiRequests[contract]; + async fetchABI(chainId: number, contract: string): Promise { + // Keyed by resolved URL rather than contract name, so the cache follows + // whatever `getABIURL` keys on (today the URL is chain-agnostic, so all + // chains share one request). + const url = this.getABIURL(chainId, contract); + const pending = this.abiRequests[url]; + if (pending) return pending; + + // Evict failed requests so a later call retries instead of replaying the + // rejection for the lifetime of the service. + const request = this.queryABI(url).catch((error) => { + if (this.abiRequests[url] === request) { + delete this.abiRequests[url]; + } + throw error; + }); + this.abiRequests[url] = request; + + return request; } } diff --git a/packages/euler-v2-sdk/src/services/accountService/adapters/accountOnchainAdapter/accountOnchainAdapter.ts b/packages/euler-v2-sdk/src/services/accountService/adapters/accountOnchainAdapter/accountOnchainAdapter.ts index ee449d3b..5b68c572 100644 --- a/packages/euler-v2-sdk/src/services/accountService/adapters/accountOnchainAdapter/accountOnchainAdapter.ts +++ b/packages/euler-v2-sdk/src/services/accountService/adapters/accountOnchainAdapter/accountOnchainAdapter.ts @@ -15,7 +15,9 @@ import { convertVaultInfoFullToIEVault } from "../../../vaults/eVaultService/ada import { type BuildQueryFn, applyBuildQuery, + serializeQueryArgs, } from "../../../../utils/buildQuery.js"; +import { resolveAccountLensAbi } from "./resolveAccountLensAbi.js"; import type { EulerPlugin, PluginBatchItems, @@ -117,6 +119,19 @@ export class AccountOnchainAdapter implements IAccountAdapter { }); }; + // The resolved ABI is deliberately not part of the key: it is fetched once and + // memoized for the lifetime of the ABIService, so it is constant for every read + // keyed here, and serializing a ~33KB array into every key is pure overhead. + getQueryKeyEVCAccountInfo( + provider: ReturnType, + accountLensAddress: Address, + evc: Address, + subAccount: Address, + _abi?: Abi, + ): string | null { + return serializeQueryArgs([provider, accountLensAddress, evc, subAccount]); + } + setQueryEVCAccountInfo(fn: typeof this.queryEVCAccountInfo): void { this.queryEVCAccountInfo = fn; } @@ -136,6 +151,22 @@ export class AccountOnchainAdapter implements IAccountAdapter { }); }; + /** See `getQueryKeyEVCAccountInfo` on why the ABI is not part of the key. */ + getQueryKeyVaultAccountInfo( + provider: ReturnType, + accountLensAddress: Address, + subAccount: Address, + vault: Address, + _abi?: Abi, + ): string | null { + return serializeQueryArgs([ + provider, + accountLensAddress, + subAccount, + vault, + ]); + } + setQueryVaultAccountInfo(fn: typeof this.queryVaultAccountInfo): void { this.queryVaultAccountInfo = fn; } @@ -249,9 +280,19 @@ export class AccountOnchainAdapter implements IAccountAdapter { const deployment = this.deploymentService.getDeployment(chainId); const accountLensAddress = deployment.addresses.lensAddrs.accountLens; const evc = deployment.addresses.coreAddrs.evc; - const resolvedAccountLensAbi = - (await this.abiService?.fetchABI(chainId, "AccountLens")) ?? - accountLensAbi; + const { abi: resolvedAccountLensAbi, fallbackReason } = + await resolveAccountLensAbi(this.abiService, chainId); + if (fallbackReason) { + errors.push({ + code: "FALLBACK_USED", + severity: "warning", + message: fallbackReason, + locations: [ + dataIssueLocation(subAccountDiagnosticOwner(chainId, subAccount)), + ], + source: "accountLens", + }); + } // Get EVC account info const evcAccountInfoResult = await this.queryEVCAccountInfo( @@ -495,6 +536,25 @@ export class AccountOnchainAdapter implements IAccountAdapter { ): VaultAccountInfo[] { const vaultAccountInfos: VaultAccountInfo[] = []; + // `originalValue` carries the raw cause: a revert reason (hex) when the lens + // reports a whole-vault query failure, or the transport error message when + // the read itself failed. + const pushUnavailable = (vault: Address, originalValue: unknown) => { + errors.push({ + code: "SOURCE_UNAVAILABLE", + severity: "warning", + message: `Failed to fetch vault account info for ${getAddress(vault)}.`, + locations: [ + dataIssueLocation( + subAccountDiagnosticOwner(chainId, subAccount), + "$.positions", + ), + ], + source: "accountLens", + originalValue, + }); + }; + results.forEach((result, index) => { const vault = vaults[index]; if (!vault) return; @@ -502,19 +562,7 @@ export class AccountOnchainAdapter implements IAccountAdapter { if (result.status === "fulfilled") { const vaultAccountInfo = result.value as VaultAccountInfo; if (vaultAccountInfo.queryFailure) { - errors.push({ - code: "SOURCE_UNAVAILABLE", - severity: "warning", - message: `Failed to fetch vault account info for ${getAddress(vault)}.`, - locations: [ - dataIssueLocation( - subAccountDiagnosticOwner(chainId, subAccount), - "$.positions", - ), - ], - source: "accountLens", - originalValue: vaultAccountInfo.queryFailureReason, - }); + pushUnavailable(vault, vaultAccountInfo.queryFailureReason); return; } @@ -522,22 +570,12 @@ export class AccountOnchainAdapter implements IAccountAdapter { return; } - errors.push({ - code: "SOURCE_UNAVAILABLE", - severity: "warning", - message: `Failed to fetch vault account info for ${getAddress(vault)}.`, - locations: [ - dataIssueLocation( - subAccountDiagnosticOwner(chainId, subAccount), - "$.positions", - ), - ], - source: "accountLens", - originalValue: - result.reason instanceof Error - ? result.reason.message - : String(result.reason), - }); + pushUnavailable( + vault, + result.reason instanceof Error + ? result.reason.message + : String(result.reason), + ); }); return vaultAccountInfos; diff --git a/packages/euler-v2-sdk/src/services/accountService/adapters/accountOnchainAdapter/resolveAccountLensAbi.ts b/packages/euler-v2-sdk/src/services/accountService/adapters/accountOnchainAdapter/resolveAccountLensAbi.ts new file mode 100644 index 00000000..4e32020a --- /dev/null +++ b/packages/euler-v2-sdk/src/services/accountService/adapters/accountOnchainAdapter/resolveAccountLensAbi.ts @@ -0,0 +1,66 @@ +import type { Abi } from "viem"; +import type { IABIService } from "../../../abiService/index.js"; +import { accountLensAbi } from "./abis/accountLensAbi.js"; + +/** The AccountLens functions the SDK encodes and decodes. */ +const REQUIRED_FUNCTIONS = [ + "getEVCAccountInfo", + "getVaultAccountInfo", +] as const; + +const bundledAccountLensAbi = accountLensAbi as unknown as Abi; + +export interface ResolvedAccountLensAbi { + abi: Abi; + /** + * Set when the runtime ABI could not be used and the bundled copy was + * substituted, e.g. the ABI document is unreachable or is missing the + * functions this SDK calls. + */ + fallbackReason?: string; +} + +const missingFunctions = (abi: Abi): string[] => + REQUIRED_FUNCTIONS.filter( + (name) => + !abi.some((item) => item.type === "function" && item.name === name), + ); + +/** + * Resolves the AccountLens ABI used for onchain reads. + * + * Deployment addresses are loaded from the mutable `euler-interfaces` document, + * so the ABI is resolved from the same source to keep the two in step: a lens + * redeployed with a changed return tuple keeps the same function selector, and + * decoding it with the compiled-in ABI would silently misread the response. + * + * The bundled ABI remains a fallback. An unreachable, rate-limited, or + * incomplete ABI document must degrade to the compiled-in copy rather than take + * down every AccountLens read; callers that have a diagnostics channel should + * surface `fallbackReason` on it. + */ +export async function resolveAccountLensAbi( + abiService: IABIService | undefined, + chainId: number, +): Promise { + if (!abiService) return { abi: bundledAccountLensAbi }; + + try { + const abi = await abiService.fetchABI(chainId, "AccountLens"); + const missing = missingFunctions(abi); + if (missing.length > 0) { + return { + abi: bundledAccountLensAbi, + fallbackReason: `Runtime AccountLens ABI is missing ${missing.join(", ")}; using the bundled ABI.`, + }; + } + + return { abi }; + } catch (error) { + const reason = error instanceof Error ? error.message : String(error); + return { + abi: bundledAccountLensAbi, + fallbackReason: `Failed to resolve the runtime AccountLens ABI (${reason}); using the bundled ABI.`, + }; + } +} diff --git a/packages/euler-v2-sdk/src/services/executionService/simulate.ts b/packages/euler-v2-sdk/src/services/executionService/simulate.ts index 1e15b78c..7fa7c740 100644 --- a/packages/euler-v2-sdk/src/services/executionService/simulate.ts +++ b/packages/euler-v2-sdk/src/services/executionService/simulate.ts @@ -115,7 +115,7 @@ import type { SlotHints } from "../../utils/stateOverrides/slotHints.js"; import { isSubAccount } from "../../utils/subAccounts.js"; import { VaultType } from "../../utils/types.js"; import type { AccountFetchOptions } from "../accountService/accountService.js"; -import { accountLensAbi } from "../accountService/adapters/accountOnchainAdapter/abis/accountLensAbi.js"; +import { resolveAccountLensAbi } from "../accountService/adapters/accountOnchainAdapter/resolveAccountLensAbi.js"; import type { EVCAccountInfo, VaultAccountInfo, @@ -464,8 +464,13 @@ export async function simulateTransactionPlan< canExecute: false, }; } - const resolvedAccountLensAbi = - (await ctx.abiService?.fetchABI(chainId, "AccountLens")) ?? accountLensAbi; + // No diagnostics channel on SimulateBatchResult for read-metadata problems, so + // a fallback is logged rather than failing the whole simulation. + const { abi: resolvedAccountLensAbi, fallbackReason } = + await resolveAccountLensAbi(ctx.abiService, chainId); + if (fallbackReason) { + console.warn(`[simulateTransactionPlan] ${fallbackReason}`); + } const diagnostics = await fetchSimulationDiagnostics( ctx, chainId, @@ -861,6 +866,10 @@ async function decodeAccountSnapshot< functionName: "getVaultAccountInfo", data: resultItem.result, }) as unknown as VaultAccountInfo; + // The lens reports a whole-vault query failure in-band, so the EVC batch + // item itself succeeded and this will not appear in `failedBatchItems`. + // Drop the position, matching how a failed batch item is skipped above: + // a partial snapshot degrades to "position absent" rather than throwing. if (decodedVaultInfo.queryFailure) continue; const key = getAddress(meta.subAccount); const list = vaultInfosBySub.get(key) ?? []; diff --git a/packages/euler-v2-sdk/src/services/rewardsService/rewardsService.ts b/packages/euler-v2-sdk/src/services/rewardsService/rewardsService.ts index 5e4c13f8..da64cd8d 100644 --- a/packages/euler-v2-sdk/src/services/rewardsService/rewardsService.ts +++ b/packages/euler-v2-sdk/src/services/rewardsService/rewardsService.ts @@ -17,6 +17,7 @@ import type { ProviderService } from "../providerService/index.js"; import type { DeploymentService } from "../deploymentService/index.js"; import type { IABIService } from "../abiService/index.js"; import { accountLensAbi } from "../accountService/adapters/accountOnchainAdapter/abis/accountLensAbi.js"; +import { resolveAccountLensAbi } from "../accountService/adapters/accountOnchainAdapter/resolveAccountLensAbi.js"; import type { VaultAccountInfo } from "../accountService/adapters/accountOnchainAdapter/accountLensTypes.js"; import type { BuildRewardClaimAllPlanArgs, @@ -593,9 +594,12 @@ export class RewardsService implements IRewardsService { ).values(), ); if (uniquePositions.length === 0) return []; - const resolvedAccountLensAbi = - (await this.abiService?.fetchABI(args.chainId, "AccountLens")) ?? - accountLensAbi; + // This result shape has no diagnostics channel, so a fallback is logged. + const { abi: resolvedAccountLensAbi, fallbackReason } = + await resolveAccountLensAbi(this.abiService, args.chainId); + if (fallbackReason) { + console.warn(`[rewardsService] ${fallbackReason}`); + } const vaultAccountInfos = await Promise.all( uniquePositions.map((position) => diff --git a/packages/euler-v2-sdk/test/accountLensAbiService.test.ts b/packages/euler-v2-sdk/test/accountLensAbiService.test.ts index 56cb4546..ef286685 100644 --- a/packages/euler-v2-sdk/test/accountLensAbiService.test.ts +++ b/packages/euler-v2-sdk/test/accountLensAbiService.test.ts @@ -1,8 +1,15 @@ import { describe, expect, it, vi } from "vitest"; import type { Abi, Address } from "viem"; import { AccountOnchainAdapter } from "../src/services/accountService/adapters/accountOnchainAdapter/accountOnchainAdapter.js"; -import { ABIService, type IABIService } from "../src/services/abiService/index.js"; +import { + ABIService, + type IABIService, +} from "../src/services/abiService/index.js"; import { RewardsService } from "../src/services/rewardsService/rewardsService.js"; +import { + type BuildQueryFn, + createQueryCacheBuildQuery, +} from "../src/utils/buildQuery.js"; const ACCOUNT = "0x0000000000000000000000000000000000000001" as Address; const EVC = "0x0000000000000000000000000000000000000002" as Address; @@ -67,21 +74,48 @@ describe("AccountLens ABI service consumers", () => { const first = abiService.fetchABI(1, "AccountLens"); const second = abiService.fetchABI(1, "AccountLens"); + // The ABI document is chain-agnostic, so another chain shares the request. + const third = abiService.fetchABI(42161, "AccountLens"); expect(queryABI).toHaveBeenCalledOnce(); resolveRequest?.(runtimeAccountLensAbi); - await expect(Promise.all([first, second])).resolves.toEqual([ + await expect(Promise.all([first, second, third])).resolves.toEqual([ + runtimeAccountLensAbi, runtimeAccountLensAbi, runtimeAccountLensAbi, ]); - await expect(abiService.fetchABI(1, "VaultLens")).rejects.toThrow( + // A failed request is evicted, so the next call for that document retries + // instead of replaying the rejection (stubbed queryABI returns the same ABI + // for any document). + await expect(abiService.fetchABI(1, "OtherLens")).rejects.toThrow( "temporary failure", ); - await expect(abiService.fetchABI(1, "VaultLens")).resolves.toBe( + await expect(abiService.fetchABI(1, "OtherLens")).resolves.toBe( runtimeAccountLensAbi, ); }); + it("retries a failed request once the buildQuery failure cache expires", async () => { + const queryABI = vi + .fn<() => Promise>() + .mockRejectedValueOnce(new Error("temporary failure")) + .mockResolvedValue(runtimeAccountLensAbi); + // Failure caching in the buildQuery layer sits in front of fetchABI's own + // eviction; with failureTtlMs disabled the retry reaches queryABI. + const abiService = new ABIService( + createQueryCacheBuildQuery({ failureTtlMs: 0 }), + ); + abiService.setQueryABI(queryABI); + + await expect(abiService.fetchABI(1, "AccountLens")).rejects.toThrow( + "temporary failure", + ); + await expect(abiService.fetchABI(1, "AccountLens")).resolves.toBe( + runtimeAccountLensAbi, + ); + expect(queryABI).toHaveBeenCalledTimes(2); + }); + it("rejects unsuccessful and malformed ABI responses", async () => { const abiService = new ABIService(); const fetchMock = vi.spyOn(globalThis, "fetch"); @@ -199,7 +233,9 @@ describe("AccountLens ABI service consumers", () => { }, { abiService }, ); - rewards.setProviderService({ getProvider: () => ({ readContract }) } as never); + rewards.setProviderService({ + getProvider: () => ({ readContract }), + } as never); rewards.setDeploymentService(deploymentService as never); await rewards.fetchRewardStreams({ @@ -236,7 +272,9 @@ describe("AccountLens ABI service consumers", () => { }, { abiService }, ); - rewards.setProviderService({ getProvider: () => ({ readContract }) } as never); + rewards.setProviderService({ + getProvider: () => ({ readContract }), + } as never); rewards.setDeploymentService(deploymentService as never); await expect( @@ -248,3 +286,195 @@ describe("AccountLens ABI service consumers", () => { ).resolves.toEqual([]); }); }); + +describe("AccountLens ABI resolution fallback", () => { + const evcAccountInfo = { + timestamp: 1n, + evc: EVC, + account: ACCOUNT, + addressPrefix: "0x00000000000000000000000000000000000000", + owner: ACCOUNT, + isLockdownMode: false, + isPermitDisabledMode: false, + lastAccountStatusCheckTimestamp: 0n, + enabledControllers: [], + enabledCollaterals: [], + }; + + const failingAbiService = ( + reason = "Failed to fetch ABI (503 Service Unavailable)", + ) => + ({ + fetchABI: vi.fn(async () => { + throw new Error(reason); + }), + }) as unknown as IABIService; + + const incompleteAbiService = () => + ({ + fetchABI: vi.fn(async () => [ + { + type: "function", + name: "getEVCAccountInfo", + stateMutability: "view", + inputs: [], + outputs: [{ name: "marker", type: "bytes32" }], + }, + ]), + }) as unknown as IABIService; + + const makeAdapter = (abiService: IABIService, readContract: unknown) => + new AccountOnchainAdapter( + { getProvider: () => ({ readContract }) } as never, + deploymentService as never, + { fetchAccountVaults: vi.fn() } as never, + undefined, + abiService, + ); + + it("falls back to the bundled ABI when the ABI fetch fails", async () => { + const readContract = vi.fn(async ({ abi }: { abi: Abi }) => { + // The bundled ABI, not the stubbed runtime one. + expect(abi).not.toBe(runtimeAccountLensAbi); + expect( + abi.some( + (item) => + item.type === "function" && item.name === "getEVCAccountInfo", + ), + ).toBe(true); + return evcAccountInfo; + }); + const adapter = makeAdapter(failingAbiService(), readContract); + + const { result, errors } = await adapter.fetchSubAccount(1, ACCOUNT); + + // The read still happens: an unreachable ABI document must not take down + // account reads while a usable bundled ABI is available. + expect(readContract).toHaveBeenCalledOnce(); + expect(result?.account).toBe(ACCOUNT); + expect(errors).toHaveLength(1); + expect(errors[0]).toMatchObject({ + code: "FALLBACK_USED", + severity: "warning", + source: "accountLens", + }); + expect(errors[0]?.message).toContain( + "Failed to fetch ABI (503 Service Unavailable)", + ); + }); + + it("falls back when the runtime ABI is missing functions the SDK calls", async () => { + const readContract = vi.fn(async () => evcAccountInfo); + const adapter = makeAdapter(incompleteAbiService(), readContract); + + const { errors } = await adapter.fetchSubAccount(1, ACCOUNT); + + expect(readContract).toHaveBeenCalledOnce(); + expect(errors).toHaveLength(1); + expect(errors[0]).toMatchObject({ code: "FALLBACK_USED" }); + expect(errors[0]?.message).toContain("getVaultAccountInfo"); + }); + + it("keeps reward streams working when the ABI fetch fails", async () => { + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + const readContract = vi.fn(async ({ abi }: { abi: Abi }) => { + expect(abi).not.toBe(runtimeAccountLensAbi); + return { + account: ACCOUNT, + vault: VAULT, + enabledRewardsInfo: [ + { reward: EVC, earnedReward: 5n, earnedRewardRecentIgnored: 0n }, + ], + }; + }); + const rewards = new RewardsService( + { fetchVaultRewards: vi.fn(), fetchChainRewards: vi.fn() } as never, + { + merklDistributorAddress: ACCOUNT, + fuulManagerAddress: ACCOUNT, + fuulFactoryAddress: ACCOUNT, + }, + { abiService: failingAbiService() }, + ); + rewards.setProviderService({ + getProvider: () => ({ readContract }), + } as never); + rewards.setDeploymentService(deploymentService as never); + + try { + await expect( + rewards.fetchRewardStreams({ + chainId: 1, + account: ACCOUNT, + positions: [{ account: ACCOUNT, vault: VAULT }], + }), + ).resolves.toEqual([ + { + account: ACCOUNT, + vault: VAULT, + reward: EVC, + earnedReward: 5n, + earnedRewardRecentIgnored: 0n, + }, + ]); + expect(warn).toHaveBeenCalledOnce(); + } finally { + warn.mockRestore(); + } + }); + + it("keeps the resolved ABI out of the keys buildQuery derives", async () => { + const keys: (string | null)[] = []; + const buildQuery = ((_name, fn, _target, context) => { + return ((...args: unknown[]) => { + if (context) keys.push(context.getCacheKey(args)); + return fn(...args); + }) as typeof fn; + }) as BuildQueryFn; + const readContract = vi.fn(async () => evcAccountInfo); + const adapter = new AccountOnchainAdapter( + { getProvider: () => ({ readContract }) } as never, + deploymentService as never, + { fetchAccountVaults: vi.fn() } as never, + buildQuery, + makeAbiService().service, + ); + + await adapter.fetchSubAccount(1, ACCOUNT); + + // Otherwise the 33KB runtime ABI is serialized into every AccountLens key. + expect(keys).toHaveLength(1); + expect(keys[0]).not.toBeNull(); + expect(keys[0]?.length).toBeLessThan(300); + }); + + it("derives the same query key regardless of the ABI argument", async () => { + const adapter = makeAdapter(failingAbiService(), vi.fn()); + const provider = { chain: { id: 1 }, transport: {} } as never; + + const withoutAbi = adapter.getQueryKeyVaultAccountInfo( + provider, + ACCOUNT_LENS, + ACCOUNT, + VAULT, + ); + + expect(withoutAbi).not.toBeNull(); + // A fetched ABI is memoized for the lifetime of the ABIService, so it is + // constant for every read keyed here and never varies the key. + for (const abi of [ + runtimeAccountLensAbi as unknown as Abi, + [...runtimeAccountLensAbi] as unknown as Abi, + ]) { + expect( + adapter.getQueryKeyVaultAccountInfo( + provider, + ACCOUNT_LENS, + ACCOUNT, + VAULT, + abi, + ), + ).toEqual(withoutAbi); + } + }); +}); diff --git a/packages/euler-v2-sdk/test/simulate.test.ts b/packages/euler-v2-sdk/test/simulate.test.ts index 2a462281..ae758c9d 100644 --- a/packages/euler-v2-sdk/test/simulate.test.ts +++ b/packages/euler-v2-sdk/test/simulate.test.ts @@ -915,6 +915,46 @@ test("simulateTransactionPlan loads the AccountLens ABI through ABIService", asy assert.deepEqual(fetchABI.mock.calls, [[1, "AccountLens"]]); }); +test("simulateTransactionPlan still simulates when the AccountLens ABI fetch fails", async () => { + const fetchABI = vi.fn(async () => { + throw new Error("Failed to fetch ABI (503 Service Unavailable)"); + }); + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + const plan: TransactionPlan = [ + { + type: "evcBatch", + items: [ + { + targetContract: EVC, + onBehalfOfAccount: ACCOUNT, + value: 0n, + data: encodeFunctionData({ + abi: ethereumVaultConnectorAbi, + functionName: "enableCollateral", + args: [ACCOUNT, TARGET], + }), + }, + ], + }, + ]; + + try { + // The lens reads are still encoded, using the bundled ABI. + const vaultAccountReads = await simulateAndCollectVaultAccountReads( + plan, + undefined, + { fetchABI } as never, + ); + + assert.ok( + vaultAccountReads.has(`${CHECKSUM_ACCOUNT}:${getAddress(TARGET)}`), + ); + assert.equal(warn.mock.calls.length, 1); + } finally { + warn.mockRestore(); + } +}); + test("simulateTransactionPlan reads EVC account-mode candidates", async () => { const subAccount = getSubAccountAddress(CHECKSUM_ACCOUNT, 1); const plan: TransactionPlan = [ From ce02fbe5b085dccdeadfd6f9c36121ec7ea44741 Mon Sep 17 00:00:00 2001 From: dglowinski Date: Wed, 29 Jul 2026 14:38:21 +0200 Subject: [PATCH 4/6] fix(sdk): align AccountLens runtime sources - configure ABI and deployment reads from one interfaces branch - use the reward-specific AccountLens query and isolate failures - refresh the bundled fallback ABI for current lens deployments --- .../euler-v2-sdk/docs/config-through-env.md | 10 +- packages/euler-v2-sdk/src/sdk/buildSDK.ts | 21 ++++- packages/euler-v2-sdk/src/sdk/config.ts | 5 + .../euler-v2-sdk/src/sdk/defaultConfig.ts | 9 +- .../src/services/abiService/abiService.ts | 16 +++- .../src/services/abiService/index.ts | 7 +- .../abis/accountLensAbi.ts | 30 ++++++ .../accountOnchainAdapter/accountLensTypes.ts | 14 +++ .../resolveAccountLensAbi.ts | 12 ++- .../services/rewardsService/rewardsService.ts | 53 ++++++----- .../test/accountLensAbiService.test.ts | 91 +++++++++++++++---- .../euler-v2-sdk/test/rewardsService.test.ts | 2 +- packages/euler-v2-sdk/test/sdkConfig.test.ts | 53 ++++++++++- 13 files changed, 266 insertions(+), 57 deletions(-) diff --git a/packages/euler-v2-sdk/docs/config-through-env.md b/packages/euler-v2-sdk/docs/config-through-env.md index 7ea7e609..d9b37921 100644 --- a/packages/euler-v2-sdk/docs/config-through-env.md +++ b/packages/euler-v2-sdk/docs/config-through-env.md @@ -60,7 +60,7 @@ endpoints without changing the V3 configuration used by other SDK services. `EULER_SDK_VAULT_TYPE_V3_TYPE_MAP_JSON` is a JSON object with string values, for example `{"custom":"EVault"}`. -## Pricing, Swaps, And Deployments +## Pricing, Swaps, And Euler Interfaces | Config field | Environment variable | Default | |---|---|---| @@ -68,7 +68,13 @@ endpoints without changing the V3 configuration used by other SDK services. | `pricingApiKey` | `EULER_SDK_PRICING_API_KEY` | `v3ApiKey` | | `swapApiUrl` | `EULER_SDK_SWAP_API_URL` | `https://swap.euler.finance` | | `swapDefaultDeadline` | `EULER_SDK_SWAP_DEFAULT_DEADLINE` | `1800` | -| `deploymentsUrl` | `EULER_SDK_DEPLOYMENTS_URL` | Euler interfaces `EulerChains.json` | +| `eulerInterfacesBranch` | `EULER_SDK_EULER_INTERFACES_BRANCH` | `master` | +| `deploymentsUrl` | `EULER_SDK_DEPLOYMENTS_URL` | `EulerChains.json` from `eulerInterfacesBranch` | + +`eulerInterfacesBranch` keeps the runtime ABI service and the default +deployments document on the same `euler-interfaces` branch. A direct +`deploymentsUrl` remains available for custom mirrors and takes precedence for +the deployment service without changing the ABI branch. ## Rewards diff --git a/packages/euler-v2-sdk/src/sdk/buildSDK.ts b/packages/euler-v2-sdk/src/sdk/buildSDK.ts index a77f0e9d..5cda6ddc 100644 --- a/packages/euler-v2-sdk/src/sdk/buildSDK.ts +++ b/packages/euler-v2-sdk/src/sdk/buildSDK.ts @@ -1,6 +1,10 @@ import type { Address } from "viem"; import { EulerSDK } from "./sdk.js"; -import { ABIService, type IABIService } from "../services/abiService/index.js"; +import { + ABIService, + DEFAULT_EULER_INTERFACES_BRANCH, + type IABIService, +} from "../services/abiService/index.js"; import { DeploymentService, type IDeploymentService, @@ -106,6 +110,7 @@ import { defaultSwapServiceConfig, defaultTokenlistServiceConfig, defaultVaultTypeAdapterConfig, + getEulerInterfacesDeploymentsUrl, } from "./defaultConfig.js"; import { defaultEVaultV3AdapterConfig } from "./defaultConfig.js"; import { @@ -568,15 +573,27 @@ export async function buildEulerSDK< }; const resolvedBuildQuery = buildQuery ?? createQueryCacheBuildQuery(resolvedQueryCacheConfig); + const resolvedEulerInterfacesBranch = + pickConfigValue( + config?.eulerInterfacesBranch, + undefined, + envConfig.eulerInterfacesBranch, + ) ?? DEFAULT_EULER_INTERFACES_BRANCH; const resolvedDeploymentServiceConfig = { ...defaultDeploymentServiceConfig, + deploymentsUrl: getEulerInterfacesDeploymentsUrl( + resolvedEulerInterfacesBranch, + ), ...maybeField("deploymentsUrl", envConfig.deploymentsUrl), ...maybeField("deploymentsUrl", config?.deploymentsUrl), }; // Build core services (these may be needed for adapters even if overridden) const abiService = - servicesOverrides?.abiService ?? new ABIService(resolvedBuildQuery); + servicesOverrides?.abiService ?? + new ABIService(resolvedBuildQuery, { + eulerInterfacesBranch: resolvedEulerInterfacesBranch, + }); const deploymentService = servicesOverrides?.deploymentService ?? (await DeploymentService.build( diff --git a/packages/euler-v2-sdk/src/sdk/config.ts b/packages/euler-v2-sdk/src/sdk/config.ts index 0c400d76..aa34d851 100644 --- a/packages/euler-v2-sdk/src/sdk/config.ts +++ b/packages/euler-v2-sdk/src/sdk/config.ts @@ -80,6 +80,7 @@ export interface EulerSDKConfig { swapApiUrl?: string; swapDefaultDeadline?: number; + eulerInterfacesBranch?: string; deploymentsUrl?: string; eulerLabelsBaseUrl?: string; @@ -497,6 +498,10 @@ export function readEulerSDKEnvConfig( swapApiUrl: readString(env, "EULER_SDK_SWAP_API_URL"), swapDefaultDeadline: readNumber(env, "EULER_SDK_SWAP_DEFAULT_DEADLINE"), + eulerInterfacesBranch: readString( + env, + "EULER_SDK_EULER_INTERFACES_BRANCH", + ), deploymentsUrl: readString(env, "EULER_SDK_DEPLOYMENTS_URL"), eulerLabelsBaseUrl: readString(env, "EULER_SDK_EULER_LABELS_BASE_URL"), diff --git a/packages/euler-v2-sdk/src/sdk/defaultConfig.ts b/packages/euler-v2-sdk/src/sdk/defaultConfig.ts index 3bde26a1..f099a015 100644 --- a/packages/euler-v2-sdk/src/sdk/defaultConfig.ts +++ b/packages/euler-v2-sdk/src/sdk/defaultConfig.ts @@ -14,6 +14,7 @@ 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"; +import { DEFAULT_EULER_INTERFACES_BRANCH } from "../services/abiService/index.js"; const SUBGRAPH_BASE_URL = "https://api.goldsky.com/api/public/project_cm4iagnemt1wp01xn4gh1agft/subgraphs"; @@ -106,9 +107,13 @@ export const defaultSwapServiceConfig: SwapServiceConfig = { defaultDeadline: 1800, // 30 minutes }; +export const getEulerInterfacesDeploymentsUrl = ( + branch = DEFAULT_EULER_INTERFACES_BRANCH, +): string => + `https://raw.githubusercontent.com/euler-xyz/euler-interfaces/refs/heads/${branch}/EulerChains.json`; + export const defaultDeploymentServiceConfig: DeploymentServiceConfig = { - deploymentsUrl: - "https://raw.githubusercontent.com/euler-xyz/euler-interfaces/refs/heads/master/EulerChains.json", + deploymentsUrl: getEulerInterfacesDeploymentsUrl(), }; export const DEFAULT_TOKENLIST_API_BASE_URL = DEFAULT_V3_API_URL; diff --git a/packages/euler-v2-sdk/src/services/abiService/abiService.ts b/packages/euler-v2-sdk/src/services/abiService/abiService.ts index bfca8d5d..d4d69d8f 100644 --- a/packages/euler-v2-sdk/src/services/abiService/abiService.ts +++ b/packages/euler-v2-sdk/src/services/abiService/abiService.ts @@ -5,15 +5,27 @@ export interface IABIService { fetchABI(chainId: number, contract: string): Promise; } +export interface ABIServiceConfig { + eulerInterfacesBranch?: string; +} + +export const DEFAULT_EULER_INTERFACES_BRANCH = "master"; + export class ABIService implements IABIService { private readonly abiRequests: Record> = {}; - constructor(buildQuery?: BuildQueryFn) { + constructor( + buildQuery?: BuildQueryFn, + private readonly config: ABIServiceConfig = {}, + ) { if (buildQuery) applyBuildQuery(this, buildQuery); } private getABIURL(_: number, contract: string): string { - return `https://raw.githubusercontent.com/euler-xyz/euler-interfaces/refs/heads/master/abis/${contract}.json`; + const branch = + this.config.eulerInterfacesBranch?.trim() || + DEFAULT_EULER_INTERFACES_BRANCH; + return `https://raw.githubusercontent.com/euler-xyz/euler-interfaces/refs/heads/${branch}/abis/${contract}.json`; } queryABI = async (url: string): Promise => { diff --git a/packages/euler-v2-sdk/src/services/abiService/index.ts b/packages/euler-v2-sdk/src/services/abiService/index.ts index 5dbe3852..a5736405 100644 --- a/packages/euler-v2-sdk/src/services/abiService/index.ts +++ b/packages/euler-v2-sdk/src/services/abiService/index.ts @@ -1,2 +1,5 @@ -export { ABIService } from "./abiService.js"; -export type { IABIService } from "./abiService.js"; +export { + ABIService, + DEFAULT_EULER_INTERFACES_BRANCH, +} from "./abiService.js"; +export type { ABIServiceConfig, IABIService } from "./abiService.js"; diff --git a/packages/euler-v2-sdk/src/services/accountService/adapters/accountOnchainAdapter/abis/accountLensAbi.ts b/packages/euler-v2-sdk/src/services/accountService/adapters/accountOnchainAdapter/abis/accountLensAbi.ts index f45a7ae7..14ee0a38 100644 --- a/packages/euler-v2-sdk/src/services/accountService/adapters/accountOnchainAdapter/abis/accountLensAbi.ts +++ b/packages/euler-v2-sdk/src/services/accountService/adapters/accountOnchainAdapter/abis/accountLensAbi.ts @@ -136,6 +136,16 @@ export const accountLensAbi = [ type: "tuple[]", internalType: "struct VaultAccountInfo[]", components: [ + { + name: "queryFailure", + type: "bool", + internalType: "bool", + }, + { + name: "queryFailureReason", + type: "bytes", + internalType: "bytes", + }, { name: "timestamp", type: "uint256", @@ -442,6 +452,16 @@ export const accountLensAbi = [ type: "tuple", internalType: "struct VaultAccountInfo", components: [ + { + name: "queryFailure", + type: "bool", + internalType: "bool", + }, + { + name: "queryFailureReason", + type: "bytes", + internalType: "bytes", + }, { name: "timestamp", type: "uint256", @@ -1066,6 +1086,16 @@ export const accountLensAbi = [ type: "tuple", internalType: "struct VaultAccountInfo", components: [ + { + name: "queryFailure", + type: "bool", + internalType: "bool", + }, + { + name: "queryFailureReason", + type: "bytes", + internalType: "bytes", + }, { name: "timestamp", type: "uint256", diff --git a/packages/euler-v2-sdk/src/services/accountService/adapters/accountOnchainAdapter/accountLensTypes.ts b/packages/euler-v2-sdk/src/services/accountService/adapters/accountOnchainAdapter/accountLensTypes.ts index 265d6540..dd01329f 100644 --- a/packages/euler-v2-sdk/src/services/accountService/adapters/accountOnchainAdapter/accountLensTypes.ts +++ b/packages/euler-v2-sdk/src/services/accountService/adapters/accountOnchainAdapter/accountLensTypes.ts @@ -34,6 +34,20 @@ export interface AccountLiquidityInfo { collateralValuesRaw: bigint[]; } +export interface AccountRewardInfo { + timestamp: bigint; + account: Address; + vault: Address; + balanceTracker: Address; + balanceForwarderEnabled: boolean; + balance: bigint; + enabledRewardsInfo: { + reward: Address; + earnedReward: bigint; + earnedRewardRecentIgnored: bigint; + }[]; +} + export interface VaultAccountInfo { /** Present on Account Lens deployments that report whole-vault query failures. */ queryFailure?: boolean; diff --git a/packages/euler-v2-sdk/src/services/accountService/adapters/accountOnchainAdapter/resolveAccountLensAbi.ts b/packages/euler-v2-sdk/src/services/accountService/adapters/accountOnchainAdapter/resolveAccountLensAbi.ts index 4e32020a..3aadcc5b 100644 --- a/packages/euler-v2-sdk/src/services/accountService/adapters/accountOnchainAdapter/resolveAccountLensAbi.ts +++ b/packages/euler-v2-sdk/src/services/accountService/adapters/accountOnchainAdapter/resolveAccountLensAbi.ts @@ -3,7 +3,7 @@ import type { IABIService } from "../../../abiService/index.js"; import { accountLensAbi } from "./abis/accountLensAbi.js"; /** The AccountLens functions the SDK encodes and decodes. */ -const REQUIRED_FUNCTIONS = [ +const DEFAULT_REQUIRED_FUNCTIONS = [ "getEVCAccountInfo", "getVaultAccountInfo", ] as const; @@ -20,8 +20,11 @@ export interface ResolvedAccountLensAbi { fallbackReason?: string; } -const missingFunctions = (abi: Abi): string[] => - REQUIRED_FUNCTIONS.filter( +const missingFunctions = ( + abi: Abi, + requiredFunctions: readonly string[], +): string[] => + requiredFunctions.filter( (name) => !abi.some((item) => item.type === "function" && item.name === name), ); @@ -42,12 +45,13 @@ const missingFunctions = (abi: Abi): string[] => export async function resolveAccountLensAbi( abiService: IABIService | undefined, chainId: number, + requiredFunctions: readonly string[] = DEFAULT_REQUIRED_FUNCTIONS, ): Promise { if (!abiService) return { abi: bundledAccountLensAbi }; try { const abi = await abiService.fetchABI(chainId, "AccountLens"); - const missing = missingFunctions(abi); + const missing = missingFunctions(abi, requiredFunctions); if (missing.length > 0) { return { abi: bundledAccountLensAbi, diff --git a/packages/euler-v2-sdk/src/services/rewardsService/rewardsService.ts b/packages/euler-v2-sdk/src/services/rewardsService/rewardsService.ts index da64cd8d..c2e7eda9 100644 --- a/packages/euler-v2-sdk/src/services/rewardsService/rewardsService.ts +++ b/packages/euler-v2-sdk/src/services/rewardsService/rewardsService.ts @@ -18,7 +18,7 @@ import type { DeploymentService } from "../deploymentService/index.js"; import type { IABIService } from "../abiService/index.js"; import { accountLensAbi } from "../accountService/adapters/accountOnchainAdapter/abis/accountLensAbi.js"; import { resolveAccountLensAbi } from "../accountService/adapters/accountOnchainAdapter/resolveAccountLensAbi.js"; -import type { VaultAccountInfo } from "../accountService/adapters/accountOnchainAdapter/accountLensTypes.js"; +import type { AccountRewardInfo } from "../accountService/adapters/accountOnchainAdapter/accountLensTypes.js"; import type { BuildRewardClaimAllPlanArgs, BuildRewardClaimPlanArgs, @@ -557,23 +557,28 @@ export class RewardsService implements IRewardsService { return this.adapter.fetchFuulClaimChecks(address, chainId); } - queryVaultAccountInfo = async ( + queryRewardAccountInfo = async ( provider: ReturnType, accountLensAddress: Address, account: Address, vault: Address, abi: Abi = accountLensAbi, - ): Promise => { + ): Promise => { return provider.readContract({ address: accountLensAddress, abi, - functionName: "getVaultAccountInfo", + functionName: "getRewardAccountInfo", args: [account, vault], - }) as Promise; + }) as Promise; }; - setQueryVaultAccountInfo(fn: typeof this.queryVaultAccountInfo): void { - this.queryVaultAccountInfo = fn; + setQueryRewardAccountInfo(fn: typeof this.queryRewardAccountInfo): void { + this.queryRewardAccountInfo = fn; + } + + /** @deprecated Use `setQueryRewardAccountInfo`. */ + setQueryVaultAccountInfo(fn: typeof this.queryRewardAccountInfo): void { + this.setQueryRewardAccountInfo(fn); } async fetchRewardStreams( @@ -596,14 +601,16 @@ export class RewardsService implements IRewardsService { if (uniquePositions.length === 0) return []; // This result shape has no diagnostics channel, so a fallback is logged. const { abi: resolvedAccountLensAbi, fallbackReason } = - await resolveAccountLensAbi(this.abiService, args.chainId); + await resolveAccountLensAbi(this.abiService, args.chainId, [ + "getRewardAccountInfo", + ]); if (fallbackReason) { console.warn(`[rewardsService] ${fallbackReason}`); } - const vaultAccountInfos = await Promise.all( + const rewardAccountInfoResults = await Promise.allSettled( uniquePositions.map((position) => - this.queryVaultAccountInfo( + this.queryRewardAccountInfo( provider, accountLensAddress, position.account, @@ -613,19 +620,19 @@ export class RewardsService implements IRewardsService { ), ); - return vaultAccountInfos.flatMap((vaultAccountInfo) => - vaultAccountInfo.queryFailure - ? [] - : (vaultAccountInfo.enabledRewardsInfo ?? []) - .filter((rewardInfo) => rewardInfo.earnedReward > 0n) - .map((rewardInfo) => ({ - account: getAddress(vaultAccountInfo.account) as Address, - vault: getAddress(vaultAccountInfo.vault) as Address, - reward: getAddress(rewardInfo.reward) as Address, - earnedReward: rewardInfo.earnedReward, - earnedRewardRecentIgnored: rewardInfo.earnedRewardRecentIgnored, - })), - ); + return rewardAccountInfoResults.flatMap((result) => { + if (result.status === "rejected") return []; + + return result.value.enabledRewardsInfo + .filter((rewardInfo) => rewardInfo.earnedReward > 0n) + .map((rewardInfo) => ({ + account: getAddress(result.value.account) as Address, + vault: getAddress(result.value.vault) as Address, + reward: getAddress(rewardInfo.reward) as Address, + earnedReward: rewardInfo.earnedReward, + earnedRewardRecentIgnored: rewardInfo.earnedRewardRecentIgnored, + })); + }); } async buildClaimPlan( diff --git a/packages/euler-v2-sdk/test/accountLensAbiService.test.ts b/packages/euler-v2-sdk/test/accountLensAbiService.test.ts index ef286685..d15eb1b7 100644 --- a/packages/euler-v2-sdk/test/accountLensAbiService.test.ts +++ b/packages/euler-v2-sdk/test/accountLensAbiService.test.ts @@ -15,6 +15,8 @@ const ACCOUNT = "0x0000000000000000000000000000000000000001" as Address; const EVC = "0x0000000000000000000000000000000000000002" as Address; const ACCOUNT_LENS = "0x0000000000000000000000000000000000000003" as Address; const VAULT = "0x0000000000000000000000000000000000000004" as Address; +const SECOND_VAULT = + "0x0000000000000000000000000000000000000005" as Address; const runtimeAccountLensAbi = [ { @@ -37,6 +39,16 @@ const runtimeAccountLensAbi = [ ], outputs: [{ name: "marker", type: "bytes32" }], }, + { + type: "function", + name: "getRewardAccountInfo", + stateMutability: "view", + inputs: [ + { name: "account", type: "address" }, + { name: "vault", type: "address" }, + ], + outputs: [{ name: "marker", type: "bytes32" }], + }, ] as const satisfies Abi; const makeAbiService = () => { @@ -116,6 +128,20 @@ describe("AccountLens ABI service consumers", () => { expect(queryABI).toHaveBeenCalledTimes(2); }); + it("resolves ABI documents from the configured euler-interfaces branch", async () => { + const abiService = new ABIService(undefined, { + eulerInterfacesBranch: "account-lens-update", + }); + const queryABI = vi.fn(async () => runtimeAccountLensAbi); + abiService.setQueryABI(queryABI); + + await abiService.fetchABI(1, "AccountLens"); + + expect(queryABI).toHaveBeenCalledWith( + "https://raw.githubusercontent.com/euler-xyz/euler-interfaces/refs/heads/account-lens-update/abis/AccountLens.json", + ); + }); + it("rejects unsuccessful and malformed ABI responses", async () => { const abiService = new ABIService(); const fetchMock = vi.spyOn(globalThis, "fetch"); @@ -216,14 +242,27 @@ describe("AccountLens ABI service consumers", () => { it("uses the ABI service for reward-stream account reads", async () => { const { service: abiService, fetchABI } = makeAbiService(); - const readContract = vi.fn(async ({ abi }: { abi: Abi }) => { + const readContract = vi.fn( + async ({ + abi, + functionName, + }: { + abi: Abi; + functionName: string; + }) => { expect(abi).toBe(runtimeAccountLensAbi); + expect(functionName).toBe("getRewardAccountInfo"); return { + timestamp: 1n, account: ACCOUNT, vault: VAULT, + balanceTracker: EVC, + balanceForwarderEnabled: true, + balance: 1n, enabledRewardsInfo: [], }; - }); + }, + ); const rewards = new RewardsService( { fetchVaultRewards: vi.fn(), fetchChainRewards: vi.fn() } as never, { @@ -248,21 +287,26 @@ describe("AccountLens ABI service consumers", () => { expect(readContract).toHaveBeenCalledOnce(); }); - it("omits reward streams from whole-vault query failures", async () => { + it("isolates failed reward-account reads by position", async () => { const { service: abiService } = makeAbiService(); - const readContract = vi.fn(async () => ({ - queryFailure: true, - queryFailureReason: "0x1234", - account: ACCOUNT, - vault: VAULT, - enabledRewardsInfo: [ - { - reward: EVC, - earnedReward: 1n, - earnedRewardRecentIgnored: 0n, - }, - ], - })); + const readContract = vi + .fn() + .mockRejectedValueOnce(new Error("hostile reward source")) + .mockResolvedValueOnce({ + timestamp: 1n, + account: ACCOUNT, + vault: SECOND_VAULT, + balanceTracker: EVC, + balanceForwarderEnabled: true, + balance: 1n, + enabledRewardsInfo: [ + { + reward: EVC, + earnedReward: 1n, + earnedRewardRecentIgnored: 0n, + }, + ], + }); const rewards = new RewardsService( { fetchVaultRewards: vi.fn(), fetchChainRewards: vi.fn() } as never, { @@ -281,9 +325,20 @@ describe("AccountLens ABI service consumers", () => { rewards.fetchRewardStreams({ chainId: 1, account: ACCOUNT, - positions: [{ account: ACCOUNT, vault: VAULT }], + positions: [ + { account: ACCOUNT, vault: VAULT }, + { account: ACCOUNT, vault: SECOND_VAULT }, + ], }), - ).resolves.toEqual([]); + ).resolves.toEqual([ + { + account: ACCOUNT, + vault: SECOND_VAULT, + reward: EVC, + earnedReward: 1n, + earnedRewardRecentIgnored: 0n, + }, + ]); }); }); diff --git a/packages/euler-v2-sdk/test/rewardsService.test.ts b/packages/euler-v2-sdk/test/rewardsService.test.ts index 94bb9602..4d80c611 100644 --- a/packages/euler-v2-sdk/test/rewardsService.test.ts +++ b/packages/euler-v2-sdk/test/rewardsService.test.ts @@ -1951,7 +1951,7 @@ test("rewards service fetches claimable reward streams from account lens", async account: Address; vault: Address; }> = []; - service.setQueryVaultAccountInfo( + service.setQueryRewardAccountInfo( async (_provider, queriedAccountLensAddress, account, vault) => { calls.push({ accountLensAddress: queriedAccountLensAddress, account, vault }); return { diff --git a/packages/euler-v2-sdk/test/sdkConfig.test.ts b/packages/euler-v2-sdk/test/sdkConfig.test.ts index d769a1ef..19a42540 100644 --- a/packages/euler-v2-sdk/test/sdkConfig.test.ts +++ b/packages/euler-v2-sdk/test/sdkConfig.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import { buildEulerSDK } from "../src/sdk/buildSDK.js"; import { readEulerSDKEnvConfig } from "../src/sdk/config.js"; import { @@ -6,6 +6,7 @@ import { defaultTokenlistServiceConfig, } from "../src/sdk/defaultConfig.js"; import type { IDeploymentService } from "../src/services/deploymentService/index.js"; +import { DeploymentService } from "../src/services/deploymentService/index.js"; const deploymentService: IDeploymentService = { getDeploymentChainIds: () => [], @@ -33,6 +34,8 @@ describe("SDK env config", () => { EULER_SDK_REWARDS_TURTLE_STREAMS_JSON: '[{"streamId":"stream-1","chainId":1,"streamAddress":"0x0000000000000000000000000000000000000001","rewardToken":{"address":"0x0000000000000000000000000000000000000002","symbol":"EUL","decimals":18},"tokenPrice":1.5}]', EULER_SDK_VAULT_TYPE_V3_TYPE_MAP_JSON: '{"custom":"EVault"}', + EULER_SDK_EULER_INTERFACES_BRANCH: "account-lens-update", + EULER_SDK_DEPLOYMENTS_URL: "https://deployments.example/EulerChains.json", }); expect(config).toMatchObject({ @@ -64,6 +67,8 @@ describe("SDK env config", () => { }, ], vaultTypeV3TypeMap: { custom: "EVault" }, + eulerInterfacesBranch: "account-lens-update", + deploymentsUrl: "https://deployments.example/EulerChains.json", }); }); @@ -84,6 +89,52 @@ describe("SDK env config", () => { ); }); + it("uses the euler-interfaces branch for default deployments", async () => { + const build = vi + .spyOn(DeploymentService, "build") + .mockResolvedValue(deploymentService as DeploymentService); + + try { + await buildEulerSDK({ + config: { eulerInterfacesBranch: "account-lens-update" }, + }); + + expect(build).toHaveBeenCalledWith( + { + deploymentsUrl: + "https://raw.githubusercontent.com/euler-xyz/euler-interfaces/refs/heads/account-lens-update/EulerChains.json", + }, + expect.any(Function), + ); + } finally { + build.mockRestore(); + } + }); + + it("prefers an explicit deployments URL over the interfaces branch", async () => { + const build = vi + .spyOn(DeploymentService, "build") + .mockResolvedValue(deploymentService as DeploymentService); + + try { + await buildEulerSDK({ + config: { + eulerInterfacesBranch: "account-lens-update", + deploymentsUrl: "https://deployments.example/EulerChains.json", + }, + }); + + expect(build).toHaveBeenCalledWith( + { + deploymentsUrl: "https://deployments.example/EulerChains.json", + }, + expect.any(Function), + ); + } finally { + build.mockRestore(); + } + }); + it("throws for invalid scalar values", () => { expect(() => readEulerSDKEnvConfig({ From 76266f9b976ede0a456084e2fec36afd8fc1b096 Mon Sep 17 00:00:00 2001 From: dglowinski Date: Wed, 29 Jul 2026 16:44:58 +0200 Subject: [PATCH 5/6] fix(sdk): expose incomplete simulation snapshots A failed AccountLens read dropped a position from a simulated snapshot with no way for callers to notice. `rawBatchResults` covers only the batch's action positions, so a reverted lens read never reaches `failedBatchItems`, and a whole-vault failure is reported in-band with the batch item itself succeeding. Either way `canExecute` could stay true while `simulatedAccounts` silently omitted a collateral or debt position, which a preflight consumer cannot distinguish from a complete healthy post-state. Record both failure modes and surface them on `SimulateBatchResult` as `snapshotReadFailures`, attributed per layer, sub-account and vault, with the lens `queryFailureReason` or the reverted read's return data. Positions still degrade to absent rather than throwing, matching the surrounding per-vault tolerance; `canExecute` still tracks whether the batch executes, and now documents the dependency on this field. Co-Authored-By: Claude Opus 5 --- .../src/services/executionService/index.ts | 1 + .../src/services/executionService/simulate.ts | 92 +++++- packages/euler-v2-sdk/test/simulate.test.ts | 292 ++++++++++++++++++ 3 files changed, 381 insertions(+), 4 deletions(-) diff --git a/packages/euler-v2-sdk/src/services/executionService/index.ts b/packages/euler-v2-sdk/src/services/executionService/index.ts index f348cdd2..2f46bf3c 100644 --- a/packages/euler-v2-sdk/src/services/executionService/index.ts +++ b/packages/euler-v2-sdk/src/services/executionService/index.ts @@ -145,5 +145,6 @@ export type { SimulateBatchOptions, SimulateBatchResult, SimulationInsufficientRequirement, + SimulationSnapshotReadFailure, SimulationStateOverrideOptions, } from "./simulate.js"; diff --git a/packages/euler-v2-sdk/src/services/executionService/simulate.ts b/packages/euler-v2-sdk/src/services/executionService/simulate.ts index 7fa7c740..c4a8f9da 100644 --- a/packages/euler-v2-sdk/src/services/executionService/simulate.ts +++ b/packages/euler-v2-sdk/src/services/executionService/simulate.ts @@ -187,6 +187,38 @@ export type SimulationInsufficientRequirement = { amount: bigint; }; +/** + * An AccountLens read that produced no position, leaving a snapshot layer + * incomplete. + * + * These never appear in `failedBatchItems`: `rawBatchResults` covers only the + * action positions of the batch, so lens reads are outside it, and a whole-vault + * failure is reported in-band by the lens with the batch item itself succeeding. + */ +export type SimulationSnapshotReadFailure = { + /** + * Index into `simulatedAccounts`: 0 = pre-batch (real) state, i = state after + * operation i. The highest index is the final post-batch state. + */ + layerIndex: number; + /** Sub-account whose decoded position set is incomplete. */ + subAccount: Address; + /** Vault the lens could not report on. Absent for account-scoped reads. */ + vault?: Address; + /** Which lens read failed. */ + kind: "vaultAccount" | "evcAccount"; + /** + * `inBand`: the read succeeded and the lens set `queryFailure`. + * `revert`: the lens read itself reverted. + */ + cause: "inBand" | "revert"; + /** The lens `queryFailureReason`, or the reverted read's return data. */ + reason?: Hex; +}; + +/** Layer-agnostic form; `decodeAccountSnapshot` does not know its own layer. */ +type SnapshotReadFailure = Omit; + export interface SimulateBatchResult< TVaultEntity extends VaultEntity = VaultEntity, > { @@ -206,7 +238,19 @@ export interface SimulateBatchResult< * delta vs layer 0. */ simulatedWalletBalances?: Record[]; + /** + * Whether the batch itself is expected to execute. A failed AccountLens read + * does not stop the batch, so this stays `true` when one occurs — check + * `snapshotReadFailures` before treating `simulatedAccounts` as a complete + * post-state (e.g. before deriving a health factor from it). + */ canExecute: boolean; + /** + * Lens reads that yielded no position, so the corresponding layer of + * `simulatedAccounts` is missing a collateral or debt position it may + * actually hold. Absent when every lens read reported cleanly. + */ + snapshotReadFailures?: SimulationSnapshotReadFailure[]; rawBatchResults?: BatchItemResult[]; failedBatchItems?: Array<{ index: number; @@ -582,6 +626,7 @@ export async function simulateTransactionPlan< account: Account; vaults: TVaultEntity[]; walletBalances: Record; + readFailures: SnapshotReadFailure[]; }> = []; for (const slice of layerSlices) { snapshots.push( @@ -601,6 +646,11 @@ export async function simulateTransactionPlan< const simulatedVaults = simulatedVaultsLayers[simulatedVaultsLayers.length - 1] ?? []; const simulatedWalletBalances = snapshots.map((s) => s.walletBalances); + // Stamped with the layer index here: `decodeAccountSnapshot` is called per + // layer and cannot know which one it produced. + const snapshotReadFailures = snapshots.flatMap((s, layerIndex) => + s.readFailures.map((failure) => ({ layerIndex, ...failure })), + ); // Accurate wallet shortfall from the per-layer balances (running-min over the // real-anchored balance), which nets out intra-batch funding. Prefer it when @@ -633,6 +683,8 @@ export async function simulateTransactionPlan< simulatedVaultsLayers, simulatedWalletBalances, canExecute, + snapshotReadFailures: + snapshotReadFailures.length > 0 ? snapshotReadFailures : undefined, rawBatchResults, failedBatchItems: failedBatchItems.length > 0 ? failedBatchItems : undefined, @@ -773,11 +825,13 @@ async function decodeAccountSnapshot< account: Account; vaults: TVaultEntity[]; walletBalances: Record; + readFailures: SnapshotReadFailure[]; }> { const vaultsByAddress = new Map(); const evcInfos = new Map(); const vaultInfosBySub = new Map(); const walletBalances: Record = {}; + const readFailures: SnapshotReadFailure[] = []; // Securitize collateral vaults are assembled from three separate reads // (ERC4626 info via UtilsLens, plus governorAdmin and supplyCapResolved read // directly off the vault), keyed by vault address and stitched after the loop. @@ -788,7 +842,22 @@ async function decodeAccountSnapshot< for (let i = 0; i < lensMeta.length; i++) { const meta = lensMeta[i]!; const resultItem = resultAt(i); - if (!resultItem?.success) continue; + if (!resultItem?.success) { + // Lens reads are not action positions, so a reverted one never reaches + // `failedBatchItems`. Record the reads that shape the account or the + // position it drops is indistinguishable from one the account lacks. + if (meta.kind === "vaultAccount" || meta.kind === "evcAccount") { + readFailures.push({ + kind: meta.kind, + subAccount: getAddress(meta.subAccount), + vault: + meta.kind === "vaultAccount" ? getAddress(meta.vault) : undefined, + cause: "revert", + reason: resultItem?.result, + }); + } + continue; + } if (meta.kind === "walletBalance") { const bal = decodeFunctionResult({ @@ -869,8 +938,18 @@ async function decodeAccountSnapshot< // The lens reports a whole-vault query failure in-band, so the EVC batch // item itself succeeded and this will not appear in `failedBatchItems`. // Drop the position, matching how a failed batch item is skipped above: - // a partial snapshot degrades to "position absent" rather than throwing. - if (decodedVaultInfo.queryFailure) continue; + // a partial snapshot degrades to "position absent" rather than throwing — + // but record it, so the gap is visible to preflight consumers. + if (decodedVaultInfo.queryFailure) { + readFailures.push({ + kind: "vaultAccount", + subAccount: getAddress(meta.subAccount), + vault: getAddress(meta.vault), + cause: "inBand", + reason: decodedVaultInfo.queryFailureReason, + }); + continue; + } const key = getAddress(meta.subAccount); const list = vaultInfosBySub.get(key) ?? []; list.push(decodedVaultInfo); @@ -990,7 +1069,12 @@ async function decodeAccountSnapshot< await populatedAccount.populateUserRewards(ctx.rewardsService); } - return { account: populatedAccount, vaults: simulatedVaults, walletBalances }; + return { + account: populatedAccount, + vaults: simulatedVaults, + walletBalances, + readFailures, + }; } export async function estimateGasForTransactionPlan( diff --git a/packages/euler-v2-sdk/test/simulate.test.ts b/packages/euler-v2-sdk/test/simulate.test.ts index ae758c9d..203f4615 100644 --- a/packages/euler-v2-sdk/test/simulate.test.ts +++ b/packages/euler-v2-sdk/test/simulate.test.ts @@ -1190,6 +1190,298 @@ test("simulateTransactionPlan reads swap-verifier account candidates", async () assert.ok(vaultAccountReads.has(`${subAccount}:${getAddress(TOKEN)}`)); }); +const emptyLiquidityInfo = { + queryFailure: false, + queryFailureReason: "0x" as const, + account: CHECKSUM_ACCOUNT, + vault: getAddress(TARGET), + unitOfAccount: getAddress(TOKEN), + timeToLiquidation: 0n, + liabilityValueBorrowing: 0n, + liabilityValueLiquidation: 0n, + collateralValueBorrowing: 0n, + collateralValueLiquidation: 0n, + collateralValueRaw: 0n, + collaterals: [], + collateralValuesBorrowing: [], + collateralValuesLiquidation: [], + collateralValuesRaw: [], +}; + +/** A `VaultAccountInfo` whose whole-vault read the lens reports as failed. */ +const failedVaultAccountInfo = (reason: `0x${string}`) => ({ + queryFailure: true, + queryFailureReason: reason, + timestamp: 0n, + account: CHECKSUM_ACCOUNT, + vault: getAddress(TARGET), + asset: getAddress(TOKEN), + assetsAccount: 0n, + shares: 0n, + assets: 0n, + borrowed: 0n, + assetAllowanceVault: 0n, + assetAllowanceVaultPermit2: 0n, + assetAllowanceExpirationVaultPermit2: 0n, + assetAllowancePermit2: 0n, + balanceForwarderEnabled: false, + isController: false, + isCollateral: false, + liquidityInfo: emptyLiquidityInfo, +}); + +const evcAccountInfo = () => ({ + timestamp: 0n, + evc: getAddress(EVC), + account: CHECKSUM_ACCOUNT, + addressPrefix: `0x${"11".repeat(19)}` as const, + owner: CHECKSUM_ACCOUNT, + isLockdownMode: false, + isPermitDisabledMode: false, + lastAccountStatusCheckTimestamp: 0n, + enabledControllers: [], + enabledCollaterals: [getAddress(TARGET)], +}); + +/** + * Simulates a plan whose action items all succeed, while every AccountLens + * `getVaultAccountInfo` read returns in-band `queryFailure`. + */ +async function simulateWithFailedVaultAccountReads(reason: `0x${string}`) { + const provider = { + simulateContract: vi.fn( + async ({ args }: { args: readonly [EVCBatchItem[]] }) => ({ + result: [ + args[0].map((item) => { + if (getAddress(item.targetContract) !== getAddress(ACCOUNT_LENS)) { + // Action items succeed, so nothing but the lens failure can + // hold `canExecute` down. Other lens reads are left failing, + // as elsewhere in this file — they degrade to "no entity". + const isAction = + getAddress(item.targetContract) === getAddress(TARGET) || + getAddress(item.targetContract) === getAddress(VERIFIER); + return { success: isAction, result: "0x" }; + } + const decoded = decodeFunctionData({ + abi: accountLensAbi, + data: item.data, + }); + if (decoded.functionName === "getEVCAccountInfo") { + return { + success: true, + result: encodeFunctionResult({ + abi: accountLensAbi, + functionName: "getEVCAccountInfo", + result: evcAccountInfo() as never, + }), + }; + } + if (decoded.functionName === "getVaultAccountInfo") { + return { + success: true, + result: encodeFunctionResult({ + abi: accountLensAbi, + functionName: "getVaultAccountInfo", + result: failedVaultAccountInfo(reason) as never, + }), + }; + } + return { success: true, result: "0x" }; + }), + [], + [], + ], + }), + ), + multicall: vi.fn(async () => []), + readContract: vi.fn(async () => { + throw new Error("asset unavailable"); + }), + }; + const service = new ExecutionService( + { + getDeployment: () => ({ + addresses: { + coreAddrs: { + evc: EVC, + permit2: "0x0000000000000000000000000000000000000012", + }, + lensAddrs: { + accountLens: ACCOUNT_LENS, + vaultLens: VAULT_LENS, + eulerEarnVaultLens: EULER_EARN_LENS, + utilsLens: UTILS_LENS, + }, + }, + }), + } as never, + undefined, + { getProvider: () => provider } as never, + { + fetchVaultTypes: async () => ({ [getAddress(TARGET)]: VaultType.EVault }), + } as never, + ); + + const plan: TransactionPlan = [ + { + type: "evcBatch", + items: [ + { + targetContract: TARGET, + onBehalfOfAccount: ACCOUNT, + value: 0n, + data: encodeFunctionData({ + abi: eVaultAbi, + functionName: "deposit", + args: [100n, ACCOUNT], + }), + }, + ], + }, + ]; + + return service.simulateTransactionPlan(1, CHECKSUM_ACCOUNT, plan, { + stateOverrides: false, + }); +} + +test("simulateTransactionPlan exposes an in-band lens vault-read failure", async () => { + // The lens reports whole-vault failure in-band, so the EVC batch item + // succeeds and nothing lands in `failedBatchItems`. Without + // `snapshotReadFailures` the dropped position is indistinguishable from a + // position the account does not hold. + const reason = "0xdeadbeef" as const; + + const result = await simulateWithFailedVaultAccountReads(reason); + + assert.equal(result.failedBatchItems, undefined); + assert.equal(result.accountStatusErrors, undefined); + assert.equal(result.vaultStatusErrors, undefined); + assert.equal(result.canExecute, true); + + const failures = result.snapshotReadFailures ?? []; + assert.ok(failures.length > 0, "expected the lens failure to be reported"); + const inBand = failures.filter((failure) => failure.cause === "inBand"); + assert.ok(inBand.length > 0, "expected an in-band failure"); + for (const failure of inBand) { + assert.equal(failure.kind, "vaultAccount"); + assert.equal(failure.subAccount, CHECKSUM_ACCOUNT); + assert.equal(failure.vault, getAddress(TARGET)); + assert.equal(failure.reason, reason); + assert.ok(typeof failure.layerIndex === "number"); + } + + // Incompleteness is visible precisely because the position is absent: the + // final layer holds no position for the vault the lens could not report. + const finalAccount = result.simulatedAccounts.at(-1); + const positions = Object.values(finalAccount?.subAccounts ?? {}).flatMap( + (subAccount) => subAccount?.positions ?? [], + ); + assert.equal( + positions.some( + (position) => getAddress(position.vaultAddress) === getAddress(TARGET), + ), + false, + ); + // Every layer that read the vault reported the failure, so a consumer cannot + // trust any layer's position set without checking. + assert.ok( + new Set(inBand.map((failure) => failure.layerIndex)).size >= 1, + "expected per-layer attribution", + ); +}); + +/** Simulates a plan where every AccountLens read reverts. */ +async function simulateTransactionPlanWithFailingLensReads( + plan: TransactionPlan, +) { + const provider = { + simulateContract: vi.fn( + async ({ args }: { args: readonly [EVCBatchItem[]] }) => ({ + result: [ + args[0].map((item) => ({ + success: + getAddress(item.targetContract) === getAddress(TARGET) || + getAddress(item.targetContract) === getAddress(VERIFIER), + result: "0x", + })), + [], + [], + ], + }), + ), + multicall: vi.fn(async () => []), + readContract: vi.fn(async () => { + throw new Error("asset unavailable"); + }), + }; + const service = new ExecutionService( + { + getDeployment: () => ({ + addresses: { + coreAddrs: { + evc: EVC, + permit2: "0x0000000000000000000000000000000000000012", + }, + lensAddrs: { + accountLens: ACCOUNT_LENS, + vaultLens: VAULT_LENS, + eulerEarnVaultLens: EULER_EARN_LENS, + utilsLens: UTILS_LENS, + }, + }, + }), + } as never, + undefined, + { getProvider: () => provider } as never, + { + fetchVaultTypes: async () => ({ [getAddress(TARGET)]: VaultType.EVault }), + } as never, + ); + + return service.simulateTransactionPlan(1, CHECKSUM_ACCOUNT, plan, { + stateOverrides: false, + }); +} + +test("simulateTransactionPlan exposes a reverted lens vault-read", async () => { + // `rawBatchResults` covers only the action positions, so a reverted lens read + // cannot reach `failedBatchItems` either. It drops a position just as the + // in-band failure does and must be just as visible. + const plan: TransactionPlan = [ + { + type: "evcBatch", + items: [ + { + targetContract: EVC, + onBehalfOfAccount: ACCOUNT, + value: 0n, + data: encodeFunctionData({ + abi: ethereumVaultConnectorAbi, + functionName: "enableCollateral", + args: [ACCOUNT, TARGET], + }), + }, + ], + }, + ]; + + const result = await simulateTransactionPlanWithFailingLensReads(plan); + + const failures = result.snapshotReadFailures ?? []; + const reverted = failures.filter( + (failure) => failure.kind === "vaultAccount" && failure.cause === "revert", + ); + assert.ok(reverted.length > 0, "expected a reverted vault read to be reported"); + assert.ok( + reverted.some( + (failure) => + failure.subAccount === CHECKSUM_ACCOUNT && + failure.vault === getAddress(TARGET), + ), + ); +}); + test("extractBalanceRequirements sums a token's approvals across spenders", () => { // Same token pulled by two different spenders (e.g. supplying it into two // vaults) — the wallet must fund the total, so the forge requirement is the From f797bca26c37c00af6440e8e64f352b2057075f5 Mon Sep 17 00:00:00 2001 From: dglowinski Date: Wed, 29 Jul 2026 16:45:19 +0200 Subject: [PATCH 6/6] fix(sdk): keep the deprecated RewardsService reward-read surface usable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Renaming the reward reader to `queryRewardAccountInfo` removed a public property and changed the deprecated setter's callback contract, so existing TypeScript consumers failed to compile: direct access to `queryVaultAccountInfo` with TS2551, and an old-style `setQueryVaultAccountInfo` callback with TS2322 once `AccountRewardInfo` required `balanceTracker` and `balance`. Restore `queryVaultAccountInfo` with its original contract, deprecated and unused internally, and widen the deprecated setter to accept the legacy return shape, projecting it onto `AccountRewardInfo` so only the fields `fetchRewardStreams` actually reads are required. The setter keeps retargeting the live reader, as it always did — pointing it at the unused legacy property would silently discard an override. Note the rename itself was a fix: `getVaultAccountInfo` carries no `enabledRewardsInfo`, so the previous reader made `fetchRewardStreams` return an empty list unconditionally. Co-Authored-By: Claude Opus 5 --- .../src/services/rewardsService/index.ts | 4 + .../services/rewardsService/rewardsService.ts | 86 +++++++++++++- .../euler-v2-sdk/test/rewardsService.test.ts | 105 ++++++++++++++++++ 3 files changed, 191 insertions(+), 4 deletions(-) diff --git a/packages/euler-v2-sdk/src/services/rewardsService/index.ts b/packages/euler-v2-sdk/src/services/rewardsService/index.ts index 40d7eafc..2edbbedd 100644 --- a/packages/euler-v2-sdk/src/services/rewardsService/index.ts +++ b/packages/euler-v2-sdk/src/services/rewardsService/index.ts @@ -1,4 +1,8 @@ export { RewardsService } from "./rewardsService.js"; +export type { + LegacyQueryRewardAccountInfoFn, + LegacyRewardAccountInfo, +} from "./rewardsService.js"; export { RewardsDirectAdapter } from "./adapters/rewardsDirectAdapter/index.js"; export { RewardsV3Adapter } from "./adapters/rewardsV3Adapter/index.js"; export { VaultRewardInfo } from "./vaultRewardInfo.js"; diff --git a/packages/euler-v2-sdk/src/services/rewardsService/rewardsService.ts b/packages/euler-v2-sdk/src/services/rewardsService/rewardsService.ts index c2e7eda9..08ba8317 100644 --- a/packages/euler-v2-sdk/src/services/rewardsService/rewardsService.ts +++ b/packages/euler-v2-sdk/src/services/rewardsService/rewardsService.ts @@ -18,7 +18,10 @@ import type { DeploymentService } from "../deploymentService/index.js"; import type { IABIService } from "../abiService/index.js"; import { accountLensAbi } from "../accountService/adapters/accountOnchainAdapter/abis/accountLensAbi.js"; import { resolveAccountLensAbi } from "../accountService/adapters/accountOnchainAdapter/resolveAccountLensAbi.js"; -import type { AccountRewardInfo } from "../accountService/adapters/accountOnchainAdapter/accountLensTypes.js"; +import type { + AccountRewardInfo, + VaultAccountInfo, +} from "../accountService/adapters/accountOnchainAdapter/accountLensTypes.js"; import type { BuildRewardClaimAllPlanArgs, BuildRewardClaimPlanArgs, @@ -445,6 +448,51 @@ const uniqueAddresses = ( } return [...out.values()]; }; + +/** + * Return shape accepted from a legacy `setQueryVaultAccountInfo` callback. + * + * Everything `AccountRewardInfo` requires beyond the old `VaultAccountInfo` is + * optional here, so a callback written against the old declared return type still + * compiles. `account`, `vault`, and `enabledRewardsInfo` are the only fields + * `fetchRewardStreams` reads. + */ +export interface LegacyRewardAccountInfo { + account: Address; + vault: Address; + enabledRewardsInfo?: AccountRewardInfo["enabledRewardsInfo"]; + timestamp?: bigint; + balanceTracker?: Address; + balanceForwarderEnabled?: boolean; + balance?: bigint; +} + +/** Callback signature the deprecated `setQueryVaultAccountInfo` still accepts. */ +export type LegacyQueryRewardAccountInfoFn = ( + provider: ReturnType, + accountLensAddress: Address, + account: Address, + vault: Address, + abi?: Abi, +) => Promise; + +/** + * Widens a legacy callback's result to `AccountRewardInfo`, defaulting the fields + * an old callback had no way to supply. Only `enabledRewardsInfo` reaches + * `fetchRewardStreams`; the rest are inert placeholders. + */ +const projectLegacyRewardAccountInfo = ( + info: LegacyRewardAccountInfo, +): AccountRewardInfo => ({ + timestamp: info.timestamp ?? 0n, + account: info.account, + vault: info.vault, + balanceTracker: info.balanceTracker ?? zeroAddress, + balanceForwarderEnabled: info.balanceForwarderEnabled ?? false, + balance: info.balance ?? 0n, + enabledRewardsInfo: info.enabledRewardsInfo ?? [], +}); + export class RewardsService implements IRewardsService { private providerService?: ProviderService; private deploymentService?: DeploymentService; @@ -576,9 +624,39 @@ export class RewardsService implements IRewardsService { this.queryRewardAccountInfo = fn; } - /** @deprecated Use `setQueryRewardAccountInfo`. */ - setQueryVaultAccountInfo(fn: typeof this.queryRewardAccountInfo): void { - this.setQueryRewardAccountInfo(fn); + /** + * @deprecated Reads `getVaultAccountInfo`, which carries no reward data, so + * `fetchRewardStreams` never used the result it returns. Kept for source + * compatibility and unused internally; use `queryRewardAccountInfo`. + */ + queryVaultAccountInfo = async ( + provider: ReturnType, + accountLensAddress: Address, + account: Address, + vault: Address, + ): Promise => { + return provider.readContract({ + address: accountLensAddress, + abi: accountLensAbi, + functionName: "getVaultAccountInfo", + args: [account, vault], + }) as Promise; + }; + + /** + * @deprecated Use `setQueryRewardAccountInfo`. + * + * Retargets the same reader `fetchRewardStreams` uses, as it always did — + * pointing this at the unused `queryVaultAccountInfo` property instead would + * silently discard the override. The callback's return value is projected onto + * `AccountRewardInfo`, so a callback written against the old declared + * `VaultAccountInfo` return type still compiles: only the fields + * `fetchRewardStreams` reads are required. + */ + setQueryVaultAccountInfo(fn: LegacyQueryRewardAccountInfoFn): void { + this.setQueryRewardAccountInfo(async (...args) => + projectLegacyRewardAccountInfo(await fn(...args)), + ); } async fetchRewardStreams( diff --git a/packages/euler-v2-sdk/test/rewardsService.test.ts b/packages/euler-v2-sdk/test/rewardsService.test.ts index 4d80c611..36bde53c 100644 --- a/packages/euler-v2-sdk/test/rewardsService.test.ts +++ b/packages/euler-v2-sdk/test/rewardsService.test.ts @@ -1998,6 +1998,111 @@ test("rewards service fetches claimable reward streams from account lens", async ]); }); +test("deprecated setQueryVaultAccountInfo still drives reward stream reads", async () => { + // The old callback was declared as returning `VaultAccountInfo`, so it supplies + // neither `balanceTracker` nor `balance`. It must keep compiling and keep + // overriding the reader `fetchRewardStreams` uses — retargeting the unused + // legacy property instead would silently discard the override. + const service = makeRewardsService(); + const calls: Array<{ account: Address; vault: Address }> = []; + + service.setQueryVaultAccountInfo(async (_provider, _lens, account, vault) => { + calls.push({ account, vault }); + return { + account, + vault, + enabledRewardsInfo: [ + { + reward: rewardToken, + earnedReward: 100n, + earnedRewardRecentIgnored: 75n, + }, + ], + }; + }); + + const rewardStreams = await service.fetchRewardStreams({ + chainId: 1, + positions: [{ account: accountAddress, vault: vaultAddress }], + }); + + assert.deepEqual(calls, [{ account: accountAddress, vault: vaultAddress }]); + assert.deepEqual(rewardStreams, [ + { + account: accountAddress, + vault: vaultAddress, + reward: rewardToken, + earnedReward: 100n, + earnedRewardRecentIgnored: 75n, + }, + ]); +}); + +test("deprecated setQueryVaultAccountInfo accepts a full AccountRewardInfo", async () => { + // A caller who already migrated the return shape but not the setter name must + // not be broken by the legacy projection. + const service = makeRewardsService(); + + service.setQueryVaultAccountInfo(async (_provider, _lens, account, vault) => ({ + timestamp: 7n, + account, + vault, + balanceTracker: rewardStreamsAddress, + balanceForwarderEnabled: true, + balance: 5n, + enabledRewardsInfo: [ + { reward: rewardToken, earnedReward: 1n, earnedRewardRecentIgnored: 0n }, + ], + })); + + const rewardStreams = await service.fetchRewardStreams({ + chainId: 1, + positions: [{ account: accountAddress, vault: vaultAddress }], + }); + + assert.deepEqual(rewardStreams, [ + { + account: accountAddress, + vault: vaultAddress, + reward: rewardToken, + earnedReward: 1n, + earnedRewardRecentIgnored: 0n, + }, + ]); +}); + +test("deprecated queryVaultAccountInfo still reads getVaultAccountInfo", async () => { + // Direct property access is part of the exported surface, so it stays callable + // and keeps its original contract. + const service = makeRewardsService(); + const readCalls: Array<{ + address: Address; + functionName: string; + args: readonly unknown[]; + }> = []; + const readContract = async (call: { + address: Address; + functionName: string; + args: readonly unknown[]; + }) => { + readCalls.push(call); + return { account: accountAddress }; + }; + + const info = await service.queryVaultAccountInfo( + { readContract } as never, + accountLensAddress, + accountAddress, + vaultAddress, + ); + + assert.equal(readCalls.length, 1); + assert.equal(readCalls[0]?.address, accountLensAddress); + assert.equal(readCalls[0]?.functionName, "getVaultAccountInfo"); + assert.deepEqual(readCalls[0]?.args, [accountAddress, vaultAddress]); + assert.equal(info.account, accountAddress); +}); + test("rewards service builds reward stream claims as an EVC batch", () => { const service = makeRewardsService();