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 fc6a8116..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( @@ -651,6 +668,7 @@ export async function buildEulerSDK< deploymentService as DeploymentService, accountVaultsAdapter, resolvedBuildQuery, + abiService, ); return accountOnchainAdapter; }; @@ -1419,7 +1437,10 @@ export async function buildEulerSDK< fuulManagerAddress: directAdapter.getFuulManagerAddress(), fuulFactoryAddress: directAdapter.getFuulFactoryAddress(), }, - { isActiveForViewer: rewardsServiceConfig?.isActiveForViewer }, + { + isActiveForViewer: rewardsServiceConfig?.isActiveForViewer, + abiService, + }, ); })(); @@ -1571,6 +1592,7 @@ export async function buildEulerSDK< ); if (executionService instanceof ExecutionService) { + executionService.setABIService(abiService); executionService.setProviderService(providerService as ProviderService); executionService.setVaultMetaService( vaultMetaService as IVaultMetaService, @@ -1611,6 +1633,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/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 496d628a..d4d69d8f 100644 --- a/packages/euler-v2-sdk/src/services/abiService/abiService.ts +++ b/packages/euler-v2-sdk/src/services/abiService/abiService.ts @@ -5,31 +5,67 @@ 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 abis: Record = {}; + 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 => { 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 { this.queryABI = fn; } - async fetchABI(_: number, contract: string): Promise { - if (!this.abis[contract]) { - this.abis[contract] = await this.queryABI(this.getABIURL(_, 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 this.abis[contract]; + return request; } } 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 8a8401da..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,7 +34,24 @@ 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; + 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 06075bf3..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 @@ -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"; @@ -14,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, @@ -38,12 +41,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 +58,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 +83,7 @@ export class AccountOnchainAdapter implements IAccountAdapter { private deploymentService: DeploymentService, private positionsAdapter: IAccountVaultsAdapter, buildQuery?: BuildQueryFn, + private abiService?: IABIService, ) { if (buildQuery) applyBuildQuery(this, buildQuery); } @@ -103,15 +109,29 @@ 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], }); }; + // 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; } @@ -121,15 +141,32 @@ 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], }); }; + /** 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; } @@ -243,6 +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 { 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( @@ -250,6 +300,7 @@ export class AccountOnchainAdapter implements IAccountAdapter { accountLensAddress, evc, subAccount, + resolvedAccountLensAbi, ); if (!evcAccountInfoResult) return { result: undefined, errors }; @@ -274,6 +325,7 @@ export class AccountOnchainAdapter implements IAccountAdapter { subAccount, vaults, errors, + resolvedAccountLensAbi, ); } else { vaultAccountInfos = await this.queryVaultAccountInfosGracefully( @@ -283,6 +335,7 @@ export class AccountOnchainAdapter implements IAccountAdapter { subAccount, vaults, errors, + resolvedAccountLensAbi, ); } @@ -357,6 +410,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 +451,7 @@ export class AccountOnchainAdapter implements IAccountAdapter { subAccount, vaults, errors, + resolvedAccountLensAbi, ); } @@ -411,7 +466,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 +483,7 @@ export class AccountOnchainAdapter implements IAccountAdapter { accountLensAddress, subAccount, vault, + resolvedAccountLensAbi, ) as Promise; }), ); @@ -448,6 +504,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 +513,7 @@ export class AccountOnchainAdapter implements IAccountAdapter { accountLensAddress, subAccount, vault, + resolvedAccountLensAbi, ), ), ); @@ -478,15 +536,10 @@ export class AccountOnchainAdapter implements IAccountAdapter { ): VaultAccountInfo[] { const vaultAccountInfos: VaultAccountInfo[] = []; - results.forEach((result, index) => { - const vault = vaults[index]; - if (!vault) return; - - if (result.status === "fulfilled") { - vaultAccountInfos.push(result.value as VaultAccountInfo); - return; - } - + // `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", @@ -498,11 +551,31 @@ export class AccountOnchainAdapter implements IAccountAdapter { ), ], source: "accountLens", - originalValue: - result.reason instanceof Error - ? result.reason.message - : String(result.reason), + originalValue, }); + }; + + results.forEach((result, index) => { + const vault = vaults[index]; + if (!vault) return; + + if (result.status === "fulfilled") { + const vaultAccountInfo = result.value as VaultAccountInfo; + if (vaultAccountInfo.queryFailure) { + pushUnavailable(vault, vaultAccountInfo.queryFailureReason); + return; + } + + vaultAccountInfos.push(vaultAccountInfo); + return; + } + + 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..3aadcc5b --- /dev/null +++ b/packages/euler-v2-sdk/src/services/accountService/adapters/accountOnchainAdapter/resolveAccountLensAbi.ts @@ -0,0 +1,70 @@ +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 DEFAULT_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, + requiredFunctions: readonly string[], +): string[] => + requiredFunctions.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, + requiredFunctions: readonly string[] = DEFAULT_REQUIRED_FUNCTIONS, +): Promise { + if (!abiService) return { abi: bundledAccountLensAbi }; + + try { + const abi = await abiService.fetchABI(chainId, "AccountLens"); + const missing = missingFunctions(abi, requiredFunctions); + 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/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/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 b5366852..c4a8f9da 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, @@ -114,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, @@ -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"; @@ -185,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, > { @@ -204,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; @@ -309,6 +355,7 @@ export type ExecutionSimulationContext< rewardsService?: IRewardsService; intrinsicApyService?: IIntrinsicApyService; eulerLabelsService?: IEulerLabelsService; + abiService?: IABIService; describeBatch: (batch: readonly EVCBatchItem[]) => BatchItemDescription[]; }; @@ -461,6 +508,13 @@ export async function simulateTransactionPlan< canExecute: false, }; } + // 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, @@ -472,6 +526,7 @@ export async function simulateTransactionPlan< chainId, owner, operations, + resolvedAccountLensAbi, extractBalanceRequirements(transactionPlan, owner).map(([token]) => token), ); @@ -571,6 +626,7 @@ export async function simulateTransactionPlan< account: Account; vaults: TVaultEntity[]; walletBalances: Record; + readFailures: SnapshotReadFailure[]; }> = []; for (const slice of layerSlices) { snapshots.push( @@ -579,6 +635,7 @@ export async function simulateTransactionPlan< chainId, owner, lensMeta, + resolvedAccountLensAbi, (i) => batchResults[slice.lensStart + i], options, ), @@ -589,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 @@ -621,6 +683,8 @@ export async function simulateTransactionPlan< simulatedVaultsLayers, simulatedWalletBalances, canExecute, + snapshotReadFailures: + snapshotReadFailures.length > 0 ? snapshotReadFailures : undefined, rawBatchResults, failedBatchItems: failedBatchItems.length > 0 ? failedBatchItems : undefined, @@ -754,17 +818,20 @@ async function decodeAccountSnapshot< chainId: number, owner: Address, lensMeta: LensMeta[], + resolvedAccountLensAbi: Abi, resultAt: (index: number) => BatchItemResult | undefined, options?: SimulateBatchOptions, ): Promise<{ 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. @@ -775,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({ @@ -840,7 +922,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,10 +931,25 @@ async function decodeAccountSnapshot< if (meta.kind === "vaultAccount") { const decodedVaultInfo = decodeFunctionResult({ - abi: accountLensAbi, + abi: resolvedAccountLensAbi, 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 — + // 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); @@ -972,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( @@ -1075,6 +1177,7 @@ async function buildSimulationBatch( chainId: number, owner: Address, operations: SimulationOperation[], + resolvedAccountLensAbi: Abi, requiredWalletBalanceTokens: Address[] = [], ): Promise<{ lensItems: EVCBatchItem[]; @@ -1216,6 +1319,7 @@ async function buildSimulationBatch( evcAddress, subAccount, owner, + resolvedAccountLensAbi, ), { kind: "evcAccount", @@ -1231,6 +1335,7 @@ async function buildSimulationBatch( subAccount, vault, owner, + resolvedAccountLensAbi, ), { kind: "vaultAccount", 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 62663bf1..08ba8317 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,8 +15,13 @@ 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 { resolveAccountLensAbi } from "../accountService/adapters/accountOnchainAdapter/resolveAccountLensAbi.js"; +import type { + AccountRewardInfo, + VaultAccountInfo, +} from "../accountService/adapters/accountOnchainAdapter/accountLensTypes.js"; import type { BuildRewardClaimAllPlanArgs, BuildRewardClaimPlanArgs, @@ -442,9 +448,55 @@ 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; + private abiService?: IABIService; private isActiveForViewer: IsActiveForViewerFn; constructor( @@ -455,8 +507,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 +529,10 @@ export class RewardsService implements IRewardsService { this.deploymentService = deploymentService; } + setABIService(abiService: IABIService): void { + this.abiService = abiService; + } + setIsActiveForViewer(fn: IsActiveForViewerFn): void { this.isActiveForViewer = fn; } @@ -545,6 +605,30 @@ export class RewardsService implements IRewardsService { return this.adapter.fetchFuulClaimChecks(address, chainId); } + queryRewardAccountInfo = async ( + provider: ReturnType, + accountLensAddress: Address, + account: Address, + vault: Address, + abi: Abi = accountLensAbi, + ): Promise => { + return provider.readContract({ + address: accountLensAddress, + abi, + functionName: "getRewardAccountInfo", + args: [account, vault], + }) as Promise; + }; + + setQueryRewardAccountInfo(fn: typeof this.queryRewardAccountInfo): void { + this.queryRewardAccountInfo = 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, @@ -559,8 +643,20 @@ export class RewardsService implements IRewardsService { }) as Promise; }; - setQueryVaultAccountInfo(fn: typeof this.queryVaultAccountInfo): void { - this.queryVaultAccountInfo = fn; + /** + * @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( @@ -580,29 +676,41 @@ export class RewardsService implements IRewardsService { }), ).values(), ); + 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, [ + "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, position.vault, + resolvedAccountLensAbi, ), ), ); - return vaultAccountInfos.flatMap((vaultAccountInfo) => - (vaultAccountInfo.enabledRewardsInfo ?? []) + return rewardAccountInfoResults.flatMap((result) => { + if (result.status === "rejected") return []; + + return result.value.enabledRewardsInfo .filter((rewardInfo) => rewardInfo.earnedReward > 0n) .map((rewardInfo) => ({ - account: getAddress(vaultAccountInfo.account) as Address, - vault: getAddress(vaultAccountInfo.vault) as Address, + 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 new file mode 100644 index 00000000..d15eb1b7 --- /dev/null +++ b/packages/euler-v2-sdk/test/accountLensAbiService.test.ts @@ -0,0 +1,535 @@ +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"; +import { + type BuildQueryFn, + createQueryCacheBuildQuery, +} from "../src/utils/buildQuery.js"; + +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 = [ + { + 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" }], + }, + { + 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 = () => { + 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"); + // 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, third])).resolves.toEqual([ + runtimeAccountLensAbi, + runtimeAccountLensAbi, + runtimeAccountLensAbi, + ]); + + // 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, "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("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"); + + 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 }) => { + 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("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, + 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, + { + 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(); + }); + + it("isolates failed reward-account reads by position", async () => { + const { service: abiService } = makeAbiService(); + 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, + { + 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 }, + { account: ACCOUNT, vault: SECOND_VAULT }, + ], + }), + ).resolves.toEqual([ + { + account: ACCOUNT, + vault: SECOND_VAULT, + reward: EVC, + earnedReward: 1n, + earnedRewardRecentIgnored: 0n, + }, + ]); + }); +}); + +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/rewardsService.test.ts b/packages/euler-v2-sdk/test/rewardsService.test.ts index 94bb9602..36bde53c 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 { @@ -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(); 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({ diff --git a/packages/euler-v2-sdk/test/simulate.test.ts b/packages/euler-v2-sdk/test/simulate.test.ts index f47b24af..203f4615 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,71 @@ 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 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 = [ @@ -1122,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