From 39266394ef4bca9600c26353c8c6d43b75f3f305 Mon Sep 17 00:00:00 2001 From: Shane T Date: Thu, 16 Jul 2026 12:04:59 +0100 Subject: [PATCH 1/2] feat(money-account-balance-service): orchestrate balance source (rpc/api) Turn MoneyAccountBalanceService into the single balance entry point. A new getBalance() method selects between the on-chain RPC Multicall strategy and a Money-API strategy (via MoneyAccountApiDataService:fetchPositions), with fallback, driven by the moneyAccountBalanceSource remote feature flag (enabledSources, preferredSource, maxAttempts) read the same way as the existing vault-config/stale-time flags. Scoped to balance values only so a Veda APY outage cannot block balances. getMoneyAccountBalance is retained for backwards compatibility. --- .../CHANGELOG.md | 10 + .../package.json | 1 + .../src/constants.ts | 25 ++ .../src/errors.ts | 16 + .../src/index.ts | 8 +- ...unt-balance-service-method-action-types.ts | 14 + .../src/money-account-balance-service.test.ts | 183 +++++++++ .../src/money-account-balance-service.ts | 366 ++++++++++++++---- .../src/structs.ts | 32 ++ .../src/types.ts | 27 ++ .../tsconfig.build.json | 1 + .../tsconfig.json | 1 + 12 files changed, 617 insertions(+), 67 deletions(-) diff --git a/packages/money-account-balance-service/CHANGELOG.md b/packages/money-account-balance-service/CHANGELOG.md index cdfc4034fc1..3dbd5f1f5ca 100644 --- a/packages/money-account-balance-service/CHANGELOG.md +++ b/packages/money-account-balance-service/CHANGELOG.md @@ -7,6 +7,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Add `getBalance` orchestration entry point to `MoneyAccountBalanceService` that fetches Money account balances from a configurable source (`'rpc'` on-chain Multicall3, or `'api'` via `MoneyAccountApiDataService:fetchPositions`) with fallback, keeping source selection out of clients +- Add balance-source orchestration config via the `moneyAccountBalanceSource` remote feature flag (`enabledSources`, `preferredSource`, `maxAttempts`), read the same way as the existing vault-config and balance stale-time flags; defaults to RPC only +- Export `BalanceSource` and `BalanceSourceConfig` types, the `MoneyAccountBalanceServiceGetBalanceAction` action type, and the `MoneyApiBalanceUnavailableError` error + +### Changed + +- `MoneyAccountBalanceService` now depends on `@metamask/money-account-api-data-service` (type + `fetchPositions` action) to power the `'api'` balance source + ## [2.2.0] ### Added diff --git a/packages/money-account-balance-service/package.json b/packages/money-account-balance-service/package.json index 37143d80c0a..bc9a7d380bc 100644 --- a/packages/money-account-balance-service/package.json +++ b/packages/money-account-balance-service/package.json @@ -61,6 +61,7 @@ "@metamask/controller-utils": "^12.3.0", "@metamask/messenger": "^2.0.0", "@metamask/metamask-eth-abis": "^3.1.1", + "@metamask/money-account-api-data-service": "^0.1.0", "@metamask/network-controller": "^34.0.0", "@metamask/remote-feature-flag-controller": "^4.2.2", "@metamask/superstruct": "^3.1.0", diff --git a/packages/money-account-balance-service/src/constants.ts b/packages/money-account-balance-service/src/constants.ts index ea7897c028e..b67b8d51450 100644 --- a/packages/money-account-balance-service/src/constants.ts +++ b/packages/money-account-balance-service/src/constants.ts @@ -23,6 +23,31 @@ export const MONEY_ACCOUNT_BALANCE_STALETIME_FEATURE_FLAG_KEY = */ export const DEFAULT_BALANCE_STALE_TIME = inMilliseconds(1, Duration.Minute); +/** + * The key under which the balance-source orchestration config is stored in + * `RemoteFeatureFlagController` state's `remoteFeatureFlags` map. + * + * The value is validated against `BalanceSourceConfigStruct`; anything absent + * or malformed falls back to {@link DEFAULT_BALANCE_SOURCE_CONFIG}. + * + * @see BalanceSourceConfig + */ +export const MONEY_ACCOUNT_BALANCE_SOURCE_FEATURE_FLAG_KEY = + 'moneyAccountBalanceSource'; + +/** + * Default balance-source orchestration config, used when + * {@link MONEY_ACCOUNT_BALANCE_SOURCE_FEATURE_FLAG_KEY} is absent or malformed. + * + * Defaults to RPC only so the on-chain path stays the source of truth until the + * Money API path is explicitly enabled via remote config. + */ +export const DEFAULT_BALANCE_SOURCE_CONFIG = { + enabledSources: ['rpc'], + preferredSource: 'rpc', + maxAttempts: 1, +} as const; + export const VEDA_API_NETWORK_NAMES: Record = { '0xa4b1': 'arbitrum', '0x8f': 'monad', diff --git a/packages/money-account-balance-service/src/errors.ts b/packages/money-account-balance-service/src/errors.ts index 329d6c10580..875b9184ef4 100644 --- a/packages/money-account-balance-service/src/errors.ts +++ b/packages/money-account-balance-service/src/errors.ts @@ -35,3 +35,19 @@ export class VaultConfigValidationError extends Error { this.name = 'VaultConfigValidationError'; } } + +/** + * Thrown by the `'api'` balance strategy when the Money Account API positions + * response does not include a usable `balance` object. This is a "source + * unavailable" signal that lets {@link MoneyAccountBalanceService.getBalance} + * fall back to the next configured source rather than failing outright. + */ +export class MoneyApiBalanceUnavailableError extends Error { + constructor(message?: string) { + super( + message ?? + 'MoneyAccountBalanceService: Money API positions response did not include a usable balance object.', + ); + this.name = 'MoneyApiBalanceUnavailableError'; + } +} diff --git a/packages/money-account-balance-service/src/index.ts b/packages/money-account-balance-service/src/index.ts index 1b25bbd7738..eced0c3d6ea 100644 --- a/packages/money-account-balance-service/src/index.ts +++ b/packages/money-account-balance-service/src/index.ts @@ -8,6 +8,7 @@ export type { MoneyAccountBalanceServiceTraceRequest, } from './money-account-balance-service'; export type { + MoneyAccountBalanceServiceGetBalanceAction, MoneyAccountBalanceServiceGetMoneyAccountBalanceAction, MoneyAccountBalanceServiceGetMusdBalanceAction, MoneyAccountBalanceServiceGetVmusdBalanceAction, @@ -22,7 +23,12 @@ export type { NormalizedVaultApyResponse, } from './response.types'; export { + MoneyApiBalanceUnavailableError, VaultConfigNotAvailableError, VaultConfigValidationError, } from './errors'; -export type { VaultConfig } from './types'; +export type { + BalanceSource, + BalanceSourceConfig, + VaultConfig, +} from './types'; diff --git a/packages/money-account-balance-service/src/money-account-balance-service-method-action-types.ts b/packages/money-account-balance-service/src/money-account-balance-service-method-action-types.ts index 1c5493a4799..11fdc589a56 100644 --- a/packages/money-account-balance-service/src/money-account-balance-service-method-action-types.ts +++ b/packages/money-account-balance-service/src/money-account-balance-service-method-action-types.ts @@ -17,6 +17,19 @@ export type MoneyAccountBalanceServiceGetMusdBalanceAction = { handler: MoneyAccountBalanceService['getMusdBalance']; }; +/** + * Fetches the account's total Money balance, orchestrating across the + * configured balance sources (RPC / Money API) with fallback. + * + * @param accountAddress - The Money account's Ethereum address. + * @returns The mUSD balance, mUSD-equivalent vault-share value, and total, as + * raw uint256 strings. + */ +export type MoneyAccountBalanceServiceGetBalanceAction = { + type: `MoneyAccountBalanceService:getBalance`; + handler: MoneyAccountBalanceService['getBalance']; +}; + /** * Fetches the account's total Money balance inputs in a single batched RPC * request via Multicall3's `aggregate3` @@ -87,6 +100,7 @@ export type MoneyAccountBalanceServiceGetVaultApyAction = { * Union of all MoneyAccountBalanceService action types. */ export type MoneyAccountBalanceServiceMethodActions = + | MoneyAccountBalanceServiceGetBalanceAction | MoneyAccountBalanceServiceGetMusdBalanceAction | MoneyAccountBalanceServiceGetMoneyAccountBalanceAction | MoneyAccountBalanceServiceGetVmusdBalanceAction diff --git a/packages/money-account-balance-service/src/money-account-balance-service.test.ts b/packages/money-account-balance-service/src/money-account-balance-service.test.ts index c2d9897d2e5..aecc3b4ab23 100644 --- a/packages/money-account-balance-service/src/money-account-balance-service.test.ts +++ b/packages/money-account-balance-service/src/money-account-balance-service.test.ts @@ -18,6 +18,7 @@ import nock, { cleanAll as nockCleanAll } from 'nock'; import { LENS_ABI, + MONEY_ACCOUNT_BALANCE_SOURCE_FEATURE_FLAG_KEY, MONEY_ACCOUNT_BALANCE_STALETIME_FEATURE_FLAG_KEY, MULTICALL3_ADDRESS_BY_CHAIN_ID, VAULT_CONFIG_FEATURE_FLAG_KEY, @@ -69,6 +70,25 @@ const MOCK_VAULT_CONFIG_WITH_UNDERLYING_TOKEN = { underlyingToken: MOCK_UNDERLYING_TOKEN_ADDRESS, }; +/** + * A Money Account API positions response including the top-level `balance` + * object the `'api'` balance source maps from. Values are raw uint256 (6-dp) + * strings, matching the RPC source's response shape. + */ +const MOCK_API_POSITIONS = { + address: MOCK_ACCOUNT_ADDRESS, + as_of_block: 0, + as_of_timestamp: '2026-01-01T00:00:00Z', + data_freshness: 'live', + indexer_lag_seconds: 0, + positions: [], + balance: { + musd_balance: '5000000', + total_balance: '7200000', + vmusd_value_in_musd: '2200000', + }, +}; + const MOCK_NETWORK_CONFIG = { chainId: '0xa4b1' as const, rpcEndpoints: [ @@ -221,6 +241,7 @@ function createService({ mockGetNetworkConfig: jest.Mock; mockGetNetworkClient: jest.Mock; mockGetRFFCState: jest.Mock; + mockFetchPositions: jest.Mock; captureException: jest.Mock; } { const rootMessenger = createRootMessenger(captureException); @@ -233,6 +254,7 @@ function createService({ const mockGetRFFCState = jest .fn() .mockReturnValue({ remoteFeatureFlags: rffcFlags, cacheTimestamp: 0 }); + const mockFetchPositions = jest.fn().mockResolvedValue(MOCK_API_POSITIONS); rootMessenger.registerActionHandler( 'NetworkController:getNetworkConfigurationByChainId', @@ -246,12 +268,17 @@ function createService({ 'RemoteFeatureFlagController:getState', mockGetRFFCState, ); + rootMessenger.registerActionHandler( + 'MoneyAccountApiDataService:fetchPositions', + mockFetchPositions, + ); rootMessenger.delegate({ actions: [ 'NetworkController:getNetworkConfigurationByChainId', 'NetworkController:getNetworkClientById', 'RemoteFeatureFlagController:getState', + 'MoneyAccountApiDataService:fetchPositions', ], // eslint-disable-next-line no-restricted-syntax events: ['RemoteFeatureFlagController:stateChange'], @@ -271,6 +298,7 @@ function createService({ mockGetNetworkConfig, mockGetNetworkClient, mockGetRFFCState, + mockFetchPositions, captureException, }; } @@ -1397,6 +1425,161 @@ describe('MoneyAccountBalanceService', () => { // getMoneyAccountBalance // ---------------------------------------------------------- + describe('getBalance (source orchestration)', () => { + const RPC_ONLY_FLAGS = { + [VAULT_CONFIG_FEATURE_FLAG_KEY]: MOCK_VAULT_CONFIG_WITH_UNDERLYING_TOKEN, + }; + + const withSourceConfig = ( + config: Record, + ): Record => ({ + ...RPC_ONLY_FLAGS, + [MONEY_ACCOUNT_BALANCE_SOURCE_FEATURE_FLAG_KEY]: config as Json, + }); + + it("defaults to the RPC source and does not call the Money API", async () => { + const aggregate3 = mockMoneyAccountBalanceMulticall({ + musdBalance: '5000000', + vmusdValueInMusd: '2200000', + }); + const { service, mockFetchPositions } = createService({ + rffcFlags: RPC_ONLY_FLAGS, + }); + + const result = await service.getBalance(MOCK_ACCOUNT_ADDRESS); + + expect(result).toStrictEqual({ + musdBalance: '5000000', + vmusdValueInMusd: '2200000', + totalBalance: '7200000', + }); + expect(aggregate3).toHaveBeenCalledTimes(1); + expect(mockFetchPositions).not.toHaveBeenCalled(); + }); + + it("uses the Money API source when preferred, mapping the balance object", async () => { + const aggregate3 = mockMoneyAccountBalanceMulticall(); + const { service, mockFetchPositions } = createService({ + rffcFlags: withSourceConfig({ + enabledSources: ['api', 'rpc'], + preferredSource: 'api', + maxAttempts: 2, + }), + }); + + const result = await service.getBalance(MOCK_ACCOUNT_ADDRESS); + + expect(result).toStrictEqual({ + musdBalance: '5000000', + vmusdValueInMusd: '2200000', + totalBalance: '7200000', + }); + expect(mockFetchPositions).toHaveBeenCalledWith(MOCK_ACCOUNT_ADDRESS); + expect(aggregate3).not.toHaveBeenCalled(); + }); + + it("falls back to RPC when the preferred API source throws", async () => { + const aggregate3 = mockMoneyAccountBalanceMulticall({ + musdBalance: '5000000', + vmusdValueInMusd: '2200000', + }); + const { service, mockFetchPositions } = createService({ + rffcFlags: withSourceConfig({ + enabledSources: ['api', 'rpc'], + preferredSource: 'api', + maxAttempts: 2, + }), + }); + mockFetchPositions.mockRejectedValueOnce(new Error('Money API down')); + + const result = await service.getBalance(MOCK_ACCOUNT_ADDRESS); + + expect(result).toStrictEqual({ + musdBalance: '5000000', + vmusdValueInMusd: '2200000', + totalBalance: '7200000', + }); + expect(mockFetchPositions).toHaveBeenCalledTimes(1); + expect(aggregate3).toHaveBeenCalledTimes(1); + }); + + it("falls back to RPC when the API response has no usable balance object", async () => { + const aggregate3 = mockMoneyAccountBalanceMulticall({ + musdBalance: '5000000', + vmusdValueInMusd: '2200000', + }); + const { service, mockFetchPositions } = createService({ + rffcFlags: withSourceConfig({ + enabledSources: ['api', 'rpc'], + preferredSource: 'api', + maxAttempts: 2, + }), + }); + mockFetchPositions.mockResolvedValueOnce({ + ...MOCK_API_POSITIONS, + balance: undefined, + }); + + const result = await service.getBalance(MOCK_ACCOUNT_ADDRESS); + + expect(result.totalBalance).toBe('7200000'); + expect(aggregate3).toHaveBeenCalledTimes(1); + }); + + it("does not fall back beyond maxAttempts", async () => { + mockMoneyAccountBalanceMulticall(); + const { service, mockFetchPositions } = createService({ + rffcFlags: withSourceConfig({ + enabledSources: ['api', 'rpc'], + preferredSource: 'api', + maxAttempts: 1, + }), + }); + mockFetchPositions.mockRejectedValue(new Error('Money API down')); + + await expect(service.getBalance(MOCK_ACCOUNT_ADDRESS)).rejects.toThrow( + 'Money API down', + ); + }); + + it("picks up a source config change published via RemoteFeatureFlagController", async () => { + const aggregate3 = mockMoneyAccountBalanceMulticall(); + const { service, rootMessenger, mockFetchPositions } = createService({ + rffcFlags: RPC_ONLY_FLAGS, + }); + + publishRFFCStateChange(rootMessenger, { + ...RPC_ONLY_FLAGS, + [MONEY_ACCOUNT_BALANCE_SOURCE_FEATURE_FLAG_KEY]: { + enabledSources: ['api'], + preferredSource: 'api', + maxAttempts: 1, + }, + }); + + await service.getBalance(MOCK_ACCOUNT_ADDRESS); + + expect(mockFetchPositions).toHaveBeenCalledTimes(1); + expect(aggregate3).not.toHaveBeenCalled(); + }); + + it("ignores a malformed source config flag and keeps using RPC", async () => { + const aggregate3 = mockMoneyAccountBalanceMulticall({ + musdBalance: '1', + vmusdValueInMusd: '2', + }); + const { service, mockFetchPositions } = createService({ + rffcFlags: withSourceConfig({ preferredSource: 'carrier-pigeon' }), + }); + + const result = await service.getBalance(MOCK_ACCOUNT_ADDRESS); + + expect(result.totalBalance).toBe('3'); + expect(aggregate3).toHaveBeenCalledTimes(1); + expect(mockFetchPositions).not.toHaveBeenCalled(); + }); + }); + describe('getMoneyAccountBalance', () => { it('returns musdBalance, vmusdValueInMusd, and totalBalance from a single aggregate3 call', async () => { const aggregate3 = mockMoneyAccountBalanceMulticall({ diff --git a/packages/money-account-balance-service/src/money-account-balance-service.ts b/packages/money-account-balance-service/src/money-account-balance-service.ts index 955ce61e5cc..ee795a67db6 100644 --- a/packages/money-account-balance-service/src/money-account-balance-service.ts +++ b/packages/money-account-balance-service/src/money-account-balance-service.ts @@ -26,10 +26,17 @@ import { assert, is } from '@metamask/superstruct'; import type { Hex, Json } from '@metamask/utils'; import { Duration, inMilliseconds } from '@metamask/utils'; +import type { + PositionResponse, + MoneyAccountApiDataServiceFetchPositionsAction, +} from '@metamask/money-account-api-data-service'; + import { ACCOUNTANT_ABI, + DEFAULT_BALANCE_SOURCE_CONFIG, DEFAULT_BALANCE_STALE_TIME, LENS_ABI, + MONEY_ACCOUNT_BALANCE_SOURCE_FEATURE_FLAG_KEY, MONEY_ACCOUNT_BALANCE_STALETIME_FEATURE_FLAG_KEY, MULTICALL3_ABI, MULTICALL3_ADDRESS_BY_CHAIN_ID, @@ -38,6 +45,7 @@ import { VEDA_PERFORMANCE_API_BASE_URL, } from './constants'; import { + MoneyApiBalanceUnavailableError, VaultConfigNotAvailableError, VaultConfigValidationError, VedaResponseValidationError, @@ -51,8 +59,29 @@ import type { MusdEquivalentValueResponse, NormalizedVaultApyResponse, } from './response.types'; -import { VaultApyRawResponseStruct, VaultConfigStruct } from './structs'; -import type { VaultConfig } from './types'; +import { + BalanceSourceConfigStruct, + MoneyApiBalanceStruct, + VaultApyRawResponseStruct, + VaultConfigStruct, +} from './structs'; +import type { BalanceSource, BalanceSourceConfig, VaultConfig } from './types'; + +/** + * The Money Account API positions response, extended with the optional + * top-level `balance` object the API returns alongside positions. Kept local + * until the field lands in the published `PositionResponse` type. + */ +type PositionResponseWithBalance = PositionResponse & { + balance?: { + // eslint-disable-next-line @typescript-eslint/naming-convention + musd_balance: string; + // eslint-disable-next-line @typescript-eslint/naming-convention + total_balance: string; + // eslint-disable-next-line @typescript-eslint/naming-convention + vmusd_value_in_musd: string; + }; +}; // === GENERAL === @@ -82,6 +111,7 @@ export const TRACES = { ERC20_BALANCE_RPC: 'Get Money Account ERC20 Balance RPC', UNDERLYING_TOKEN_RPC: 'Get Money Account Underlying Token RPC', MONEY_ACCOUNT_BALANCE_RPC: 'Get Money Account Balance RPC', + MONEY_ACCOUNT_BALANCE_API: 'Get Money Account Balance API', EXCHANGE_RATE_RPC: 'Get Money Account Exchange Rate RPC', MUSD_EQUIVALENT_VALUE_RPC: 'Get Money Account mUSD Equivalent Value RPC', VAULT_APY_API: 'Get Money Account Vault APY API', @@ -109,6 +139,7 @@ const traceLogger = createModuleLogger(projectLogger, 'trace'); // === MESSENGER === const MESSENGER_EXPOSED_METHODS = [ + 'getBalance', 'getMoneyAccountBalance', 'getMusdBalance', 'getVmusdBalance', @@ -117,6 +148,8 @@ const MESSENGER_EXPOSED_METHODS = [ 'getVaultApy', ] as const; +const balanceSourceLogger = createModuleLogger(projectLogger, 'balance-source'); + /** * Invalidates cached queries for {@link MoneyAccountBalanceService}. */ @@ -136,7 +169,8 @@ export type MoneyAccountBalanceServiceActions = type AllowedActions = | NetworkControllerGetNetworkConfigurationByChainIdAction | NetworkControllerGetNetworkClientByIdAction - | RemoteFeatureFlagControllerGetStateAction; + | RemoteFeatureFlagControllerGetStateAction + | MoneyAccountApiDataServiceFetchPositionsAction; /** * Published when {@link MoneyAccountBalanceService}'s cache is updated. @@ -216,6 +250,17 @@ export class MoneyAccountBalanceService extends BaseDataService< /** Cache stale time (ms) for on-chain balance reads. Overridable via remote feature flag. */ #balanceStaleTime: number = DEFAULT_BALANCE_STALE_TIME; + /** + * Balance-source orchestration config. Controls which source + * {@link getBalance} uses and its fallback order. Overridable via remote + * feature flag; defaults to {@link DEFAULT_BALANCE_SOURCE_CONFIG}. + */ + #balanceSourceConfig: BalanceSourceConfig = { + enabledSources: [...DEFAULT_BALANCE_SOURCE_CONFIG.enabledSources], + preferredSource: DEFAULT_BALANCE_SOURCE_CONFIG.preferredSource, + maxAttempts: DEFAULT_BALANCE_SOURCE_CONFIG.maxAttempts, + }; + readonly #trace: MoneyAccountBalanceServiceTraceCallback; /** @@ -400,9 +445,64 @@ export class MoneyAccountBalanceService extends BaseDataService< this.#applyBalanceStaleTimeFlag( remoteFeatureFlags[MONEY_ACCOUNT_BALANCE_STALETIME_FEATURE_FLAG_KEY], ); + this.#applyBalanceSourceConfig( + remoteFeatureFlags[MONEY_ACCOUNT_BALANCE_SOURCE_FEATURE_FLAG_KEY], + ); this.#applyVaultConfig(remoteFeatureFlags[VAULT_CONFIG_FEATURE_FLAG_KEY]); } + /** + * Applies the balance-source orchestration config feature flag, merging any + * provided fields over {@link DEFAULT_BALANCE_SOURCE_CONFIG}. Malformed values + * are ignored (config falls back to the default / previous value), mirroring + * the tolerant handling of the `staleTime` flag. + * + * @param flagValue - Raw flag value from `remoteFeatureFlags`. + */ + #applyBalanceSourceConfig(flagValue: Json | undefined): void { + if (flagValue === undefined) { + return; + } + + if (!is(flagValue, BalanceSourceConfigStruct)) { + balanceSourceLogger( + 'Invalid balance-source config flag value; keeping previous config', + { flagValue, current: this.#balanceSourceConfig }, + ); + return; + } + + const enabledSources = + flagValue.enabledSources && flagValue.enabledSources.length > 0 + ? flagValue.enabledSources + : [...DEFAULT_BALANCE_SOURCE_CONFIG.enabledSources]; + + const preferredSource = + flagValue.preferredSource ?? + DEFAULT_BALANCE_SOURCE_CONFIG.preferredSource; + + const maxAttempts = + typeof flagValue.maxAttempts === 'number' && flagValue.maxAttempts >= 1 + ? flagValue.maxAttempts + : DEFAULT_BALANCE_SOURCE_CONFIG.maxAttempts; + + const next: BalanceSourceConfig = { + enabledSources, + preferredSource, + maxAttempts, + }; + + if (JSON.stringify(next) === JSON.stringify(this.#balanceSourceConfig)) { + return; + } + + balanceSourceLogger('Balance-source config updated', { + previous: this.#balanceSourceConfig, + next, + }); + this.#balanceSourceConfig = next; + } + /** * Validates the vault config feature flag value, updates `#vaultConfig`, and * invalidates all cached queries when the config changes. @@ -637,81 +737,215 @@ export class MoneyAccountBalanceService extends BaseDataService< } /** - * Fetches the account's total Money balance inputs in a single batched RPC - * request via Multicall3's `aggregate3` + * Fetches the account's total Money balance, orchestrating across the + * configured balance sources. + * + * This is the single entry point for balances: source selection and fallback + * are handled here (driven by the remote {@link BalanceSourceConfig}) so + * clients never choose a source. The `preferredSource` is tried first, then + * the remaining `enabledSources` in order, stopping at the first success + * (bounded by `maxAttempts`). Both sources return the identical + * {@link MoneyAccountBalanceResponse} shape. + * + * Scoped to balance values only — APY lives in {@link getVaultApy} so that a + * Veda outage cannot block balances. + * + * @param accountAddress - The Money account's Ethereum address. + * @returns The mUSD balance, mUSD-equivalent vault-share value, and total, as + * raw uint256 strings. + * @throws {@link VaultConfigNotAvailableError} if the RPC source is used + * before vault config has loaded, or the last source error if all fail. + */ + async getBalance( + accountAddress: Hex, + ): Promise { + return this.fetchQuery({ + queryKey: [`${this.name}:getBalance`, accountAddress], + queryFn: async () => this.#orchestrateBalance(accountAddress), + staleTime: this.#balanceStaleTime, + }); + } + + /** + * Builds the ordered list of balance sources to attempt: the preferred + * source first (when enabled), then the remaining enabled sources, capped at + * `maxAttempts`. + * + * @returns The ordered, de-duplicated list of sources to try. + */ + #resolveBalanceSources(): BalanceSource[] { + const { enabledSources, preferredSource, maxAttempts } = + this.#balanceSourceConfig; + + const ordered: BalanceSource[] = [ + ...(enabledSources.includes(preferredSource) ? [preferredSource] : []), + ...enabledSources.filter((source) => source !== preferredSource), + ]; + + return ordered.slice(0, Math.max(1, maxAttempts)); + } + + /** + * Tries each configured balance source in order, returning the first + * success. Non-fatal source failures are logged and the next source is + * attempted; if every source fails the last error is rethrown. + * + * @param accountAddress - The Money account's Ethereum address. + * @returns The balance from the first successful source. + */ + async #orchestrateBalance( + accountAddress: Hex, + ): Promise { + const sources = this.#resolveBalanceSources(); + let lastError: unknown; + + for (const source of sources) { + try { + return source === 'api' + ? await this.#fetchBalanceViaApi(accountAddress) + : await this.#fetchBalanceViaRpc(accountAddress); + } catch (error) { + lastError = error; + balanceSourceLogger('Balance source failed; trying next source', { + source, + error, + }); + } + } + + throw ( + lastError ?? + new Error('MoneyAccountBalanceService: no balance sources configured') + ); + } + + /** + * `'api'` balance strategy: fetches positions from the Money Account API via + * `MoneyAccountApiDataService:fetchPositions` and maps the response's + * `balance` object to {@link MoneyAccountBalanceResponse}. + * + * @param accountAddress - The Money account's Ethereum address. + * @returns The balance derived from the API response. + * @throws {@link MoneyApiBalanceUnavailableError} if the response has no + * usable `balance` object, so the orchestrator can fall back to another source. + */ + async #fetchBalanceViaApi( + accountAddress: Hex, + ): Promise { + const positions = (await this.#traceNetworkRequest( + { + name: TRACES.MONEY_ACCOUNT_BALANCE_API, + data: { operation: 'fetchPositions' }, + }, + async () => + this.messenger.call( + 'MoneyAccountApiDataService:fetchPositions', + accountAddress, + ), + )) as PositionResponseWithBalance; + + const { balance } = positions; + + if (!is(balance, MoneyApiBalanceStruct)) { + throw new MoneyApiBalanceUnavailableError(); + } + + return { + musdBalance: balance.musd_balance, + vmusdValueInMusd: balance.vmusd_value_in_musd, + totalBalance: balance.total_balance, + }; + } + + /** + * `'rpc'` balance strategy: reads the account's total Money balance inputs in + * a single batched Multicall3 `aggregate3` request. * * @param accountAddress - The Money account's Ethereum address. * @returns The mUSD balance and the mUSD-equivalent value of vault shares as - * raw uint256 strings. The total balance is the sum of the mUSD balance and the mUSD-equivalent value of vault shares. + * raw uint256 strings. The total balance is their sum. * @throws {@link VaultConfigNotAvailableError} if vault config has not been loaded. */ - async getMoneyAccountBalance( + async #fetchBalanceViaRpc( accountAddress: Hex, ): Promise { - return this.fetchQuery({ - queryKey: [`${this.name}:getMoneyAccountBalance`, accountAddress], - queryFn: async () => { - const { chainId, boringVault, accountantAddress, lensAddress } = - this.#requireConfig(); - const provider = this.#getProvider(chainId); + const { chainId, boringVault, accountantAddress, lensAddress } = + this.#requireConfig(); + const provider = this.#getProvider(chainId); - const underlyingTokenAddress = - await this.#resolveUnderlyingTokenAddress(chainId); + const underlyingTokenAddress = + await this.#resolveUnderlyingTokenAddress(chainId); - const erc20 = new Contract(underlyingTokenAddress, abiERC20, provider); - const lens = new Contract(lensAddress, LENS_ABI, provider); + const erc20 = new Contract(underlyingTokenAddress, abiERC20, provider); + const lens = new Contract(lensAddress, LENS_ABI, provider); - const calls = [ - { - target: underlyingTokenAddress, - allowFailure: false, - callData: erc20.interface.encodeFunctionData('balanceOf', [ - accountAddress, - ]), - }, - { - target: lensAddress, - allowFailure: false, - callData: lens.interface.encodeFunctionData('balanceOfInAssets', [ - accountAddress, - boringVault, - accountantAddress, - ]), - }, - ]; + const calls = [ + { + target: underlyingTokenAddress, + allowFailure: false, + callData: erc20.interface.encodeFunctionData('balanceOf', [ + accountAddress, + ]), + }, + { + target: lensAddress, + allowFailure: false, + callData: lens.interface.encodeFunctionData('balanceOfInAssets', [ + accountAddress, + boringVault, + accountantAddress, + ]), + }, + ]; - const multicall3 = new Contract( - this.#getMulticall3Address(chainId), - MULTICALL3_ABI, - provider, - ); - const [musdResult, vmusdResult] = (await this.#traceNetworkRequest( - { - name: TRACES.MONEY_ACCOUNT_BALANCE_RPC, - data: { chainId, operation: 'aggregate3' }, - }, - async () => - await multicall3.callStatic.aggregate3( - calls, - PENDING_READ_OVERRIDES, - ), - )) as [Multicall3Result, Multicall3Result]; - - const musdBalanceBN = erc20.interface.decodeFunctionResult( - 'balanceOf', - musdResult.returnData, - )[0]; - const vmusdBN = lens.interface.decodeFunctionResult( - 'balanceOfInAssets', - vmusdResult.returnData, - )[0]; - - return { - musdBalance: musdBalanceBN.toString(), - vmusdValueInMusd: vmusdBN.toString(), - totalBalance: musdBalanceBN.add(vmusdBN).toString(), - }; + const multicall3 = new Contract( + this.#getMulticall3Address(chainId), + MULTICALL3_ABI, + provider, + ); + const [musdResult, vmusdResult] = (await this.#traceNetworkRequest( + { + name: TRACES.MONEY_ACCOUNT_BALANCE_RPC, + data: { chainId, operation: 'aggregate3' }, }, + async () => + await multicall3.callStatic.aggregate3(calls, PENDING_READ_OVERRIDES), + )) as [Multicall3Result, Multicall3Result]; + + const musdBalanceBN = erc20.interface.decodeFunctionResult( + 'balanceOf', + musdResult.returnData, + )[0]; + const vmusdBN = lens.interface.decodeFunctionResult( + 'balanceOfInAssets', + vmusdResult.returnData, + )[0]; + + return { + musdBalance: musdBalanceBN.toString(), + vmusdValueInMusd: vmusdBN.toString(), + totalBalance: musdBalanceBN.add(vmusdBN).toString(), + }; + } + + /** + * Fetches the account's total Money balance inputs in a single batched RPC + * request via Multicall3's `aggregate3`. + * + * Kept for backwards compatibility with existing consumers; new callers + * should prefer {@link getBalance}, which orchestrates across sources. + * + * @param accountAddress - The Money account's Ethereum address. + * @returns The mUSD balance and the mUSD-equivalent value of vault shares as + * raw uint256 strings. The total balance is the sum of the mUSD balance and the mUSD-equivalent value of vault shares. + * @throws {@link VaultConfigNotAvailableError} if vault config has not been loaded. + */ + async getMoneyAccountBalance( + accountAddress: Hex, + ): Promise { + return this.fetchQuery({ + queryKey: [`${this.name}:getMoneyAccountBalance`, accountAddress], + queryFn: async () => this.#fetchBalanceViaRpc(accountAddress), staleTime: this.#balanceStaleTime, }); } diff --git a/packages/money-account-balance-service/src/structs.ts b/packages/money-account-balance-service/src/structs.ts index 1c3f55031b9..8657366afe2 100644 --- a/packages/money-account-balance-service/src/structs.ts +++ b/packages/money-account-balance-service/src/structs.ts @@ -1,5 +1,6 @@ import { array, + enums, number, optional, record, @@ -8,6 +9,37 @@ import { } from '@metamask/superstruct'; import { StrictHexStruct } from '@metamask/utils'; +/** + * Superstruct schema for a single {@link BalanceSource}. + */ +export const BalanceSourceStruct = enums(['rpc', 'api'] as const); + +/** + * Superstruct schema for the balance-source orchestration config feature flag. + * + * Uses `type()` (loose validation) and optional fields so partial configs and + * future additions do not break validation. Missing fields are filled from + * {@link DEFAULT_BALANCE_SOURCE_CONFIG} by the service. + */ +export const BalanceSourceConfigStruct = type({ + enabledSources: optional(array(BalanceSourceStruct)), + preferredSource: optional(BalanceSourceStruct), + maxAttempts: optional(number()), +}); + +/** + * Superstruct schema for the `balance` object returned by the Money Account + * API positions endpoint. Values are raw uint256 strings in the smallest mUSD + * unit (6 decimals), matching {@link MoneyAccountBalanceResponse}. + */ +export const MoneyApiBalanceStruct = type({ + /* eslint-disable @typescript-eslint/naming-convention */ + musd_balance: string(), + total_balance: string(), + vmusd_value_in_musd: string(), + /* eslint-enable @typescript-eslint/naming-convention */ +}); + /** * Superstruct schema for {@link VaultConfig}. * diff --git a/packages/money-account-balance-service/src/types.ts b/packages/money-account-balance-service/src/types.ts index a47087bf541..4c7a15ec4ef 100644 --- a/packages/money-account-balance-service/src/types.ts +++ b/packages/money-account-balance-service/src/types.ts @@ -21,3 +21,30 @@ export type VaultConfig = { */ underlyingToken?: Hex; }; + +/** + * A balance data source the orchestration layer can fetch from. + * + * - `'rpc'`: on-chain Multicall3 reads via the wallet's RPC provider + * (this service's {@link MoneyAccountBalanceService.getMoneyAccountBalance}). + * - `'api'`: the indexed Money Account API, reached via + * `MoneyAccountApiDataService:fetchPositions`. + */ +export type BalanceSource = 'rpc' | 'api'; + +/** + * Orchestration config for {@link MoneyAccountBalanceService.getBalance}, read + * from the remote feature flag and validated by `BalanceSourceConfigStruct`. + * + * The orchestrator tries `preferredSource` first, then the remaining + * `enabledSources` in order, stopping at the first success (bounded by + * `maxAttempts`). This keeps source selection and fallback out of clients. + */ +export type BalanceSourceConfig = { + /** Sources the orchestrator is allowed to use. */ + enabledSources: BalanceSource[]; + /** Source tried first; must also appear in `enabledSources` to be used. */ + preferredSource: BalanceSource; + /** Maximum number of source attempts before giving up. */ + maxAttempts: number; +}; diff --git a/packages/money-account-balance-service/tsconfig.build.json b/packages/money-account-balance-service/tsconfig.build.json index 6b76ddf531e..fd6acd41017 100644 --- a/packages/money-account-balance-service/tsconfig.build.json +++ b/packages/money-account-balance-service/tsconfig.build.json @@ -9,6 +9,7 @@ { "path": "../base-data-service/tsconfig.build.json" }, { "path": "../controller-utils/tsconfig.build.json" }, { "path": "../messenger/tsconfig.build.json" }, + { "path": "../money-account-api-data-service/tsconfig.build.json" }, { "path": "../network-controller/tsconfig.build.json" }, { "path": "../remote-feature-flag-controller/tsconfig.build.json" } ], diff --git a/packages/money-account-balance-service/tsconfig.json b/packages/money-account-balance-service/tsconfig.json index e55b9f62c43..af55fc4762b 100644 --- a/packages/money-account-balance-service/tsconfig.json +++ b/packages/money-account-balance-service/tsconfig.json @@ -7,6 +7,7 @@ { "path": "../base-data-service" }, { "path": "../controller-utils" }, { "path": "../messenger" }, + { "path": "../money-account-api-data-service" }, { "path": "../network-controller" }, { "path": "../remote-feature-flag-controller" } ], From 5060511f275bfc57575b2a60865969da33d2d768 Mon Sep 17 00:00:00 2001 From: Shane T Date: Thu, 16 Jul 2026 12:20:22 +0100 Subject: [PATCH 2/2] docs: update dependency graph for money-account-api-data-service edge --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index b8b3a2d8ca8..4e09842ad7f 100644 --- a/README.md +++ b/README.md @@ -430,6 +430,7 @@ linkStyle default opacity:0.5 money_account_balance_service --> base_data_service; money_account_balance_service --> controller_utils; money_account_balance_service --> messenger; + money_account_balance_service --> money_account_api_data_service; money_account_balance_service --> network_controller; money_account_balance_service --> remote_feature_flag_controller; money_account_controller --> accounts_controller;