From a4d4ddadf872ec66f6e8a0d7c2b94c2b33582d40 Mon Sep 17 00:00:00 2001 From: Matthew Grainger Date: Fri, 10 Apr 2026 13:50:30 -0400 Subject: [PATCH 1/5] feat: first iteration of the money-account-balance-service --- .../money-account-controller/package.json | 8 +- .../money-account-controller/src/index.ts | 24 ++ .../constants.ts | 51 +++ ...unt-balance-service-method-action-types.ts | 73 ++++ .../money-account-balance-service.ts | 359 ++++++++++++++++++ .../money-account-balance-service/types.ts | 57 +++ .../tsconfig.build.json | 9 +- .../money-account-controller/tsconfig.json | 5 +- 8 files changed, 583 insertions(+), 3 deletions(-) create mode 100644 packages/money-account-controller/src/money-account-balance-service/constants.ts create mode 100644 packages/money-account-controller/src/money-account-balance-service/money-account-balance-service-method-action-types.ts create mode 100644 packages/money-account-controller/src/money-account-balance-service/money-account-balance-service.ts create mode 100644 packages/money-account-controller/src/money-account-balance-service/types.ts diff --git a/packages/money-account-controller/package.json b/packages/money-account-controller/package.json index 8e4f6933efe..9559c468650 100644 --- a/packages/money-account-controller/package.json +++ b/packages/money-account-controller/package.json @@ -49,18 +49,24 @@ "test:watch": "NODE_OPTIONS=--experimental-vm-modules jest --watch" }, "dependencies": { + "@ethersproject/contracts": "^5.7.0", + "@ethersproject/providers": "^5.7.0", "@metamask/accounts-controller": "^37.2.0", "@metamask/base-controller": "^9.0.1", + "@metamask/base-data-service": "^0.1.1", + "@metamask/controller-utils": "^11.20.0", "@metamask/eth-money-keyring": "^2.0.0", "@metamask/keyring-api": "^21.6.0", "@metamask/keyring-controller": "^25.2.0", "@metamask/messenger": "^1.1.1", + "@metamask/metamask-eth-abis": "^3.1.1", + "@metamask/network-controller": "^30.0.1", + "@metamask/utils": "^11.9.0", "async-mutex": "^0.5.0" }, "devDependencies": { "@metamask/auto-changelog": "^3.4.4", "@metamask/keyring-utils": "^3.1.0", - "@metamask/utils": "^11.9.0", "@ts-bridge/cli": "^0.6.4", "@types/jest": "^29.5.14", "deepmerge": "^4.2.2", diff --git a/packages/money-account-controller/src/index.ts b/packages/money-account-controller/src/index.ts index cfc0f01c007..8087e26fb83 100644 --- a/packages/money-account-controller/src/index.ts +++ b/packages/money-account-controller/src/index.ts @@ -18,3 +18,27 @@ export type { MoneyAccountControllerCreateMoneyAccountAction, MoneyAccountControllerGetMoneyAccountAction, } from './MoneyAccountController-method-action-types'; +export { + MoneyAccountBalanceService, + serviceName as moneyAccountBalanceServiceName, +} from './money-account-balance-service/money-account-balance-service'; +export type { + MoneyAccountBalanceServiceActions, + MoneyAccountBalanceServiceEvents, + MoneyAccountBalanceServiceMessenger, +} from './money-account-balance-service/money-account-balance-service'; +export type { + MoneyAccountBalanceServiceGetMusdBalanceAction, + MoneyAccountBalanceServiceGetMusdSHFvdBalanceAction, + MoneyAccountBalanceServiceGetExchangeRateAction, + MoneyAccountBalanceServiceGetMusdEquivalentValueAction, + MoneyAccountBalanceServiceGetVaultApyAction, +} from './money-account-balance-service/money-account-balance-service-method-action-types'; +export type { + MusdBalanceResponse, + MusdSHFvdBalanceResponse, + ExchangeRateResponse, + MusdEquivalentValueResponse, + VaultApyResponse, + VaultApyBreakdownEntry, +} from './money-account-balance-service/types'; diff --git a/packages/money-account-controller/src/money-account-balance-service/constants.ts b/packages/money-account-controller/src/money-account-balance-service/constants.ts new file mode 100644 index 00000000000..3bba7f86b02 --- /dev/null +++ b/packages/money-account-controller/src/money-account-balance-service/constants.ts @@ -0,0 +1,51 @@ +import type { Hex } from '@metamask/utils'; + +// TODO: Replace placeholder addresses with actual deployed contract addresses. +// TODO: Rename constants to be more generic. +/** + * Arbitrum USDC (test Vault): 0xaf88d065e77c8cc2239327c5edb3a432268e5831 + */ +export const MUSD_CONTRACT_ADDRESS: Hex = + '0xaf88d065e77c8cc2239327c5edb3a432268e5831'; + +/** + * Arbitrum USDC (test Vault): 0xB5F07d769dD60fE54c97dd53101181073DDf21b2 + */ +// TODO: Rename to Veda Vault address +export const MUSDHFVD_CONTRACT_ADDRESS: Hex = + '0xB5F07d769dD60fE54c97dd53101181073DDf21b2'; + +// TODO: Rename to Veda Accountant address +/** + * Arbitrum Accountant (test Vault): 0x800ebc3B74F67EaC27C9CCE4E4FF28b17CdCA173 + */ +export const ACCOUNTANT_CONTRACT_ADDRESS: Hex = + '0x800ebc3B74F67EaC27C9CCE4E4FF28b17CdCA173'; + +// TODO: Use CHAIN_IDS.ARBITRUM instead. +export const VAULT_CHAIN_ID: Hex = '0xa4b1'; // Arbitrum One + +// TODO: Replace with the canonical Veda network identifier for the deployment. +export const VEDA_NETWORK = 'arbitrum'; + +export const MUSD_DECIMALS = 6; + +export const MUSDHFVD_DECIMALS = 6; + +export const VEDA_PERFORMANCE_API_BASE_URL = 'https://api.sevenseas.capital'; + +/** + * Minimal ABI for the Veda Accountant's `getRate()` function (selector 0x679aefce). + * Returns the exchange rate between vault shares (musdSHFvd) and the + * underlying asset (mUSD) as a uint256. + */ +// TODO: Verify this ABI is correct. +export const ACCOUNTANT_ABI = [ + { + inputs: [], + name: 'getRate', + outputs: [{ internalType: 'uint256', name: 'rate', type: 'uint256' }], + stateMutability: 'view', + type: 'function', + }, +] as const; diff --git a/packages/money-account-controller/src/money-account-balance-service/money-account-balance-service-method-action-types.ts b/packages/money-account-controller/src/money-account-balance-service/money-account-balance-service-method-action-types.ts new file mode 100644 index 00000000000..f5e74c95a99 --- /dev/null +++ b/packages/money-account-controller/src/money-account-balance-service/money-account-balance-service-method-action-types.ts @@ -0,0 +1,73 @@ +// TODO: This file is supposed to be auto generated. Verify that this generates correctly. The first iteration (seen here) was generated by agent. +/** + * This file is auto generated. + * Do not edit manually. + */ + +import type { MoneyAccountBalanceService } from './money-account-balance-service'; + +/** + * Fetches the mUSD ERC-20 balance for the given account address via RPC. + * + * @param accountAddress - The Money account's address. + * @returns The mUSD balance as a raw uint256 string. + */ +export type MoneyAccountBalanceServiceGetMusdBalanceAction = { + type: `MoneyAccountBalanceService:getMusdBalance`; + handler: MoneyAccountBalanceService['getMusdBalance']; +}; + +/** + * Fetches the musdSHFvd (Veda vault share) ERC-20 balance for the given + * account address via RPC. + * + * @param accountAddress - The Money account's address. + * @returns The musdSHFvd balance as a raw uint256 string. + */ +export type MoneyAccountBalanceServiceGetMusdSHFvdBalanceAction = { + type: `MoneyAccountBalanceService:getMusdSHFvdBalance`; + handler: MoneyAccountBalanceService['getMusdSHFvdBalance']; +}; + +/** + * Fetches the current exchange rate from the Veda Accountant contract via RPC. + * + * @returns The exchange rate as a raw uint256 string. + */ +export type MoneyAccountBalanceServiceGetExchangeRateAction = { + type: `MoneyAccountBalanceService:getExchangeRate`; + handler: MoneyAccountBalanceService['getExchangeRate']; +}; + +/** + * Computes the mUSD-equivalent value of the account's musdSHFvd holdings. + * Internally fetches the musdSHFvd balance and exchange rate (using cached + * values when available), then multiplies them. + * + * @param accountAddress - The Money account's address. + * @returns The musdSHFvd balance, exchange rate, and computed mUSD-equivalent value. + */ +export type MoneyAccountBalanceServiceGetMusdEquivalentValueAction = { + type: `MoneyAccountBalanceService:getMusdEquivalentValue`; + handler: MoneyAccountBalanceService['getMusdEquivalentValue']; +}; + +/** + * Fetches the vault's APY and fee breakdown from the Veda performance REST API. + * + * @returns The 7-day trailing net APY, fees, and per-position breakdown. + */ +export type MoneyAccountBalanceServiceGetVaultApyAction = { + type: `MoneyAccountBalanceService:getVaultApy`; + handler: MoneyAccountBalanceService['getVaultApy']; +}; + +/** + * Union of all MoneyAccountBalanceService action types. + */ +export type MoneyAccountBalanceServiceMethodActions = + | MoneyAccountBalanceServiceGetMusdBalanceAction + | MoneyAccountBalanceServiceGetMusdSHFvdBalanceAction + | MoneyAccountBalanceServiceGetExchangeRateAction + | MoneyAccountBalanceServiceGetMusdEquivalentValueAction + | MoneyAccountBalanceServiceGetVaultApyAction; diff --git a/packages/money-account-controller/src/money-account-balance-service/money-account-balance-service.ts b/packages/money-account-controller/src/money-account-balance-service/money-account-balance-service.ts new file mode 100644 index 00000000000..30c2b5a670f --- /dev/null +++ b/packages/money-account-controller/src/money-account-balance-service/money-account-balance-service.ts @@ -0,0 +1,359 @@ +import { Contract } from '@ethersproject/contracts'; +import { Web3Provider } from '@ethersproject/providers'; +import type { + DataServiceCacheUpdatedEvent, + DataServiceGranularCacheUpdatedEvent, + DataServiceInvalidateQueriesAction, +} from '@metamask/base-data-service'; +import { BaseDataService } from '@metamask/base-data-service'; +import type { CreateServicePolicyOptions } from '@metamask/controller-utils'; +import { HttpError } from '@metamask/controller-utils'; +import type { Messenger } from '@metamask/messenger'; +import { abiERC20 } from '@metamask/metamask-eth-abis'; +import type { + NetworkControllerGetNetworkClientByIdAction, + NetworkControllerGetNetworkConfigurationByChainIdAction, +} from '@metamask/network-controller'; +import type { Hex } from '@metamask/utils'; +import { Duration, inMilliseconds } from '@metamask/utils'; + +import { + ACCOUNTANT_ABI, + ACCOUNTANT_CONTRACT_ADDRESS, + MUSD_CONTRACT_ADDRESS, + MUSD_DECIMALS, + MUSDHFVD_CONTRACT_ADDRESS, + VAULT_CHAIN_ID, + VEDA_NETWORK, + VEDA_PERFORMANCE_API_BASE_URL, +} from './constants'; +import type { MoneyAccountBalanceServiceMethodActions } from './money-account-balance-service-method-action-types'; +import type { + ExchangeRateResponse, + MusdBalanceResponse, + MusdEquivalentValueResponse, + MusdSHFvdBalanceResponse, + VaultApyResponse, +} from './types'; + +// === GENERAL === + +/** + * The name of the {@link MoneyAccountBalanceService}, used to namespace the + * service's actions and events. + */ +export const serviceName = 'MoneyAccountBalanceService'; + +// === MESSENGER === + +const MESSENGER_EXPOSED_METHODS = [ + 'getMusdBalance', + 'getMusdSHFvdBalance', + 'getExchangeRate', + 'getMusdEquivalentValue', + 'getVaultApy', +] as const; + +/** + * Invalidates cached queries for {@link MoneyAccountBalanceService}. + */ +export type MoneyAccountBalanceServiceInvalidateQueriesAction = + DataServiceInvalidateQueriesAction; + +/** + * Actions that {@link MoneyAccountBalanceService} exposes to other consumers. + */ +export type MoneyAccountBalanceServiceActions = + | MoneyAccountBalanceServiceMethodActions + | MoneyAccountBalanceServiceInvalidateQueriesAction; + +/** + * Actions from other messengers that {@link MoneyAccountBalanceService} calls. + */ +type AllowedActions = + | NetworkControllerGetNetworkConfigurationByChainIdAction + | NetworkControllerGetNetworkClientByIdAction; + +/** + * Published when {@link MoneyAccountBalanceService}'s cache is updated. + */ +export type MoneyAccountBalanceServiceCacheUpdatedEvent = + DataServiceCacheUpdatedEvent; + +/** + * Published when a key within {@link MoneyAccountBalanceService}'s cache is + * updated. + */ +export type MoneyAccountBalanceServiceGranularCacheUpdatedEvent = + DataServiceGranularCacheUpdatedEvent; + +/** + * Events that {@link MoneyAccountBalanceService} exposes to other consumers. + */ +export type MoneyAccountBalanceServiceEvents = + | MoneyAccountBalanceServiceCacheUpdatedEvent + | MoneyAccountBalanceServiceGranularCacheUpdatedEvent; + +/** + * Events from other messengers that {@link MoneyAccountBalanceService} + * subscribes to. + */ +type AllowedEvents = never; + +/** + * The messenger which is restricted to actions and events accessed by + * {@link MoneyAccountBalanceService}. + */ +export type MoneyAccountBalanceServiceMessenger = Messenger< + typeof serviceName, + MoneyAccountBalanceServiceActions | AllowedActions, + MoneyAccountBalanceServiceEvents | AllowedEvents +>; + +// === SERVICE DEFINITION === + +/** + * Data service responsible for fetching Money account balances (mUSD and + * musdSHFvd) via on-chain RPC reads, the Veda Accountant exchange rate, and + * the Veda vault APY from the Seven Seas REST API. + * + * All queries are cached via TanStack Query (inherited from + * {@link BaseDataService}) and protected by a service policy that provides + * automatic retries and circuit-breaking. + * + * @example + * + * ```ts + * const service = new MoneyAccountBalanceService({ + * messenger: moneyAccountBalanceServiceMessenger, + * }); + * + * const { balance } = await service.getMusdBalance('0xYourMoneyAccount...'); + * ``` + */ +export class MoneyAccountBalanceService extends BaseDataService< + typeof serviceName, + MoneyAccountBalanceServiceMessenger +> { + /** + * Constructs a new MoneyAccountBalanceService. + * + * @param args - The constructor arguments. + * @param args.messenger - The messenger suited for this service. + * @param args.policyOptions - Options to pass to `createServicePolicy`, + * which is used to wrap each request. + */ + constructor({ + messenger, + policyOptions = {}, + }: { + messenger: MoneyAccountBalanceServiceMessenger; + policyOptions?: CreateServicePolicyOptions; + }) { + super({ + name: serviceName, + messenger, + policyOptions, + }); + + this.messenger.registerMethodActionHandlers( + this, + MESSENGER_EXPOSED_METHODS, + ); + } + + /** + * Resolves a Web3Provider for {@link VAULT_CHAIN_ID} by looking up the + * network configuration and client via the messenger. + * + * @returns A Web3Provider connected to the vault chain. + * @throws If no network configuration exists for the vault chain, or if the + * resolved network client has no provider. + */ + #getProvider(): Web3Provider { + const config = this.messenger.call( + 'NetworkController:getNetworkConfigurationByChainId', + VAULT_CHAIN_ID, + ); + + if (!config) { + throw new Error( + `No network configuration found for chain ${VAULT_CHAIN_ID}`, + ); + } + + const { rpcEndpoints, defaultRpcEndpointIndex } = config; + const { networkClientId } = rpcEndpoints[defaultRpcEndpointIndex]; + + const networkClient = this.messenger.call( + 'NetworkController:getNetworkClientById', + networkClientId, + ); + + if (!networkClient?.provider) { + throw new Error(`No provider found for chain ${VAULT_CHAIN_ID}`); + } + + return new Web3Provider(networkClient.provider); + } + + /** + * Fetches the mUSD ERC-20 balance for the given account address via RPC. + * + * @param accountAddress - The Money account's Ethereum address. + * @returns The mUSD balance as a raw uint256 string. + */ + async getMusdBalance(accountAddress: Hex): Promise { + return this.fetchQuery({ + queryKey: [`${this.name}:getMusdBalance`, accountAddress], + queryFn: async () => { + const provider = this.#getProvider(); + const contract = new Contract( + MUSD_CONTRACT_ADDRESS, + abiERC20, + provider, + ); + const balance = await contract.balanceOf(accountAddress); + return { balance: balance.toString() }; + }, + staleTime: inMilliseconds(30, Duration.Second), + }); + } + + /** + * Fetches the musdSHFvd (Veda vault share) ERC-20 balance for the given + * account address via RPC. + * + * @param accountAddress - The Money account's Ethereum address. + * @returns The musdSHFvd balance as a raw uint256 string. + */ + async getMusdSHFvdBalance( + accountAddress: Hex, + ): Promise { + return this.fetchQuery({ + queryKey: [`${this.name}:getMusdSHFvdBalance`, accountAddress], + queryFn: async () => { + const provider = this.#getProvider(); + const contract = new Contract( + MUSDHFVD_CONTRACT_ADDRESS, + abiERC20, + provider, + ); + const balance = await contract.balanceOf(accountAddress); + return { balance: balance.toString() } as MusdSHFvdBalanceResponse; + }, + staleTime: inMilliseconds(30, Duration.Second), + }); + } + + /** + * Fetches the current exchange rate from the Veda Accountant contract via + * RPC. The rate represents the conversion factor from musdSHFvd shares to + * the underlying mUSD asset. + * + * @returns The exchange rate as a raw uint256 string. + */ + async getExchangeRate(): Promise { + return this.fetchQuery({ + queryKey: [`${this.name}:getExchangeRate`], + queryFn: async () => { + const provider = this.#getProvider(); + const contract = new Contract( + ACCOUNTANT_CONTRACT_ADDRESS, + ACCOUNTANT_ABI, + provider, + ); + const rate = await contract.getRate(); + return { rate: rate.toString() } as ExchangeRateResponse; + }, + staleTime: inMilliseconds(30, Duration.Second), + }); + } + + /** + * Computes the mUSD-equivalent value of the account's musdSHFvd holdings. + * Internally fetches the musdSHFvd balance and exchange rate (using cached + * values when available within their staleTime windows), then multiplies + * them. + * + * The Veda Accountant's `getRate()` returns the exchange rate in + * `MUSD_DECIMALS` (6) precision (e.g., `1000000` = 1.0, `1050000` = 1.05). + * Dividing by `10^MUSD_DECIMALS` removes the rate's scaling, producing + * a result in the same 6-decimal raw units as mUSD. + * + * @param accountAddress - The Money account's Ethereum address. + * @returns The musdSHFvd balance, exchange rate, and computed + * mUSD-equivalent value as raw uint256 strings. + */ + async getMusdEquivalentValue( + accountAddress: Hex, + ): Promise { + const [{ balance: musdSHFvdBalance }, { rate: exchangeRate }] = + await Promise.all([ + this.getMusdSHFvdBalance(accountAddress), + this.getExchangeRate(), + ]); + + const balanceBigInt = BigInt(musdSHFvdBalance); + const rateBigInt = BigInt(exchangeRate); + const musdEquivalentValue = ( + (balanceBigInt * rateBigInt) / + BigInt(10 ** MUSD_DECIMALS) + ).toString(); + + return { + musdSHFvdBalance, + exchangeRate, + musdEquivalentValue, + }; + } + + /** + * Fetches the vault's APY and fee breakdown from the Veda performance REST + * API at `api.sevenseas.capital`. + * + * @returns The 7-day trailing net APY, fees, and per-position breakdown. + */ + async getVaultApy(): Promise { + return this.fetchQuery({ + queryKey: [`${this.name}:getVaultApy`], + queryFn: async () => { + const url = new URL( + `/performance/${VEDA_NETWORK}/${MUSDHFVD_CONTRACT_ADDRESS}`, + VEDA_PERFORMANCE_API_BASE_URL, + ); + + const response = await fetch(url); + + if (!response.ok) { + throw new HttpError( + response.status, + `Veda performance API failed with status '${response.status}'`, + ); + } + + const json = await response.json(); + + return { + apy: json.apy ?? null, + fees: json.global_apy_breakdown?.fees ?? null, + performanceFees: json.performance_fees ?? null, + apyBreakdown: ( + json.global_apy_breakdown?.real_apy_breakdown ?? [] + ).map( + (entry: { + category?: string; + apy?: number; + allocation?: number; + }) => ({ + category: entry.category ?? 'unknown', + apy: entry.apy ?? null, + allocation: entry.allocation ?? null, + }), + ), + } as VaultApyResponse; + }, + staleTime: inMilliseconds(5, Duration.Minute), + }); + } +} diff --git a/packages/money-account-controller/src/money-account-balance-service/types.ts b/packages/money-account-controller/src/money-account-balance-service/types.ts new file mode 100644 index 00000000000..52efbd1ea3a --- /dev/null +++ b/packages/money-account-controller/src/money-account-balance-service/types.ts @@ -0,0 +1,57 @@ +// TODO: Determine if this is necessary. See if other existing data services have similar type definitions. +// TODO: Validate if types are accurate. +/** + * Response from {@link MoneyAccountBalanceService.getMusdBalance}. + * Balance is a raw uint256 string (no decimal normalization). + */ +export type MusdBalanceResponse = { + balance: string; +}; + +/** + * Response from {@link MoneyAccountBalanceService.getMusdSHFvdBalance}. + * Balance is a raw uint256 string (no decimal normalization). + */ +export type MusdSHFvdBalanceResponse = { + balance: string; +}; + +/** + * Response from {@link MoneyAccountBalanceService.getExchangeRate}. + * Rate is the raw uint256 string returned by the Accountant's `getRate()`. + */ +export type ExchangeRateResponse = { + rate: string; +}; + +/** + * Response from {@link MoneyAccountBalanceService.getMusdEquivalentValue}. + * All values are raw uint256 strings. The `musdEquivalentValue` is + * `musdSHFvdBalance * exchangeRate / 1e18`. + */ +export type MusdEquivalentValueResponse = { + musdSHFvdBalance: string; + exchangeRate: string; + musdEquivalentValue: string; +}; + +/** + * Per-position APY entry from the Veda performance API's + * `global_apy_breakdown.real_apy_breakdown` array. + */ +export type VaultApyBreakdownEntry = { + category: string; + apy: number; + allocation: number; +}; + +/** + * Response from {@link MoneyAccountBalanceService.getVaultApy}. + * All APY / fee values are decimals (multiply by 100 for percentage). + */ +export type VaultApyResponse = { + apy: number; + fees: number; + performanceFees: number; + apyBreakdown: VaultApyBreakdownEntry[]; +}; diff --git a/packages/money-account-controller/tsconfig.build.json b/packages/money-account-controller/tsconfig.build.json index 8fa5f6bf61b..be5597139b8 100644 --- a/packages/money-account-controller/tsconfig.build.json +++ b/packages/money-account-controller/tsconfig.build.json @@ -9,13 +9,20 @@ { "path": "../base-controller/tsconfig.build.json" }, + { + "path": "../base-data-service/tsconfig.build.json" + }, { "path": "../accounts-controller/tsconfig.build.json" }, + { + "path": "../controller-utils/tsconfig.build.json" + }, { "path": "../keyring-controller/tsconfig.build.json" }, - { "path": "../messenger/tsconfig.build.json" } + { "path": "../messenger/tsconfig.build.json" }, + { "path": "../network-controller/tsconfig.build.json" } ], "include": ["../../types", "./src"] } diff --git a/packages/money-account-controller/tsconfig.json b/packages/money-account-controller/tsconfig.json index e1b9b25e4a4..4d2655c31a4 100644 --- a/packages/money-account-controller/tsconfig.json +++ b/packages/money-account-controller/tsconfig.json @@ -5,9 +5,12 @@ }, "references": [ { "path": "../base-controller" }, + { "path": "../base-data-service" }, { "path": "../accounts-controller" }, + { "path": "../controller-utils" }, { "path": "../keyring-controller" }, - { "path": "../messenger" } + { "path": "../messenger" }, + { "path": "../network-controller" } ], "include": ["../../types", "./src"] } From 7727e889532036378e46b26f2f1d4e6c89f70c50 Mon Sep 17 00:00:00 2001 From: Matthew Grainger Date: Fri, 10 Apr 2026 16:41:19 -0400 Subject: [PATCH 2/5] feat: money-account-balance-service cleanup --- .../money-account-controller/package.json | 1 + .../money-account-controller/src/index.ts | 6 +- .../constants.ts | 39 +--- .../money-account-balance-service/errors.ts | 6 + .../money-account-balance-service.ts | 171 +++++++++++------- .../requestNormalization.ts | 38 ++++ .../{types.ts => response.types.ts} | 44 ++--- .../money-account-balance-service/structs.ts | 32 ++++ 8 files changed, 212 insertions(+), 125 deletions(-) create mode 100644 packages/money-account-controller/src/money-account-balance-service/errors.ts create mode 100644 packages/money-account-controller/src/money-account-balance-service/requestNormalization.ts rename packages/money-account-controller/src/money-account-balance-service/{types.ts => response.types.ts} (51%) create mode 100644 packages/money-account-controller/src/money-account-balance-service/structs.ts diff --git a/packages/money-account-controller/package.json b/packages/money-account-controller/package.json index 9559c468650..4bfd0c174e8 100644 --- a/packages/money-account-controller/package.json +++ b/packages/money-account-controller/package.json @@ -61,6 +61,7 @@ "@metamask/messenger": "^1.1.1", "@metamask/metamask-eth-abis": "^3.1.1", "@metamask/network-controller": "^30.0.1", + "@metamask/superstruct": "^3.1.0", "@metamask/utils": "^11.9.0", "async-mutex": "^0.5.0" }, diff --git a/packages/money-account-controller/src/index.ts b/packages/money-account-controller/src/index.ts index 8087e26fb83..87a38648b99 100644 --- a/packages/money-account-controller/src/index.ts +++ b/packages/money-account-controller/src/index.ts @@ -35,10 +35,8 @@ export type { MoneyAccountBalanceServiceGetVaultApyAction, } from './money-account-balance-service/money-account-balance-service-method-action-types'; export type { - MusdBalanceResponse, - MusdSHFvdBalanceResponse, + Erc20BalanceResponse, ExchangeRateResponse, MusdEquivalentValueResponse, VaultApyResponse, - VaultApyBreakdownEntry, -} from './money-account-balance-service/types'; +} from './money-account-balance-service/response.types'; diff --git a/packages/money-account-controller/src/money-account-balance-service/constants.ts b/packages/money-account-controller/src/money-account-balance-service/constants.ts index 3bba7f86b02..2ea80a14959 100644 --- a/packages/money-account-controller/src/money-account-balance-service/constants.ts +++ b/packages/money-account-controller/src/money-account-balance-service/constants.ts @@ -1,45 +1,18 @@ -import type { Hex } from '@metamask/utils'; +import { Hex } from '@metamask/utils'; -// TODO: Replace placeholder addresses with actual deployed contract addresses. -// TODO: Rename constants to be more generic. -/** - * Arbitrum USDC (test Vault): 0xaf88d065e77c8cc2239327c5edb3a432268e5831 - */ -export const MUSD_CONTRACT_ADDRESS: Hex = - '0xaf88d065e77c8cc2239327c5edb3a432268e5831'; - -/** - * Arbitrum USDC (test Vault): 0xB5F07d769dD60fE54c97dd53101181073DDf21b2 - */ -// TODO: Rename to Veda Vault address -export const MUSDHFVD_CONTRACT_ADDRESS: Hex = - '0xB5F07d769dD60fE54c97dd53101181073DDf21b2'; - -// TODO: Rename to Veda Accountant address -/** - * Arbitrum Accountant (test Vault): 0x800ebc3B74F67EaC27C9CCE4E4FF28b17CdCA173 - */ -export const ACCOUNTANT_CONTRACT_ADDRESS: Hex = - '0x800ebc3B74F67EaC27C9CCE4E4FF28b17CdCA173'; - -// TODO: Use CHAIN_IDS.ARBITRUM instead. -export const VAULT_CHAIN_ID: Hex = '0xa4b1'; // Arbitrum One - -// TODO: Replace with the canonical Veda network identifier for the deployment. -export const VEDA_NETWORK = 'arbitrum'; - -export const MUSD_DECIMALS = 6; +export const VEDA_PERFORMANCE_API_BASE_URL = 'https://api.sevenseas.capital'; -export const MUSDHFVD_DECIMALS = 6; +export const VEDA_API_NETWORK_NAMES: Record = { + '0xa4b1': 'arbitrum', +}; -export const VEDA_PERFORMANCE_API_BASE_URL = 'https://api.sevenseas.capital'; +export const DEFAULT_VEDA_API_NETWORK_NAME = VEDA_API_NETWORK_NAMES['0xa4b1']; /** * Minimal ABI for the Veda Accountant's `getRate()` function (selector 0x679aefce). * Returns the exchange rate between vault shares (musdSHFvd) and the * underlying asset (mUSD) as a uint256. */ -// TODO: Verify this ABI is correct. export const ACCOUNTANT_ABI = [ { inputs: [], diff --git a/packages/money-account-controller/src/money-account-balance-service/errors.ts b/packages/money-account-controller/src/money-account-balance-service/errors.ts new file mode 100644 index 00000000000..d0de4dc0b28 --- /dev/null +++ b/packages/money-account-controller/src/money-account-balance-service/errors.ts @@ -0,0 +1,6 @@ +export class VedaResponseValidationError extends Error { + constructor(message?: string) { + super(message ?? 'Malformed response received from Veda API'); + this.name = 'VedaResponseValidationError'; + } +} diff --git a/packages/money-account-controller/src/money-account-balance-service/money-account-balance-service.ts b/packages/money-account-controller/src/money-account-balance-service/money-account-balance-service.ts index 30c2b5a670f..de7b73ae8d7 100644 --- a/packages/money-account-controller/src/money-account-balance-service/money-account-balance-service.ts +++ b/packages/money-account-controller/src/money-account-balance-service/money-account-balance-service.ts @@ -7,34 +7,33 @@ import type { } from '@metamask/base-data-service'; import { BaseDataService } from '@metamask/base-data-service'; import type { CreateServicePolicyOptions } from '@metamask/controller-utils'; -import { HttpError } from '@metamask/controller-utils'; +import { handleWhen, HttpError } from '@metamask/controller-utils'; import type { Messenger } from '@metamask/messenger'; import { abiERC20 } from '@metamask/metamask-eth-abis'; import type { NetworkControllerGetNetworkClientByIdAction, NetworkControllerGetNetworkConfigurationByChainIdAction, } from '@metamask/network-controller'; +import { is } from '@metamask/superstruct'; import type { Hex } from '@metamask/utils'; import { Duration, inMilliseconds } from '@metamask/utils'; import { ACCOUNTANT_ABI, - ACCOUNTANT_CONTRACT_ADDRESS, - MUSD_CONTRACT_ADDRESS, - MUSD_DECIMALS, - MUSDHFVD_CONTRACT_ADDRESS, - VAULT_CHAIN_ID, - VEDA_NETWORK, + DEFAULT_VEDA_API_NETWORK_NAME, + VEDA_API_NETWORK_NAMES, VEDA_PERFORMANCE_API_BASE_URL, } from './constants'; +import { VedaResponseValidationError } from './errors'; import type { MoneyAccountBalanceServiceMethodActions } from './money-account-balance-service-method-action-types'; +import { normalizeVaultApyResponse } from './requestNormalization'; import type { + Erc20BalanceResponse, ExchangeRateResponse, - MusdBalanceResponse, MusdEquivalentValueResponse, - MusdSHFvdBalanceResponse, VaultApyResponse, -} from './types'; +} from './response.types'; +import { VaultApyResponseStruct } from './structs'; // === GENERAL === @@ -110,6 +109,24 @@ export type MoneyAccountBalanceServiceMessenger = Messenger< MoneyAccountBalanceServiceEvents | AllowedEvents >; +export type MoneyAccountBalanceServiceConfig = { + /** The address of the Veda vault (musdSHFvd token contract). */ + vaultAddress: Hex; + /** The chain ID of the Veda vault. */ + vaultChainId: Hex; + /** The address of the Veda Accountant contract. */ + accountantAddress: Hex; + /** The address of the underlying token (mUSD). Must be on the same chain as the vault. */ + underlyingTokenAddress: Hex; + /** + * The decimals of the underlying token. Also determines the precision of + * the Accountant's `getRate()` return value. + */ + underlyingTokenDecimals: number; + /** Base URL for the Veda Seven Seas performance API. Defaults to https://api.sevenseas.capital. */ + vedaApiBaseUrl?: string; +}; + // === SERVICE DEFINITION === /** @@ -126,6 +143,13 @@ export type MoneyAccountBalanceServiceMessenger = Messenger< * ```ts * const service = new MoneyAccountBalanceService({ * messenger: moneyAccountBalanceServiceMessenger, + * config: { + * vaultAddress: '0x...', + * vaultChainId: '0xa4b1', + * accountantAddress: '0x...', + * underlyingTokenAddress: '0x...', + * underlyingTokenDecimals: 6, + * }, * }); * * const { balance } = await service.getMusdBalance('0xYourMoneyAccount...'); @@ -135,6 +159,10 @@ export class MoneyAccountBalanceService extends BaseDataService< typeof serviceName, MoneyAccountBalanceServiceMessenger > { + readonly #config: MoneyAccountBalanceServiceConfig; + + readonly #networkName: string; + /** * Constructs a new MoneyAccountBalanceService. * @@ -142,20 +170,37 @@ export class MoneyAccountBalanceService extends BaseDataService< * @param args.messenger - The messenger suited for this service. * @param args.policyOptions - Options to pass to `createServicePolicy`, * which is used to wrap each request. + * @param args.config - The configuration for the service. */ constructor({ messenger, policyOptions = {}, + config, }: { messenger: MoneyAccountBalanceServiceMessenger; + config: MoneyAccountBalanceServiceConfig; policyOptions?: CreateServicePolicyOptions; }) { super({ name: serviceName, messenger, - policyOptions, + policyOptions: { + ...policyOptions, + retryFilterPolicy: handleWhen( + (error) => !(error instanceof VedaResponseValidationError), + ), + }, }); + this.#config = { + ...config, + vedaApiBaseUrl: config.vedaApiBaseUrl ?? VEDA_PERFORMANCE_API_BASE_URL, + }; + + this.#networkName = + VEDA_API_NETWORK_NAMES[this.#config.vaultChainId] ?? + DEFAULT_VEDA_API_NETWORK_NAME; + this.messenger.registerMethodActionHandlers( this, MESSENGER_EXPOSED_METHODS, @@ -163,7 +208,7 @@ export class MoneyAccountBalanceService extends BaseDataService< } /** - * Resolves a Web3Provider for {@link VAULT_CHAIN_ID} by looking up the + * Resolves a Web3Provider for {@link MoneyAccountBalanceServiceConfig.vaultChainId} by looking up the * network configuration and client via the messenger. * * @returns A Web3Provider connected to the vault chain. @@ -173,12 +218,12 @@ export class MoneyAccountBalanceService extends BaseDataService< #getProvider(): Web3Provider { const config = this.messenger.call( 'NetworkController:getNetworkConfigurationByChainId', - VAULT_CHAIN_ID, + this.#config.vaultChainId, ); if (!config) { throw new Error( - `No network configuration found for chain ${VAULT_CHAIN_ID}`, + `No network configuration found for chain ${this.#config.vaultChainId}`, ); } @@ -191,30 +236,46 @@ export class MoneyAccountBalanceService extends BaseDataService< ); if (!networkClient?.provider) { - throw new Error(`No provider found for chain ${VAULT_CHAIN_ID}`); + throw new Error( + `No provider found for chain ${this.#config.vaultChainId}`, + ); } return new Web3Provider(networkClient.provider); } + /** + * Fetches the ERC-20 balance for the given contract address and account address via RPC. + * + * @param contractAddress - The address of the ERC-20 contract. + * @param accountAddress - The address of the account. + * @returns The balance as a raw uint256 string. + */ + async #fetchErc20Balance( + contractAddress: Hex, + accountAddress: Hex, + ): Promise { + const provider = this.#getProvider(); + const contract = new Contract(contractAddress, abiERC20, provider); + const balance = await contract.balanceOf(accountAddress); + return balance.toString(); + } + /** * Fetches the mUSD ERC-20 balance for the given account address via RPC. * * @param accountAddress - The Money account's Ethereum address. * @returns The mUSD balance as a raw uint256 string. */ - async getMusdBalance(accountAddress: Hex): Promise { + async getMusdBalance(accountAddress: Hex): Promise { return this.fetchQuery({ queryKey: [`${this.name}:getMusdBalance`, accountAddress], queryFn: async () => { - const provider = this.#getProvider(); - const contract = new Contract( - MUSD_CONTRACT_ADDRESS, - abiERC20, - provider, + const balance = await this.#fetchErc20Balance( + this.#config.underlyingTokenAddress, + accountAddress, ); - const balance = await contract.balanceOf(accountAddress); - return { balance: balance.toString() }; + return { balance }; }, staleTime: inMilliseconds(30, Duration.Second), }); @@ -229,18 +290,15 @@ export class MoneyAccountBalanceService extends BaseDataService< */ async getMusdSHFvdBalance( accountAddress: Hex, - ): Promise { + ): Promise { return this.fetchQuery({ queryKey: [`${this.name}:getMusdSHFvdBalance`, accountAddress], queryFn: async () => { - const provider = this.#getProvider(); - const contract = new Contract( - MUSDHFVD_CONTRACT_ADDRESS, - abiERC20, - provider, + const balance = await this.#fetchErc20Balance( + this.#config.vaultAddress, + accountAddress, ); - const balance = await contract.balanceOf(accountAddress); - return { balance: balance.toString() } as MusdSHFvdBalanceResponse; + return { balance }; }, staleTime: inMilliseconds(30, Duration.Second), }); @@ -259,12 +317,12 @@ export class MoneyAccountBalanceService extends BaseDataService< queryFn: async () => { const provider = this.#getProvider(); const contract = new Contract( - ACCOUNTANT_CONTRACT_ADDRESS, + this.#config.accountantAddress, ACCOUNTANT_ABI, provider, ); const rate = await contract.getRate(); - return { rate: rate.toString() } as ExchangeRateResponse; + return { rate: rate.toString() }; }, staleTime: inMilliseconds(30, Duration.Second), }); @@ -276,11 +334,6 @@ export class MoneyAccountBalanceService extends BaseDataService< * values when available within their staleTime windows), then multiplies * them. * - * The Veda Accountant's `getRate()` returns the exchange rate in - * `MUSD_DECIMALS` (6) precision (e.g., `1000000` = 1.0, `1050000` = 1.05). - * Dividing by `10^MUSD_DECIMALS` removes the rate's scaling, producing - * a result in the same 6-decimal raw units as mUSD. - * * @param accountAddress - The Money account's Ethereum address. * @returns The musdSHFvd balance, exchange rate, and computed * mUSD-equivalent value as raw uint256 strings. @@ -296,9 +349,10 @@ export class MoneyAccountBalanceService extends BaseDataService< const balanceBigInt = BigInt(musdSHFvdBalance); const rateBigInt = BigInt(exchangeRate); + const musdEquivalentValue = ( (balanceBigInt * rateBigInt) / - BigInt(10 ** MUSD_DECIMALS) + 10n ** BigInt(this.#config.underlyingTokenDecimals) ).toString(); return { @@ -309,18 +363,17 @@ export class MoneyAccountBalanceService extends BaseDataService< } /** - * Fetches the vault's APY and fee breakdown from the Veda performance REST - * API at `api.sevenseas.capital`. + * Fetches the vault's APY and fee breakdown from the Veda performance REST API. * - * @returns The 7-day trailing net APY, fees, and per-position breakdown. + * @returns The normalized vault APY response. */ async getVaultApy(): Promise { return this.fetchQuery({ queryKey: [`${this.name}:getVaultApy`], queryFn: async () => { const url = new URL( - `/performance/${VEDA_NETWORK}/${MUSDHFVD_CONTRACT_ADDRESS}`, - VEDA_PERFORMANCE_API_BASE_URL, + `/performance/${this.#networkName}/${this.#config.vaultAddress}`, + this.#config.vedaApiBaseUrl, ); const response = await fetch(url); @@ -332,26 +385,16 @@ export class MoneyAccountBalanceService extends BaseDataService< ); } - const json = await response.json(); - - return { - apy: json.apy ?? null, - fees: json.global_apy_breakdown?.fees ?? null, - performanceFees: json.performance_fees ?? null, - apyBreakdown: ( - json.global_apy_breakdown?.real_apy_breakdown ?? [] - ).map( - (entry: { - category?: string; - apy?: number; - allocation?: number; - }) => ({ - category: entry.category ?? 'unknown', - apy: entry.apy ?? null, - allocation: entry.allocation ?? null, - }), - ), - } as VaultApyResponse; + const rawResponse = await response.json(); + + // Validate raw response inside queryFn to avoid poisoned cache. + if (!is(rawResponse, VaultApyResponseStruct)) { + throw new VedaResponseValidationError( + 'Malformed response received from Veda performance API', + ); + } + + return normalizeVaultApyResponse(rawResponse); }, staleTime: inMilliseconds(5, Duration.Minute), }); diff --git a/packages/money-account-controller/src/money-account-balance-service/requestNormalization.ts b/packages/money-account-controller/src/money-account-balance-service/requestNormalization.ts new file mode 100644 index 00000000000..c66fb09ec1d --- /dev/null +++ b/packages/money-account-controller/src/money-account-balance-service/requestNormalization.ts @@ -0,0 +1,38 @@ +import { Infer } from '@metamask/superstruct'; + +import { VaultApyResponseStruct } from './structs'; +import { VaultApyResponse } from './response.types'; + +/** + * Normalizes the raw response from the Veda performance API into the expected + * format. + * + * @param rawResponse - The raw response from the Veda performance API. + * @returns The normalized response. + */ +export function normalizeVaultApyResponse( + rawResponse: Infer, +): VaultApyResponse { + const { Response: response } = rawResponse; + + return { + aggregationPeriod: response.aggregation_period, + apy: response.apy, + chainAllocation: response.chain_allocation, + fees: response.fees, + globalApyBreakdown: { + fee: response.global_apy_breakdown.fee, + maturityApy: response.global_apy_breakdown.maturity_apy, + realApy: response.global_apy_breakdown.real_apy, + }, + performanceFees: response.performance_fees, + realApyBreakdown: response.real_apy_breakdown.map((item) => ({ + allocation: item.allocation, + apy: item.apy, + apyNet: item.apy_net, + chain: item.chain, + protocol: item.protocol, + })), + timestamp: response.timestamp, + }; +} diff --git a/packages/money-account-controller/src/money-account-balance-service/types.ts b/packages/money-account-controller/src/money-account-balance-service/response.types.ts similarity index 51% rename from packages/money-account-controller/src/money-account-balance-service/types.ts rename to packages/money-account-controller/src/money-account-balance-service/response.types.ts index 52efbd1ea3a..e9ec1cbb9af 100644 --- a/packages/money-account-controller/src/money-account-balance-service/types.ts +++ b/packages/money-account-controller/src/money-account-balance-service/response.types.ts @@ -1,18 +1,8 @@ -// TODO: Determine if this is necessary. See if other existing data services have similar type definitions. -// TODO: Validate if types are accurate. /** - * Response from {@link MoneyAccountBalanceService.getMusdBalance}. + * Response from {@link MoneyAccountBalanceService.#fetchErc20Balance}. * Balance is a raw uint256 string (no decimal normalization). */ -export type MusdBalanceResponse = { - balance: string; -}; - -/** - * Response from {@link MoneyAccountBalanceService.getMusdSHFvdBalance}. - * Balance is a raw uint256 string (no decimal normalization). - */ -export type MusdSHFvdBalanceResponse = { +export type Erc20BalanceResponse = { balance: string; }; @@ -27,7 +17,7 @@ export type ExchangeRateResponse = { /** * Response from {@link MoneyAccountBalanceService.getMusdEquivalentValue}. * All values are raw uint256 strings. The `musdEquivalentValue` is - * `musdSHFvdBalance * exchangeRate / 1e18`. + * `musdSHFvdBalance * exchangeRate / 10^underlyingTokenDecimals` (= 1e6 for mUSD). */ export type MusdEquivalentValueResponse = { musdSHFvdBalance: string; @@ -35,23 +25,29 @@ export type MusdEquivalentValueResponse = { musdEquivalentValue: string; }; -/** - * Per-position APY entry from the Veda performance API's - * `global_apy_breakdown.real_apy_breakdown` array. - */ -export type VaultApyBreakdownEntry = { - category: string; - apy: number; - allocation: number; -}; - /** * Response from {@link MoneyAccountBalanceService.getVaultApy}. * All APY / fee values are decimals (multiply by 100 for percentage). */ export type VaultApyResponse = { + aggregationPeriod: string; // E.g. "7 days" apy: number; + chainAllocation: { + [network: string]: number; + }; fees: number; + globalApyBreakdown: { + fee: number; + maturityApy: number; + realApy: number; + }; performanceFees: number; - apyBreakdown: VaultApyBreakdownEntry[]; + realApyBreakdown: { + allocation: number; + apy: number; + apyNet: number; + chain: string; + protocol: string; + }[]; + timestamp: string; }; diff --git a/packages/money-account-controller/src/money-account-balance-service/structs.ts b/packages/money-account-controller/src/money-account-balance-service/structs.ts new file mode 100644 index 00000000000..34739d3454b --- /dev/null +++ b/packages/money-account-controller/src/money-account-balance-service/structs.ts @@ -0,0 +1,32 @@ +import { array, number, record, string, type } from '@metamask/superstruct'; + +/** + * Superstruct schema for {@link VaultApyResponse}. + * + * Uses `type()` (loose validation) so that unknown fields returned by the + * Veda API do not cause validation failures. + */ +export const VaultApyResponseStruct = type({ + Response: type({ + aggregation_period: string(), + apy: number(), + chain_allocation: record(string(), number()), + fees: number(), + global_apy_breakdown: type({ + fee: number(), + maturity_apy: number(), + real_apy: number(), + }), + performance_fees: number(), + real_apy_breakdown: array( + type({ + allocation: number(), + apy: number(), + apy_net: number(), + chain: string(), + protocol: string(), + }), + ), + timestamp: string(), + }), +}); From aa31fa36be22349e9c57518a2c8c0bcc597bac82 Mon Sep 17 00:00:00 2001 From: Matthew Grainger Date: Fri, 10 Apr 2026 16:46:35 -0400 Subject: [PATCH 3/5] feat: update yarn.lock --- yarn.lock | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/yarn.lock b/yarn.lock index 5d0a8876af7..84082d9db4f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4406,14 +4406,21 @@ __metadata: version: 0.0.0-use.local resolution: "@metamask/money-account-controller@workspace:packages/money-account-controller" dependencies: + "@ethersproject/contracts": "npm:^5.7.0" + "@ethersproject/providers": "npm:^5.7.0" "@metamask/accounts-controller": "npm:^37.2.0" "@metamask/auto-changelog": "npm:^3.4.4" "@metamask/base-controller": "npm:^9.0.1" + "@metamask/base-data-service": "npm:^0.1.1" + "@metamask/controller-utils": "npm:^11.20.0" "@metamask/eth-money-keyring": "npm:^2.0.0" "@metamask/keyring-api": "npm:^21.6.0" "@metamask/keyring-controller": "npm:^25.2.0" "@metamask/keyring-utils": "npm:^3.1.0" "@metamask/messenger": "npm:^1.1.1" + "@metamask/metamask-eth-abis": "npm:^3.1.1" + "@metamask/network-controller": "npm:^30.0.1" + "@metamask/superstruct": "npm:^3.1.0" "@metamask/utils": "npm:^11.9.0" "@ts-bridge/cli": "npm:^0.6.4" "@types/jest": "npm:^29.5.14" From 24d7d65e9c232f3e853cd6a2e1ee6b1b3e6985f6 Mon Sep 17 00:00:00 2001 From: Matthew Grainger Date: Fri, 10 Apr 2026 18:17:29 -0400 Subject: [PATCH 4/5] feat: made all VaultApyResponse fields optional except for apy and timestamp. Vaults aren't guaranteed to have the other properties when there's no activity --- .../requestNormalization.ts | 16 ++++--- .../response.types.ts | 31 +++++++------ .../money-account-balance-service/structs.ts | 46 ++++++++++++------- yarn.lock | 1 + 4 files changed, 57 insertions(+), 37 deletions(-) diff --git a/packages/money-account-controller/src/money-account-balance-service/requestNormalization.ts b/packages/money-account-controller/src/money-account-balance-service/requestNormalization.ts index c66fb09ec1d..ee3df074012 100644 --- a/packages/money-account-controller/src/money-account-balance-service/requestNormalization.ts +++ b/packages/money-account-controller/src/money-account-balance-service/requestNormalization.ts @@ -1,7 +1,7 @@ import { Infer } from '@metamask/superstruct'; -import { VaultApyResponseStruct } from './structs'; import { VaultApyResponse } from './response.types'; +import { VaultApyResponseStruct } from './structs'; /** * Normalizes the raw response from the Veda performance API into the expected @@ -20,13 +20,15 @@ export function normalizeVaultApyResponse( apy: response.apy, chainAllocation: response.chain_allocation, fees: response.fees, - globalApyBreakdown: { - fee: response.global_apy_breakdown.fee, - maturityApy: response.global_apy_breakdown.maturity_apy, - realApy: response.global_apy_breakdown.real_apy, - }, + globalApyBreakdown: response.global_apy_breakdown + ? { + fee: response.global_apy_breakdown.fee, + maturityApy: response.global_apy_breakdown.maturity_apy, + realApy: response.global_apy_breakdown.real_apy, + } + : undefined, performanceFees: response.performance_fees, - realApyBreakdown: response.real_apy_breakdown.map((item) => ({ + realApyBreakdown: response.real_apy_breakdown?.map((item) => ({ allocation: item.allocation, apy: item.apy, apyNet: item.apy_net, diff --git a/packages/money-account-controller/src/money-account-balance-service/response.types.ts b/packages/money-account-controller/src/money-account-balance-service/response.types.ts index e9ec1cbb9af..7f119010b35 100644 --- a/packages/money-account-controller/src/money-account-balance-service/response.types.ts +++ b/packages/money-account-controller/src/money-account-balance-service/response.types.ts @@ -28,26 +28,29 @@ export type MusdEquivalentValueResponse = { /** * Response from {@link MoneyAccountBalanceService.getVaultApy}. * All APY / fee values are decimals (multiply by 100 for percentage). + * + * Only `apy` and `timestamp` are guaranteed to be present — all other fields + * are optional because the Veda API omits them when the vault has no activity. */ export type VaultApyResponse = { - aggregationPeriod: string; // E.g. "7 days" + aggregationPeriod?: string; // E.g. "7 days" apy: number; - chainAllocation: { + chainAllocation?: { [network: string]: number; }; - fees: number; - globalApyBreakdown: { - fee: number; - maturityApy: number; - realApy: number; + fees?: number; + globalApyBreakdown?: { + fee?: number; + maturityApy?: number; + realApy?: number; }; - performanceFees: number; - realApyBreakdown: { - allocation: number; - apy: number; - apyNet: number; - chain: string; - protocol: string; + performanceFees?: number; + realApyBreakdown?: { + allocation?: number; + apy?: number; + apyNet?: number; + chain?: string; + protocol?: string; }[]; timestamp: string; }; diff --git a/packages/money-account-controller/src/money-account-balance-service/structs.ts b/packages/money-account-controller/src/money-account-balance-service/structs.ts index 34739d3454b..a8bd4f0f082 100644 --- a/packages/money-account-controller/src/money-account-balance-service/structs.ts +++ b/packages/money-account-controller/src/money-account-balance-service/structs.ts @@ -1,32 +1,46 @@ -import { array, number, record, string, type } from '@metamask/superstruct'; +import { + array, + number, + optional, + record, + string, + type, +} from '@metamask/superstruct'; /** * Superstruct schema for {@link VaultApyResponse}. * * Uses `type()` (loose validation) so that unknown fields returned by the * Veda API do not cause validation failures. + * + * Only `apy` and `timestamp` are required — all other fields are optional + * because the Veda API omits some fields when the vault has no activity. */ export const VaultApyResponseStruct = type({ Response: type({ - aggregation_period: string(), + aggregation_period: optional(string()), apy: number(), - chain_allocation: record(string(), number()), - fees: number(), - global_apy_breakdown: type({ - fee: number(), - maturity_apy: number(), - real_apy: number(), - }), - performance_fees: number(), - real_apy_breakdown: array( + chain_allocation: optional(record(string(), number())), + fees: optional(number()), + global_apy_breakdown: optional( type({ - allocation: number(), - apy: number(), - apy_net: number(), - chain: string(), - protocol: string(), + fee: optional(number()), + maturity_apy: optional(number()), + real_apy: optional(number()), }), ), + performance_fees: optional(number()), + real_apy_breakdown: optional( + array( + type({ + allocation: optional(number()), + apy: optional(number()), + apy_net: optional(number()), + chain: optional(string()), + protocol: optional(string()), + }), + ), + ), timestamp: string(), }), }); diff --git a/yarn.lock b/yarn.lock index 84082d9db4f..5cadadca844 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4427,6 +4427,7 @@ __metadata: async-mutex: "npm:^0.5.0" deepmerge: "npm:^4.2.2" jest: "npm:^29.7.0" + nock: "npm:^13.3.1" ts-jest: "npm:^29.2.5" tsx: "npm:^4.20.5" typedoc: "npm:^0.25.13" From 6e596eb2f24bee51f676e949e7c836fb07e72035 Mon Sep 17 00:00:00 2001 From: Matthew Grainger Date: Fri, 10 Apr 2026 18:25:29 -0400 Subject: [PATCH 5/5] feat: add tests --- .../money-account-controller/package.json | 1 + .../money-account-balance-service.test.ts | 693 ++++++++++++++++++ 2 files changed, 694 insertions(+) create mode 100644 packages/money-account-controller/src/money-account-balance-service/money-account-balance-service.test.ts diff --git a/packages/money-account-controller/package.json b/packages/money-account-controller/package.json index 4bfd0c174e8..dfa1b824034 100644 --- a/packages/money-account-controller/package.json +++ b/packages/money-account-controller/package.json @@ -72,6 +72,7 @@ "@types/jest": "^29.5.14", "deepmerge": "^4.2.2", "jest": "^29.7.0", + "nock": "^13.3.1", "ts-jest": "^29.2.5", "tsx": "^4.20.5", "typedoc": "^0.25.13", diff --git a/packages/money-account-controller/src/money-account-balance-service/money-account-balance-service.test.ts b/packages/money-account-controller/src/money-account-balance-service/money-account-balance-service.test.ts new file mode 100644 index 00000000000..748c331a5bf --- /dev/null +++ b/packages/money-account-controller/src/money-account-balance-service/money-account-balance-service.test.ts @@ -0,0 +1,693 @@ +import { Contract } from '@ethersproject/contracts'; +import { Web3Provider } from '@ethersproject/providers'; +import { DEFAULT_MAX_RETRIES, HttpError } from '@metamask/controller-utils'; +import { Messenger, MOCK_ANY_NAMESPACE } from '@metamask/messenger'; +import type { + MockAnyNamespace, + MessengerActions, + MessengerEvents, +} from '@metamask/messenger'; +import nock, { cleanAll as nockCleanAll } from 'nock'; + +import { VedaResponseValidationError } from './errors'; +import type { + MoneyAccountBalanceServiceConfig, + MoneyAccountBalanceServiceMessenger, +} from './money-account-balance-service'; +import { + MoneyAccountBalanceService, + serviceName, +} from './money-account-balance-service'; + +jest.mock('@ethersproject/contracts'); +jest.mock('@ethersproject/providers'); + +const MockContract = Contract as jest.MockedClass; +const MockWeb3Provider = Web3Provider as jest.MockedClass; + +// ============================================================ +// Fixtures +// ============================================================ + +const MOCK_VAULT_ADDRESS = + '0xVaultAddress000000000000000000000000000000' as const; +const MOCK_ACCOUNTANT_ADDRESS = + '0xAccountantAddr000000000000000000000000000' as const; +const MOCK_UNDERLYING_TOKEN_ADDRESS = + '0xMusdAddress0000000000000000000000000000000' as const; +const MOCK_ACCOUNT_ADDRESS = + '0xUserAccount0000000000000000000000000000000' as const; +const MOCK_NETWORK_CLIENT_ID = 'arbitrum-mainnet'; + +const DEFAULT_CONFIG = { + vaultAddress: MOCK_VAULT_ADDRESS, + vaultChainId: '0xa4b1' as const, + accountantAddress: MOCK_ACCOUNTANT_ADDRESS, + underlyingTokenAddress: MOCK_UNDERLYING_TOKEN_ADDRESS, + underlyingTokenDecimals: 6, +}; + +const MOCK_NETWORK_CONFIG = { + chainId: '0xa4b1' as const, + rpcEndpoints: [ + { + networkClientId: MOCK_NETWORK_CLIENT_ID, + url: 'https://arb1.arbitrum.io/rpc', + }, + ], + defaultRpcEndpointIndex: 0, + name: 'Arbitrum One', + nativeCurrency: 'ETH', + blockExplorerUrls: [], +}; + +// A bare object suffices — Web3Provider and Contract are mocked at the module level. +// eslint-disable-next-line @typescript-eslint/no-explicit-any +const MOCK_PROVIDER = {} as any; + +const MOCK_VAULT_APY_RAW_RESPONSE = { + Response: { + aggregation_period: '7 days', + apy: 0.055, + chain_allocation: { arbitrum: 1.0 }, + fees: 0.005, + global_apy_breakdown: { + fee: 0.005, + maturity_apy: 0.03, + real_apy: 0.05, + }, + performance_fees: 0.001, + real_apy_breakdown: [ + { + allocation: 1.0, + apy: 0.055, + apy_net: 0.05, + chain: 'arbitrum', + protocol: 'aave', + }, + ], + timestamp: '2024-01-01T00:00:00Z', + }, +}; + +const MOCK_VAULT_APY_NORMALIZED = { + aggregationPeriod: '7 days', + apy: 0.055, + chainAllocation: { arbitrum: 1.0 }, + fees: 0.005, + globalApyBreakdown: { + fee: 0.005, + maturityApy: 0.03, + realApy: 0.05, + }, + performanceFees: 0.001, + realApyBreakdown: [ + { + allocation: 1.0, + apy: 0.055, + apyNet: 0.05, + chain: 'arbitrum', + protocol: 'aave', + }, + ], + timestamp: '2024-01-01T00:00:00Z', +}; + +// ============================================================ +// Messenger helpers +// ============================================================ + +type RootMessenger = Messenger< + MockAnyNamespace, + MessengerActions, + MessengerEvents +>; + +function createRootMessenger(): RootMessenger { + return new Messenger({ namespace: MOCK_ANY_NAMESPACE }); +} + +function createServiceMessenger( + rootMessenger: RootMessenger, +): MoneyAccountBalanceServiceMessenger { + return new Messenger({ + namespace: serviceName, + parent: rootMessenger, + }); +} + +// ============================================================ +// Factory +// ============================================================ + +/** + * Builds the service under test with messenger action stubs for the two + * NetworkController dependencies. + * + * @param args - Optional overrides for the service config and constructor options. + * @param args.config - Partial config merged over {@link DEFAULT_CONFIG}. + * @param args.options - Partial constructor options passed to the service. + * @returns The constructed service together with messenger instances and mock stubs. + */ +function createService({ + config = {}, + options = {}, +}: { + config?: Partial; + options?: Partial< + ConstructorParameters[0] + >; +} = {}): { + service: MoneyAccountBalanceService; + rootMessenger: RootMessenger; + messenger: MoneyAccountBalanceServiceMessenger; + mockGetNetworkConfig: jest.Mock; + mockGetNetworkClient: jest.Mock; +} { + const rootMessenger = createRootMessenger(); + const messenger = createServiceMessenger(rootMessenger); + + const mockGetNetworkConfig = jest.fn().mockReturnValue(MOCK_NETWORK_CONFIG); + const mockGetNetworkClient = jest.fn().mockReturnValue({ + provider: MOCK_PROVIDER, + }); + + rootMessenger.registerActionHandler( + 'NetworkController:getNetworkConfigurationByChainId', + mockGetNetworkConfig, + ); + rootMessenger.registerActionHandler( + 'NetworkController:getNetworkClientById', + mockGetNetworkClient, + ); + + rootMessenger.delegate({ + actions: [ + 'NetworkController:getNetworkConfigurationByChainId', + 'NetworkController:getNetworkClientById', + ], + events: [], + messenger, + }); + + const service = new MoneyAccountBalanceService({ + messenger, + config: { ...DEFAULT_CONFIG, ...config }, + ...options, + }); + + return { + service, + rootMessenger, + messenger, + mockGetNetworkConfig, + mockGetNetworkClient, + }; +} + +/** + * Configures the Contract mock so that `balanceOf` resolves to an object + * whose `.toString()` returns `balance`. + * + * @param balance - The raw uint256 balance string to return. + */ +function mockErc20BalanceOf(balance: string): void { + MockContract.mockImplementation( + () => + ({ + balanceOf: jest.fn().mockResolvedValue({ toString: () => balance }), + }) as unknown as Contract, + ); +} + +/** + * Configures the Contract mock so that `getRate` resolves to an object + * whose `.toString()` returns `rate`. + * + * @param rate - The raw uint256 rate string to return. + */ +function mockAccountantGetRate(rate: string): void { + MockContract.mockImplementation( + () => + ({ + getRate: jest.fn().mockResolvedValue({ toString: () => rate }), + }) as unknown as Contract, + ); +} + +// ============================================================ +// Tests +// ============================================================ + +describe('MoneyAccountBalanceService', () => { + beforeEach(() => { + MockWeb3Provider.mockImplementation(() => ({}) as unknown as Web3Provider); + nockCleanAll(); + }); + + afterEach(() => { + jest.resetAllMocks(); + }); + + // ---------------------------------------------------------- + // getMusdBalance + // ---------------------------------------------------------- + + describe('getMusdBalance', () => { + it('returns the mUSD balance for the given address', async () => { + mockErc20BalanceOf('5000000'); + const { service } = createService(); + + const result = await service.getMusdBalance(MOCK_ACCOUNT_ADDRESS); + + expect(result).toStrictEqual({ balance: '5000000' }); + }); + + it('calls balanceOf on the underlying token contract, not the vault', async () => { + mockErc20BalanceOf('5000000'); + const { service } = createService(); + + await service.getMusdBalance(MOCK_ACCOUNT_ADDRESS); + + expect(MockContract).toHaveBeenCalledWith( + MOCK_UNDERLYING_TOKEN_ADDRESS, + expect.anything(), + expect.anything(), + ); + expect(MockContract).not.toHaveBeenCalledWith( + MOCK_VAULT_ADDRESS, + expect.anything(), + expect.anything(), + ); + }); + + it('is also callable via the messenger action', async () => { + mockErc20BalanceOf('5000000'); + const { rootMessenger } = createService(); + + const result = await rootMessenger.call( + 'MoneyAccountBalanceService:getMusdBalance', + MOCK_ACCOUNT_ADDRESS, + ); + + expect(result).toStrictEqual({ balance: '5000000' }); + }); + + it('throws if no network configuration is found for the vault chain', async () => { + const { service, mockGetNetworkConfig } = createService(); + mockGetNetworkConfig.mockReturnValue(undefined); + + await expect( + service.getMusdBalance(MOCK_ACCOUNT_ADDRESS), + ).rejects.toThrow('No network configuration found for chain 0xa4b1'); + }); + + it('throws if the network client has no provider', async () => { + const { service, mockGetNetworkClient } = createService(); + mockGetNetworkClient.mockReturnValue({ provider: null }); + + await expect( + service.getMusdBalance(MOCK_ACCOUNT_ADDRESS), + ).rejects.toThrow('No provider found for chain 0xa4b1'); + }); + + it('uses the network client at defaultRpcEndpointIndex, not always index 0', async () => { + mockErc20BalanceOf('1000000'); + const { service, mockGetNetworkConfig, mockGetNetworkClient } = + createService(); + mockGetNetworkConfig.mockReturnValue({ + ...MOCK_NETWORK_CONFIG, + rpcEndpoints: [ + { + networkClientId: 'client-at-index-0', + url: 'https://rpc0.example.com', + }, + { + networkClientId: 'client-at-index-1', + url: 'https://rpc1.example.com', + }, + ], + defaultRpcEndpointIndex: 1, + }); + + await service.getMusdBalance(MOCK_ACCOUNT_ADDRESS); + + expect(mockGetNetworkClient).toHaveBeenCalledWith('client-at-index-1'); + expect(mockGetNetworkClient).not.toHaveBeenCalledWith( + 'client-at-index-0', + ); + }); + }); + + // ---------------------------------------------------------- + // getMusdSHFvdBalance + // ---------------------------------------------------------- + + describe('getMusdSHFvdBalance', () => { + it('returns the vault share balance for the given address', async () => { + mockErc20BalanceOf('3000000'); + const { service } = createService(); + + const result = await service.getMusdSHFvdBalance(MOCK_ACCOUNT_ADDRESS); + + expect(result).toStrictEqual({ balance: '3000000' }); + }); + + it('calls balanceOf on the vault contract, not the underlying token', async () => { + mockErc20BalanceOf('3000000'); + const { service } = createService(); + + await service.getMusdSHFvdBalance(MOCK_ACCOUNT_ADDRESS); + + expect(MockContract).toHaveBeenCalledWith( + MOCK_VAULT_ADDRESS, + expect.anything(), + expect.anything(), + ); + expect(MockContract).not.toHaveBeenCalledWith( + MOCK_UNDERLYING_TOKEN_ADDRESS, + expect.anything(), + expect.anything(), + ); + }); + }); + + // ---------------------------------------------------------- + // getExchangeRate + // ---------------------------------------------------------- + + describe('getExchangeRate', () => { + it('returns the exchange rate from the Accountant contract', async () => { + mockAccountantGetRate('1050000'); + const { service } = createService(); + + const result = await service.getExchangeRate(); + + expect(result).toStrictEqual({ rate: '1050000' }); + }); + + it('calls getRate on the accountant contract address', async () => { + mockAccountantGetRate('1050000'); + const { service } = createService(); + + await service.getExchangeRate(); + + expect(MockContract).toHaveBeenCalledWith( + MOCK_ACCOUNTANT_ADDRESS, + expect.anything(), + expect.anything(), + ); + }); + + it('throws if no network configuration is found for the vault chain', async () => { + const { service, mockGetNetworkConfig } = createService(); + mockGetNetworkConfig.mockReturnValue(undefined); + + await expect(service.getExchangeRate()).rejects.toThrow( + 'No network configuration found for chain 0xa4b1', + ); + }); + + it('throws if the network client has no provider', async () => { + const { service, mockGetNetworkClient } = createService(); + mockGetNetworkClient.mockReturnValue({ provider: null }); + + await expect(service.getExchangeRate()).rejects.toThrow( + 'No provider found for chain 0xa4b1', + ); + }); + }); + + // ---------------------------------------------------------- + // getMusdEquivalentValue + // ---------------------------------------------------------- + + describe('getMusdEquivalentValue', () => { + it('returns the vault share balance, exchange rate, and computed mUSD-equivalent value', async () => { + // balance = 2_000_000, rate = 1_100_000, decimals = 6 + // equivalent = (2_000_000 * 1_100_000) / 10^6 = 2_200_000 + MockContract.mockImplementationOnce( + () => + ({ + balanceOf: jest + .fn() + .mockResolvedValue({ toString: () => '2000000' }), + }) as unknown as Contract, + ).mockImplementationOnce( + () => + ({ + getRate: jest.fn().mockResolvedValue({ toString: () => '1100000' }), + }) as unknown as Contract, + ); + + const { service } = createService(); + + const result = await service.getMusdEquivalentValue(MOCK_ACCOUNT_ADDRESS); + + expect(result).toStrictEqual({ + musdSHFvdBalance: '2000000', + exchangeRate: '1100000', + musdEquivalentValue: '2200000', + }); + }); + + it('returns zero musdEquivalentValue when the vault share balance is zero', async () => { + MockContract.mockImplementationOnce( + () => + ({ + balanceOf: jest.fn().mockResolvedValue({ toString: () => '0' }), + }) as unknown as Contract, + ).mockImplementationOnce( + () => + ({ + getRate: jest.fn().mockResolvedValue({ toString: () => '1100000' }), + }) as unknown as Contract, + ); + + const { service } = createService(); + + const result = await service.getMusdEquivalentValue(MOCK_ACCOUNT_ADDRESS); + + expect(result.musdEquivalentValue).toBe('0'); + }); + + it('truncates (floors) fractional mUSD when the product is not evenly divisible', async () => { + // balance = 1_000_001, rate = 1_000_000, decimals = 6 + // equivalent = (1_000_001 * 1_000_000) / 10^6 = 1_000_001 (exact) + // Check with a value that *would* truncate: balance=3, rate=1_000_000, decimals=6 + // => (3 * 1_000_000) / 1_000_000 = 3 (no truncation needed in this case) + // Real truncation test: balance=7, rate=1_500_000, decimals=6 + // => (7 * 1_500_000) / 1_000_000 = 10_500_000 / 1_000_000 = 10 (BigInt floors) + MockContract.mockImplementationOnce( + () => + ({ + balanceOf: jest.fn().mockResolvedValue({ toString: () => '7' }), + }) as unknown as Contract, + ).mockImplementationOnce( + () => + ({ + getRate: jest.fn().mockResolvedValue({ toString: () => '1500000' }), + }) as unknown as Contract, + ); + + const { service } = createService(); + + const result = await service.getMusdEquivalentValue(MOCK_ACCOUNT_ADDRESS); + + expect(result.musdEquivalentValue).toBe('10'); + }); + }); + + // ---------------------------------------------------------- + // getVaultApy + // ---------------------------------------------------------- + + describe('getVaultApy', () => { + it('returns the normalized vault APY from the Veda performance API', async () => { + nock('https://api.sevenseas.capital') + .get(`/performance/arbitrum/${MOCK_VAULT_ADDRESS}`) + .reply(200, MOCK_VAULT_APY_RAW_RESPONSE); + + const { service } = createService(); + + const result = await service.getVaultApy(); + + expect(result).toStrictEqual(MOCK_VAULT_APY_NORMALIZED); + }); + + it('uses a custom vedaApiBaseUrl when provided', async () => { + nock('https://custom-veda-api.example.com') + .get(`/performance/arbitrum/${MOCK_VAULT_ADDRESS}`) + .reply(200, MOCK_VAULT_APY_RAW_RESPONSE); + + const { service } = createService({ + config: { vedaApiBaseUrl: 'https://custom-veda-api.example.com' }, + }); + + const result = await service.getVaultApy(); + + expect(result).toStrictEqual(MOCK_VAULT_APY_NORMALIZED); + }); + + it('throws HttpError on a non-200 response', async () => { + nock('https://api.sevenseas.capital') + .get(`/performance/arbitrum/${MOCK_VAULT_ADDRESS}`) + .times(DEFAULT_MAX_RETRIES + 1) + .reply(500); + + const { service } = createService(); + + await expect(service.getVaultApy()).rejects.toThrow( + new HttpError(500, "Veda performance API failed with status '500'"), + ); + }); + + it('throws VedaResponseValidationError on a malformed response body', async () => { + nock('https://api.sevenseas.capital') + .get(`/performance/arbitrum/${MOCK_VAULT_ADDRESS}`) + .reply(200, { unexpected: 'shape' }); + + const { service } = createService(); + + await expect(service.getVaultApy()).rejects.toThrow( + new VedaResponseValidationError( + 'Malformed response received from Veda performance API', + ), + ); + }); + + it.each([ + { description: 'missing Response key', body: {} }, + { + description: 'missing apy field', + body: { + Response: { ...MOCK_VAULT_APY_RAW_RESPONSE.Response, apy: undefined }, + }, + }, + { + description: 'apy is not a number', + body: { + Response: { ...MOCK_VAULT_APY_RAW_RESPONSE.Response, apy: 'high' }, + }, + }, + { + description: 'missing timestamp field', + body: { + Response: { + ...MOCK_VAULT_APY_RAW_RESPONSE.Response, + timestamp: undefined, + }, + }, + }, + ])( + 'throws VedaResponseValidationError when response is malformed: $description', + async ({ body }) => { + nock('https://api.sevenseas.capital') + .get(`/performance/arbitrum/${MOCK_VAULT_ADDRESS}`) + .reply(200, body); + + const { service } = createService(); + + await expect(service.getVaultApy()).rejects.toThrow( + VedaResponseValidationError, + ); + }, + ); + + it('accepts and normalizes a sparse response where only apy and timestamp are present', async () => { + const sparseResponse = { + Response: { + aggregation_period: '7 days', + apy: 0, + chain_allocation: { arbitrum: 0 }, + fees: 0, + global_apy_breakdown: { fee: 0, maturity_apy: 0, real_apy: 0 }, + maturity_apy_breakdown: [], + real_apy_breakdown: [], + timestamp: 'Fri, 10 Apr 2026 22:05:54 GMT', + }, + }; + + nock('https://api.sevenseas.capital') + .get(`/performance/arbitrum/${MOCK_VAULT_ADDRESS}`) + .reply(200, sparseResponse); + + const { service } = createService(); + + const result = await service.getVaultApy(); + + expect(result.apy).toBe(0); + expect(result.timestamp).toBe('Fri, 10 Apr 2026 22:05:54 GMT'); + expect(result.realApyBreakdown).toStrictEqual([]); + }); + + it('accepts a response that omits all optional fields', async () => { + const minimalResponse = { + Response: { + apy: 0.03, + timestamp: '2026-01-01T00:00:00Z', + }, + }; + + nock('https://api.sevenseas.capital') + .get(`/performance/arbitrum/${MOCK_VAULT_ADDRESS}`) + .reply(200, minimalResponse); + + const { service } = createService(); + + const result = await service.getVaultApy(); + + expect(result).toStrictEqual({ + aggregationPeriod: undefined, + apy: 0.03, + chainAllocation: undefined, + fees: undefined, + globalApyBreakdown: undefined, + performanceFees: undefined, + realApyBreakdown: undefined, + timestamp: '2026-01-01T00:00:00Z', + }); + }); + + it('does not retry on VedaResponseValidationError', async () => { + // Only one nock scope — if retry happened, the second call would throw a + // different error (nock "no match" instead of VedaResponseValidationError). + nock('https://api.sevenseas.capital') + .get(`/performance/arbitrum/${MOCK_VAULT_ADDRESS}`) + .once() + .reply(200, { unexpected: 'shape' }); + + const { service } = createService(); + + await expect(service.getVaultApy()).rejects.toThrow( + VedaResponseValidationError, + ); + }); + + it('falls back to the default network name for unknown chain IDs', async () => { + // 0x1 is not in VEDA_API_NETWORK_NAMES, so DEFAULT_VEDA_API_NETWORK_NAME + // ('arbitrum') should be used. Nock matches on exact URL, so if the wrong + // network name were used the request would throw instead of returning data. + nock('https://api.sevenseas.capital') + .get(`/performance/arbitrum/${MOCK_VAULT_ADDRESS}`) + .reply(200, MOCK_VAULT_APY_RAW_RESPONSE); + + const { service } = createService({ + config: { vaultChainId: '0x1' as const }, + }); + + const result = await service.getVaultApy(); + + expect(result).toStrictEqual(MOCK_VAULT_APY_NORMALIZED); + }); + }); +}); + +describe('VedaResponseValidationError', () => { + it('uses the default message when constructed with no argument', () => { + const error = new VedaResponseValidationError(); + + expect(error.message).toBe('Malformed response received from Veda API'); + expect(error.name).toBe('VedaResponseValidationError'); + }); +});