From 43433214b6bfd07dfe87fb8226c91889c7e8bafd Mon Sep 17 00:00:00 2001 From: ffmcgee Date: Mon, 20 Jul 2026 13:04:23 +0200 Subject: [PATCH 1/4] feat(money): centralize money account balance fetching (poc) --- .../CHANGELOG.md | 4 + .../src/index.ts | 1 + .../money-account-api-data-service.test.ts | 53 +++ .../src/money-account-api-data-service.ts | 3 +- .../src/response.types.ts | 10 + .../src/structs.ts | 16 +- .../CHANGELOG.md | 6 + .../package.json | 1 + .../src/constants.ts | 36 ++ .../src/errors.ts | 38 ++ .../src/index.ts | 11 + ...unt-balance-service-method-action-types.ts | 19 + .../src/money-account-balance-service.test.ts | 384 ++++++++++++++++++ .../src/money-account-balance-service.ts | 273 ++++++++++++- .../src/response.types.ts | 10 + .../tsconfig.build.json | 1 + .../tsconfig.json | 1 + yarn.lock | 3 +- 18 files changed, 863 insertions(+), 7 deletions(-) diff --git a/packages/money-account-api-data-service/CHANGELOG.md b/packages/money-account-api-data-service/CHANGELOG.md index 21a36bc23bc..0a74ade6c5f 100644 --- a/packages/money-account-api-data-service/CHANGELOG.md +++ b/packages/money-account-api-data-service/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Add optional nullable `balance` field to the positions response (`musd_balance`, `vmusd_value_in_musd`, `total_balance`), matching the Money Account API contract. Export `PositionBalance` type. + ## [0.1.0] ### Added diff --git a/packages/money-account-api-data-service/src/index.ts b/packages/money-account-api-data-service/src/index.ts index 780facd0ac0..a83c15380f0 100644 --- a/packages/money-account-api-data-service/src/index.ts +++ b/packages/money-account-api-data-service/src/index.ts @@ -12,6 +12,7 @@ export type { } from './money-account-api-data-service-method-action-types'; export type { PositionResponse, + PositionBalance, InterestResponse, HistoryResponse, RateHistoryResponse, diff --git a/packages/money-account-api-data-service/src/money-account-api-data-service.test.ts b/packages/money-account-api-data-service/src/money-account-api-data-service.test.ts index f396130de6f..b8d8c8098ce 100644 --- a/packages/money-account-api-data-service/src/money-account-api-data-service.test.ts +++ b/packages/money-account-api-data-service/src/money-account-api-data-service.test.ts @@ -22,12 +22,19 @@ import { const MOCK_ADDRESS = '0x1111111111111111111111111111111111111111'; const MOCK_VAULT_ADDRESS = '0x2222222222222222222222222222222222222222'; +const MOCK_POSITION_BALANCE = { + musd_balance: '2', + vmusd_value_in_musd: '1513527', + total_balance: '1513529', +}; + const MOCK_POSITION_RESPONSE = { address: MOCK_ADDRESS, as_of_block: 12345, as_of_timestamp: '2026-06-01T12:00:00Z', data_freshness: 'live' as const, indexer_lag_seconds: 5, + balance: MOCK_POSITION_BALANCE, positions: [ { vault_address: MOCK_VAULT_ADDRESS, @@ -232,6 +239,52 @@ describe('MoneyAccountApiDataService', () => { service.destroy(); }); + it('accepts a null balance when the API balance path is unavailable', async () => { + const { service } = createService(Env.DEV); + const responseWithNullBalance = { + ...MOCK_POSITION_RESPONSE, + balance: null, + }; + + nock(MONEY_ACCOUNT_API_URL_MAP[Env.DEV]) + .get(`/v1/positions/${MOCK_ADDRESS}`) + .reply(200, responseWithNullBalance); + + const result = await service.fetchPositions(MOCK_ADDRESS); + expect(result).toStrictEqual(responseWithNullBalance); + service.destroy(); + }); + + it('accepts a response that omits the balance field', async () => { + const { service } = createService(Env.DEV); + const { balance: _balance, ...responseWithoutBalance } = + MOCK_POSITION_RESPONSE; + + nock(MONEY_ACCOUNT_API_URL_MAP[Env.DEV]) + .get(`/v1/positions/${MOCK_ADDRESS}`) + .reply(200, responseWithoutBalance); + + const result = await service.fetchPositions(MOCK_ADDRESS); + expect(result).toStrictEqual(responseWithoutBalance); + service.destroy(); + }); + + it('throws MoneyAccountApiResponseValidationError on malformed balance', async () => { + const { service } = createService(Env.DEV); + + nock(MONEY_ACCOUNT_API_URL_MAP[Env.DEV]) + .get(`/v1/positions/${MOCK_ADDRESS}`) + .reply(200, { + ...MOCK_POSITION_RESPONSE, + balance: { musd_balance: '1' }, + }); + + await expect(service.fetchPositions(MOCK_ADDRESS)).rejects.toThrow( + MoneyAccountApiResponseValidationError, + ); + service.destroy(); + }); + it('caches responses via TanStack Query', async () => { const { service } = createService(Env.DEV); diff --git a/packages/money-account-api-data-service/src/money-account-api-data-service.ts b/packages/money-account-api-data-service/src/money-account-api-data-service.ts index 2f216249d06..cab2156ed71 100644 --- a/packages/money-account-api-data-service/src/money-account-api-data-service.ts +++ b/packages/money-account-api-data-service/src/money-account-api-data-service.ts @@ -170,7 +170,8 @@ export class MoneyAccountApiDataService extends BaseDataService< * Fetches the current vault positions for a given user address. * * @param address - The user's Ethereum address. - * @returns The position response containing vault positions. + * @returns The position response containing vault positions and an optional + * `balance` summary (`null` when the API balance path is unavailable). */ async fetchPositions(address: string): Promise { const url = new URL( diff --git a/packages/money-account-api-data-service/src/response.types.ts b/packages/money-account-api-data-service/src/response.types.ts index ff779e24b2b..73bfe74fd7e 100644 --- a/packages/money-account-api-data-service/src/response.types.ts +++ b/packages/money-account-api-data-service/src/response.types.ts @@ -34,6 +34,16 @@ export type VaultPosition = { effective_apy: string; }; +/** + * Balance summary on the positions response. + * `null` when the API's wallet-balance path is disabled or unavailable. + */ +export type PositionBalance = { + musd_balance: string; + vmusd_value_in_musd: string; + total_balance: string; +}; + /** * Response from `GET /v1/positions/:address`. * Derived from {@link PositionResponseStruct} to ensure type/struct parity. diff --git a/packages/money-account-api-data-service/src/structs.ts b/packages/money-account-api-data-service/src/structs.ts index ae08ec3dae9..1041327c745 100644 --- a/packages/money-account-api-data-service/src/structs.ts +++ b/packages/money-account-api-data-service/src/structs.ts @@ -2,10 +2,11 @@ import { array, boolean, enums, + nullable, number, object, + optional, string, - nullable, } from '@metamask/superstruct'; const DataFreshnessStruct = enums(['live', 'degraded']); @@ -25,12 +26,25 @@ const VaultPositionStruct = object({ effective_apy: string(), }); +/** + * Wallet + vault balance summary on the positions response. + * `null` when the API's wallet-balance path is disabled or unavailable. + */ +const PositionBalanceStruct = object({ + musd_balance: string(), + vmusd_value_in_musd: string(), + total_balance: string(), +}); + export const PositionResponseStruct = object({ address: string(), as_of_block: number(), as_of_timestamp: string(), data_freshness: DataFreshnessStruct, indexer_lag_seconds: number(), + // Optional for backwards compatibility with responses that omit the field; + // when present, may be `null` if the API balance flag is off. + balance: optional(nullable(PositionBalanceStruct)), positions: array(VaultPositionStruct), }); diff --git a/packages/money-account-balance-service/CHANGELOG.md b/packages/money-account-balance-service/CHANGELOG.md index cdfc4034fc1..83167197c20 100644 --- a/packages/money-account-balance-service/CHANGELOG.md +++ b/packages/money-account-balance-service/CHANGELOG.md @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Add `fetchBalanceWithFallback` facade that selects Money API or RPC balance sources from the `moneyAccountBalanceSource` remote feature flag (`api` | `rpc` | `api-only` | `rpc-only`; default `api` = API primary with RPC fallback). Returns canonical amounts plus `source` and `usedFallback` provenance; throws `MoneyAccountBalanceFetchError` when all eligible sources fail. +- Permit `MoneyAccountApiDataService:fetchPositions` on the balance service messenger so the facade can read Money API balances. +- Export `CanonicalMoneyAccountBalanceResponse`, balance-source constants/types, and `MoneyAccountBalanceFetchError` / `MoneyAccountBalanceUnavailableError` / `MoneyAccountBalanceValidationError`. + ## [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..a11d33f1552 100644 --- a/packages/money-account-balance-service/src/constants.ts +++ b/packages/money-account-balance-service/src/constants.ts @@ -23,6 +23,42 @@ export const MONEY_ACCOUNT_BALANCE_STALETIME_FEATURE_FLAG_KEY = */ export const DEFAULT_BALANCE_STALE_TIME = inMilliseconds(1, Duration.Minute); +/** + * The key under which the Money account balance source routing policy is + * stored in `RemoteFeatureFlagController` state's `remoteFeatureFlags` map. + * Falls back to {@link DEFAULT_BALANCE_SOURCE_POLICY} when absent or malformed. + */ +export const MONEY_ACCOUNT_BALANCE_SOURCE_FEATURE_FLAG_KEY = + 'moneyAccountBalanceSource'; + +/** + * Supported values for {@link MONEY_ACCOUNT_BALANCE_SOURCE_FEATURE_FLAG_KEY}. + * + * - `api` — Money API primary, RPC fallback + * - `rpc` — RPC primary, Money API fallback + * - `api-only` — Money API only (incident kill switch) + * - `rpc-only` — RPC only (incident kill switch) + */ +export const BALANCE_SOURCE_POLICIES = [ + 'api', + 'rpc', + 'api-only', + 'rpc-only', +] as const; + +export type BalanceSourcePolicy = (typeof BALANCE_SOURCE_POLICIES)[number]; + +/** + * Hard default when the routing flag is absent or malformed: Money API + * primary with RPC fallback. + */ +export const DEFAULT_BALANCE_SOURCE_POLICY: BalanceSourcePolicy = 'api'; + +/** + * Balance source used in the canonical facade result. + */ +export type BalanceSource = 'api' | 'rpc'; + 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..a8a4d3c76b2 100644 --- a/packages/money-account-balance-service/src/errors.ts +++ b/packages/money-account-balance-service/src/errors.ts @@ -5,6 +5,44 @@ export class VedaResponseValidationError extends Error { } } +/** + * Thrown when a balance source returns data that fails semantic validation + * (e.g. non-integer amounts, or `totalBalance !== musdBalance + vmusdValueInMusd`). + */ +export class MoneyAccountBalanceValidationError extends Error { + constructor(message: string) { + super(message); + this.name = 'MoneyAccountBalanceValidationError'; + } +} + +/** + * Thrown when a balance source is transport-successful but has no usable + * balance (e.g. Money API `balance: null`). + */ +export class MoneyAccountBalanceUnavailableError extends Error { + constructor(message: string) { + super(message); + this.name = 'MoneyAccountBalanceUnavailableError'; + } +} + +/** + * Thrown when every eligible balance source fails. Preserves each cause for + * diagnostics; never substitutes a zero balance. + */ +export class MoneyAccountBalanceFetchError extends Error { + readonly causes: unknown[]; + + constructor(causes: unknown[]) { + super( + 'MoneyAccountBalanceService: failed to fetch balance from all eligible sources', + ); + this.name = 'MoneyAccountBalanceFetchError'; + this.causes = causes; + } +} + /** * Thrown when a public method is called but vault config has not yet been * loaded from RemoteFeatureFlagController, or the flag key is absent. diff --git a/packages/money-account-balance-service/src/index.ts b/packages/money-account-balance-service/src/index.ts index 1b25bbd7738..25d31101b95 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 { + MoneyAccountBalanceServiceFetchBalanceWithFallbackAction, MoneyAccountBalanceServiceGetMoneyAccountBalanceAction, MoneyAccountBalanceServiceGetMusdBalanceAction, MoneyAccountBalanceServiceGetVmusdBalanceAction, @@ -16,12 +17,22 @@ export type { MoneyAccountBalanceServiceGetVaultApyAction, } from './money-account-balance-service-method-action-types'; export type { + CanonicalMoneyAccountBalanceResponse, ExchangeRateResponse, MoneyAccountBalanceResponse, MusdEquivalentValueResponse, NormalizedVaultApyResponse, } from './response.types'; +export type { BalanceSource, BalanceSourcePolicy } from './constants'; export { + BALANCE_SOURCE_POLICIES, + DEFAULT_BALANCE_SOURCE_POLICY, + MONEY_ACCOUNT_BALANCE_SOURCE_FEATURE_FLAG_KEY, +} from './constants'; +export { + MoneyAccountBalanceFetchError, + MoneyAccountBalanceUnavailableError, + MoneyAccountBalanceValidationError, VaultConfigNotAvailableError, VaultConfigValidationError, } from './errors'; 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..84f9466b486 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 @@ -5,6 +5,24 @@ import type { MoneyAccountBalanceService } from './money-account-balance-service'; +/** + * Fetches the canonical Money account balance, selecting the Money API or + * RPC source according to the `moneyAccountBalanceSource` remote feature + * flag (default: API primary with RPC fallback). + * + * Callers must not select a source. Provenance is returned on the result so + * fallback is never silent. + * + * @param accountAddress - The Money account's Ethereum address. + * @returns Canonical balance amounts with source provenance. + * @throws {@link MoneyAccountBalanceFetchError} when every eligible source + * fails. Never returns a synthetic zero balance. + */ +export type MoneyAccountBalanceServiceFetchBalanceWithFallbackAction = { + type: `MoneyAccountBalanceService:fetchBalanceWithFallback`; + handler: MoneyAccountBalanceService['fetchBalanceWithFallback']; +}; + /** * Fetches the mUSD ERC-20 balance for the given account address via RPC. * @@ -87,6 +105,7 @@ export type MoneyAccountBalanceServiceGetVaultApyAction = { * Union of all MoneyAccountBalanceService action types. */ export type MoneyAccountBalanceServiceMethodActions = + | MoneyAccountBalanceServiceFetchBalanceWithFallbackAction | 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..c775bc61358 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,11 +18,15 @@ 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, } from './constants'; import { + MoneyAccountBalanceFetchError, + MoneyAccountBalanceUnavailableError, + MoneyAccountBalanceValidationError, VaultConfigNotAvailableError, VaultConfigValidationError, VedaResponseValidationError, @@ -200,6 +204,7 @@ function publishRFFCStateChange( * @param args.callInit - Whether to call `service.init()` after construction. Defaults to true. * @param args.captureException - Error reporter wired on the root messenger. * @param args.options - Partial constructor options for the service. + * @param args.mockFetchPositions - Stub for `MoneyAccountApiDataService:fetchPositions`. * @returns The constructed service together with messenger instances and mock stubs. */ function createService({ @@ -207,6 +212,7 @@ function createService({ callInit = true, captureException = jest.fn(), options = {}, + mockFetchPositions = jest.fn(), }: { rffcFlags?: Record; callInit?: boolean; @@ -214,6 +220,7 @@ function createService({ options?: Partial< ConstructorParameters[0] >; + mockFetchPositions?: jest.Mock; } = {}): { service: MoneyAccountBalanceService; rootMessenger: RootMessenger; @@ -221,6 +228,7 @@ function createService({ mockGetNetworkConfig: jest.Mock; mockGetNetworkClient: jest.Mock; mockGetRFFCState: jest.Mock; + mockFetchPositions: jest.Mock; captureException: jest.Mock; } { const rootMessenger = createRootMessenger(captureException); @@ -246,12 +254,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 +284,7 @@ function createService({ mockGetNetworkConfig, mockGetNetworkClient, mockGetRFFCState, + mockFetchPositions, captureException, }; } @@ -1605,6 +1619,351 @@ describe('MoneyAccountBalanceService', () => { }); }); + // ---------------------------------------------------------- + // fetchBalanceWithFallback + // ---------------------------------------------------------- + + describe('fetchBalanceWithFallback', () => { + const MOCK_API_BALANCE = { + musd_balance: '2', + vmusd_value_in_musd: '1513527', + total_balance: '1513529', + }; + + const MOCK_API_POSITIONS = { + address: MOCK_ACCOUNT_ADDRESS, + as_of_block: 88976660, + as_of_timestamp: '2026-07-20T10:49:51Z', + data_freshness: 'live' as const, + indexer_lag_seconds: 3, + balance: MOCK_API_BALANCE, + positions: [], + }; + + it('returns API balance by default (API primary) without using fallback', async () => { + const mockFetchPositions = jest.fn().mockResolvedValue(MOCK_API_POSITIONS); + const { service } = createService({ mockFetchPositions }); + + const result = await service.fetchBalanceWithFallback( + MOCK_ACCOUNT_ADDRESS, + ); + + expect(result).toStrictEqual({ + musdBalance: '2', + vmusdValueInMusd: '1513527', + totalBalance: '1513529', + source: 'api', + usedFallback: false, + }); + expect(mockFetchPositions).toHaveBeenCalledWith(MOCK_ACCOUNT_ADDRESS); + }); + + it('falls back to RPC when API balance is null', async () => { + mockMoneyAccountBalanceMulticall({ + musdBalance: '5000000', + vmusdValueInMusd: '2200000', + }); + const mockFetchPositions = jest.fn().mockResolvedValue({ + ...MOCK_API_POSITIONS, + balance: null, + }); + const { service } = createService({ + rffcFlags: { + [VAULT_CONFIG_FEATURE_FLAG_KEY]: + MOCK_VAULT_CONFIG_WITH_UNDERLYING_TOKEN, + }, + mockFetchPositions, + }); + + const result = await service.fetchBalanceWithFallback( + MOCK_ACCOUNT_ADDRESS, + ); + + expect(result).toStrictEqual({ + musdBalance: '5000000', + vmusdValueInMusd: '2200000', + totalBalance: '7200000', + source: 'rpc', + usedFallback: true, + }); + }); + + it('falls back to RPC when the API call fails', async () => { + mockMoneyAccountBalanceMulticall({ + musdBalance: '7', + vmusdValueInMusd: '3', + }); + const mockFetchPositions = jest + .fn() + .mockRejectedValue(new Error('network down')); + const { service } = createService({ + rffcFlags: { + [VAULT_CONFIG_FEATURE_FLAG_KEY]: + MOCK_VAULT_CONFIG_WITH_UNDERLYING_TOKEN, + }, + mockFetchPositions, + }); + + const result = await service.fetchBalanceWithFallback( + MOCK_ACCOUNT_ADDRESS, + ); + + expect(result).toStrictEqual({ + musdBalance: '7', + vmusdValueInMusd: '3', + totalBalance: '10', + source: 'rpc', + usedFallback: true, + }); + }); + + it('falls back to RPC when API balance fails semantic validation', async () => { + mockMoneyAccountBalanceMulticall({ + musdBalance: '1', + vmusdValueInMusd: '2', + }); + const mockFetchPositions = jest.fn().mockResolvedValue({ + ...MOCK_API_POSITIONS, + balance: { + musd_balance: '1', + vmusd_value_in_musd: '2', + total_balance: '999', + }, + }); + const { service } = createService({ + rffcFlags: { + [VAULT_CONFIG_FEATURE_FLAG_KEY]: + MOCK_VAULT_CONFIG_WITH_UNDERLYING_TOKEN, + }, + mockFetchPositions, + }); + + const result = await service.fetchBalanceWithFallback( + MOCK_ACCOUNT_ADDRESS, + ); + + expect(result).toStrictEqual({ + musdBalance: '1', + vmusdValueInMusd: '2', + totalBalance: '3', + source: 'rpc', + usedFallback: true, + }); + }); + + it('uses RPC as primary when the flag is set to rpc', async () => { + mockMoneyAccountBalanceMulticall({ + musdBalance: '5000000', + vmusdValueInMusd: '2200000', + }); + const mockFetchPositions = jest.fn(); + const { service } = createService({ + rffcFlags: { + [VAULT_CONFIG_FEATURE_FLAG_KEY]: + MOCK_VAULT_CONFIG_WITH_UNDERLYING_TOKEN, + [MONEY_ACCOUNT_BALANCE_SOURCE_FEATURE_FLAG_KEY]: 'rpc', + }, + mockFetchPositions, + }); + + const result = await service.fetchBalanceWithFallback( + MOCK_ACCOUNT_ADDRESS, + ); + + expect(result).toStrictEqual({ + musdBalance: '5000000', + vmusdValueInMusd: '2200000', + totalBalance: '7200000', + source: 'rpc', + usedFallback: false, + }); + expect(mockFetchPositions).not.toHaveBeenCalled(); + }); + + it('falls back to API when RPC primary fails and the flag is rpc', async () => { + const mockFetchPositions = jest.fn().mockResolvedValue(MOCK_API_POSITIONS); + const { service } = createService({ + rffcFlags: { + [VAULT_CONFIG_FEATURE_FLAG_KEY]: + MOCK_VAULT_CONFIG_WITH_UNDERLYING_TOKEN, + [MONEY_ACCOUNT_BALANCE_SOURCE_FEATURE_FLAG_KEY]: 'rpc', + }, + mockFetchPositions, + }); + + MockContract.mockImplementation( + () => + ({ + callStatic: { + aggregate3: jest + .fn() + .mockRejectedValue(new Error('execution reverted')), + }, + interface: { + encodeFunctionData: jest.fn().mockReturnValue('0x'), + decodeFunctionResult: jest.fn(), + }, + }) as unknown as Contract, + ); + + const result = await service.fetchBalanceWithFallback( + MOCK_ACCOUNT_ADDRESS, + ); + + expect(result).toStrictEqual({ + musdBalance: '2', + vmusdValueInMusd: '1513527', + totalBalance: '1513529', + source: 'api', + usedFallback: true, + }); + }); + + it('does not fall back when the flag is api-only', async () => { + const mockFetchPositions = jest.fn().mockResolvedValue({ + ...MOCK_API_POSITIONS, + balance: null, + }); + const { service } = createService({ + rffcFlags: { + [VAULT_CONFIG_FEATURE_FLAG_KEY]: MOCK_VAULT_CONFIG, + [MONEY_ACCOUNT_BALANCE_SOURCE_FEATURE_FLAG_KEY]: 'api-only', + }, + mockFetchPositions, + }); + + await expect( + service.fetchBalanceWithFallback(MOCK_ACCOUNT_ADDRESS), + ).rejects.toThrow(MoneyAccountBalanceFetchError); + }); + + it('does not fall back when the flag is rpc-only', async () => { + MockContract.mockImplementation( + () => + ({ + callStatic: { + aggregate3: jest + .fn() + .mockRejectedValue(new Error('execution reverted')), + }, + interface: { + encodeFunctionData: jest.fn().mockReturnValue('0x'), + decodeFunctionResult: jest.fn(), + }, + }) as unknown as Contract, + ); + const mockFetchPositions = jest.fn().mockResolvedValue(MOCK_API_POSITIONS); + const { service } = createService({ + rffcFlags: { + [VAULT_CONFIG_FEATURE_FLAG_KEY]: + MOCK_VAULT_CONFIG_WITH_UNDERLYING_TOKEN, + [MONEY_ACCOUNT_BALANCE_SOURCE_FEATURE_FLAG_KEY]: 'rpc-only', + }, + mockFetchPositions, + }); + + await expect( + service.fetchBalanceWithFallback(MOCK_ACCOUNT_ADDRESS), + ).rejects.toThrow(MoneyAccountBalanceFetchError); + expect(mockFetchPositions).not.toHaveBeenCalled(); + }); + + it('throws MoneyAccountBalanceFetchError with both causes when primary and fallback fail', async () => { + MockContract.mockImplementation( + () => + ({ + callStatic: { + aggregate3: jest + .fn() + .mockRejectedValue(new Error('execution reverted')), + }, + interface: { + encodeFunctionData: jest.fn().mockReturnValue('0x'), + decodeFunctionResult: jest.fn(), + }, + }) as unknown as Contract, + ); + const mockFetchPositions = jest + .fn() + .mockRejectedValue(new Error('api down')); + const { service } = createService({ + rffcFlags: { + [VAULT_CONFIG_FEATURE_FLAG_KEY]: + MOCK_VAULT_CONFIG_WITH_UNDERLYING_TOKEN, + }, + mockFetchPositions, + }); + + let thrown: unknown; + try { + await service.fetchBalanceWithFallback(MOCK_ACCOUNT_ADDRESS); + } catch (error) { + thrown = error; + } + + expect(thrown).toBeInstanceOf(MoneyAccountBalanceFetchError); + expect((thrown as MoneyAccountBalanceFetchError).causes).toHaveLength(2); + }); + + it('defaults to api policy when the source flag is malformed', async () => { + const mockFetchPositions = jest.fn().mockResolvedValue(MOCK_API_POSITIONS); + const { service } = createService({ + rffcFlags: { + [VAULT_CONFIG_FEATURE_FLAG_KEY]: MOCK_VAULT_CONFIG, + [MONEY_ACCOUNT_BALANCE_SOURCE_FEATURE_FLAG_KEY]: 'not-a-policy', + }, + mockFetchPositions, + }); + + const result = await service.fetchBalanceWithFallback( + MOCK_ACCOUNT_ADDRESS, + ); + + expect(result.source).toBe('api'); + expect(result.usedFallback).toBe(false); + }); + + it('updates the source policy on RemoteFeatureFlagController:stateChange', async () => { + mockMoneyAccountBalanceMulticall({ + musdBalance: '5000000', + vmusdValueInMusd: '2200000', + }); + const mockFetchPositions = jest.fn().mockResolvedValue(MOCK_API_POSITIONS); + const { service, rootMessenger } = createService({ + rffcFlags: { + [VAULT_CONFIG_FEATURE_FLAG_KEY]: + MOCK_VAULT_CONFIG_WITH_UNDERLYING_TOKEN, + }, + mockFetchPositions, + }); + + publishRFFCStateChange(rootMessenger, { + [VAULT_CONFIG_FEATURE_FLAG_KEY]: MOCK_VAULT_CONFIG_WITH_UNDERLYING_TOKEN, + [MONEY_ACCOUNT_BALANCE_SOURCE_FEATURE_FLAG_KEY]: 'rpc-only', + }); + + const result = await service.fetchBalanceWithFallback( + MOCK_ACCOUNT_ADDRESS, + ); + + expect(result.source).toBe('rpc'); + expect(mockFetchPositions).not.toHaveBeenCalled(); + }); + + it('is callable via messenger action', async () => { + const mockFetchPositions = jest.fn().mockResolvedValue(MOCK_API_POSITIONS); + const { rootMessenger, service } = createService({ mockFetchPositions }); + + const result = await rootMessenger.call( + 'MoneyAccountBalanceService:fetchBalanceWithFallback', + MOCK_ACCOUNT_ADDRESS, + ); + + expect(result.source).toBe('api'); + service.destroy(); + }); + }); + // ---------------------------------------------------------- // getVaultApy // ---------------------------------------------------------- @@ -1916,6 +2275,31 @@ describe('VaultConfigNotAvailableError', () => { }); }); +describe('MoneyAccountBalanceUnavailableError', () => { + it('has the expected name', () => { + const error = new MoneyAccountBalanceUnavailableError('missing'); + expect(error.message).toBe('missing'); + expect(error.name).toBe('MoneyAccountBalanceUnavailableError'); + }); +}); + +describe('MoneyAccountBalanceValidationError', () => { + it('has the expected name', () => { + const error = new MoneyAccountBalanceValidationError('bad total'); + expect(error.message).toBe('bad total'); + expect(error.name).toBe('MoneyAccountBalanceValidationError'); + }); +}); + +describe('MoneyAccountBalanceFetchError', () => { + it('preserves causes', () => { + const causes = [new Error('a'), new Error('b')]; + const error = new MoneyAccountBalanceFetchError(causes); + expect(error.name).toBe('MoneyAccountBalanceFetchError'); + expect(error.causes).toBe(causes); + }); +}); + describe('VaultConfigValidationError', () => { it('uses the default message when constructed with no argument', () => { const error = new VaultConfigValidationError(); 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..224a828b40e 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 @@ -14,6 +14,10 @@ import type { import { handleWhen, HttpError } from '@metamask/controller-utils'; import type { Messenger } from '@metamask/messenger'; import { abiERC20 } from '@metamask/metamask-eth-abis'; +import type { + MoneyAccountApiDataServiceFetchPositionsAction, + PositionResponse, +} from '@metamask/money-account-api-data-service'; import type { NetworkControllerGetNetworkClientByIdAction, NetworkControllerGetNetworkConfigurationByChainIdAction, @@ -28,8 +32,11 @@ import { Duration, inMilliseconds } from '@metamask/utils'; import { ACCOUNTANT_ABI, + BALANCE_SOURCE_POLICIES, + DEFAULT_BALANCE_SOURCE_POLICY, 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, @@ -37,7 +44,11 @@ import { VEDA_API_NETWORK_NAMES, VEDA_PERFORMANCE_API_BASE_URL, } from './constants'; +import type { BalanceSource, BalanceSourcePolicy } from './constants'; import { + MoneyAccountBalanceFetchError, + MoneyAccountBalanceUnavailableError, + MoneyAccountBalanceValidationError, VaultConfigNotAvailableError, VaultConfigValidationError, VedaResponseValidationError, @@ -46,6 +57,7 @@ import { projectLogger, createModuleLogger } from './logger'; import type { MoneyAccountBalanceServiceMethodActions } from './money-account-balance-service-method-action-types'; import { normalizeVaultApyResponse } from './requestNormalization'; import type { + CanonicalMoneyAccountBalanceResponse, ExchangeRateResponse, MoneyAccountBalanceResponse, MusdEquivalentValueResponse, @@ -105,10 +117,74 @@ export type MoneyAccountBalanceServiceTraceCallback = ( const configLogger = createModuleLogger(projectLogger, 'config'); const traceLogger = createModuleLogger(projectLogger, 'trace'); +const balanceLogger = createModuleLogger(projectLogger, 'balance'); + +const NON_NEGATIVE_INTEGER_STRING_PATTERN = /^\d+$/u; + +/** + * Validates that balance amounts are non-negative integer strings and that + * `totalBalance === musdBalance + vmusdValueInMusd`. + * + * @param balance - Balance amounts to validate. + * @throws {@link MoneyAccountBalanceValidationError} when validation fails. + */ +function assertValidBalanceAmounts( + balance: MoneyAccountBalanceResponse, +): void { + const entries: [keyof MoneyAccountBalanceResponse, string][] = [ + ['musdBalance', balance.musdBalance], + ['vmusdValueInMusd', balance.vmusdValueInMusd], + ['totalBalance', balance.totalBalance], + ]; + + for (const [field, value] of entries) { + if (!NON_NEGATIVE_INTEGER_STRING_PATTERN.test(value)) { + throw new MoneyAccountBalanceValidationError( + `Invalid ${field}: expected a non-negative integer string, got '${value}'`, + ); + } + } + + if ( + BigInt(balance.musdBalance) + BigInt(balance.vmusdValueInMusd) !== + BigInt(balance.totalBalance) + ) { + throw new MoneyAccountBalanceValidationError( + `Invalid balance invariant: totalBalance (${balance.totalBalance}) must equal musdBalance (${balance.musdBalance}) + vmusdValueInMusd (${balance.vmusdValueInMusd})`, + ); + } +} + +/** + * Resolves primary and optional fallback sources from a routing policy. + * + * @param policy - The active balance source policy. + * @returns Primary source and optional fallback. + */ +function resolveBalanceRouting(policy: BalanceSourcePolicy): { + primary: BalanceSource; + fallback: BalanceSource | null; +} { + switch (policy) { + case 'api': + return { primary: 'api', fallback: 'rpc' }; + case 'rpc': + return { primary: 'rpc', fallback: 'api' }; + case 'api-only': + return { primary: 'api', fallback: null }; + case 'rpc-only': + return { primary: 'rpc', fallback: null }; + default: { + const _exhaustive: never = policy; + return _exhaustive; + } + } +} // === MESSENGER === const MESSENGER_EXPOSED_METHODS = [ + 'fetchBalanceWithFallback', 'getMoneyAccountBalance', 'getMusdBalance', 'getVmusdBalance', @@ -136,7 +212,8 @@ export type MoneyAccountBalanceServiceActions = type AllowedActions = | NetworkControllerGetNetworkConfigurationByChainIdAction | NetworkControllerGetNetworkClientByIdAction - | RemoteFeatureFlagControllerGetStateAction; + | RemoteFeatureFlagControllerGetStateAction + | MoneyAccountApiDataServiceFetchPositionsAction; /** * Published when {@link MoneyAccountBalanceService}'s cache is updated. @@ -178,8 +255,14 @@ export type MoneyAccountBalanceServiceMessenger = Messenger< /** * Data service responsible for fetching Money account balances (mUSD and - * vmUSD) via on-chain RPC reads, the Veda Accountant exchange rate, and - * the Veda vault APY from the Seven Seas REST API. + * vmUSD). Prefer {@link MoneyAccountBalanceService.fetchBalanceWithFallback} + * for presentation — it selects between the Money API and Multicall3 RPC + * sources via the `moneyAccountBalanceSource` remote feature flag. + * + * Lower-level methods remain available for diagnostics and source-specific + * use cases: on-chain RPC reads (`getMoneyAccountBalance`, etc.), 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 @@ -197,7 +280,7 @@ export type MoneyAccountBalanceServiceMessenger = Messenger< * messenger: moneyAccountBalanceServiceMessenger, * }); * - * const { balance } = await service.getMusdBalance('0xYourMoneyAccount...'); + * const balance = await service.fetchBalanceWithFallback('0xYourMoneyAccount...'); * ``` */ @@ -216,6 +299,12 @@ 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; + /** + * Preferred balance source routing policy. Overridable via remote feature + * flag; defaults to Money API primary with RPC fallback. + */ + #balanceSourcePolicy: BalanceSourcePolicy = DEFAULT_BALANCE_SOURCE_POLICY; + readonly #trace: MoneyAccountBalanceServiceTraceCallback; /** @@ -400,9 +489,47 @@ export class MoneyAccountBalanceService extends BaseDataService< this.#applyBalanceStaleTimeFlag( remoteFeatureFlags[MONEY_ACCOUNT_BALANCE_STALETIME_FEATURE_FLAG_KEY], ); + this.#applyBalanceSourcePolicyFlag( + remoteFeatureFlags[MONEY_ACCOUNT_BALANCE_SOURCE_FEATURE_FLAG_KEY], + ); this.#applyVaultConfig(remoteFeatureFlags[VAULT_CONFIG_FEATURE_FLAG_KEY]); } + /** + * Applies the balance source routing feature flag, falling back to + * {@link DEFAULT_BALANCE_SOURCE_POLICY} when the flag is absent or malformed. + * + * @param flagValue - Raw flag value from `remoteFeatureFlags`. + */ + #applyBalanceSourcePolicyFlag(flagValue: Json | undefined): void { + let nextPolicy = DEFAULT_BALANCE_SOURCE_POLICY; + + if (flagValue !== undefined) { + if ( + typeof flagValue === 'string' && + (BALANCE_SOURCE_POLICIES as readonly string[]).includes(flagValue) + ) { + nextPolicy = flagValue as BalanceSourcePolicy; + } else { + configLogger( + 'Invalid balance source policy flag value; using default', + { + flagValue, + default: DEFAULT_BALANCE_SOURCE_POLICY, + }, + ); + } + } + + if (nextPolicy !== this.#balanceSourcePolicy) { + configLogger('Balance source policy updated', { + previous: this.#balanceSourcePolicy, + next: nextPolicy, + }); + this.#balanceSourcePolicy = nextPolicy; + } + } + /** * Validates the vault config feature flag value, updates `#vaultConfig`, and * invalidates all cached queries when the config changes. @@ -609,6 +736,144 @@ export class MoneyAccountBalanceService extends BaseDataService< return multicall3Address; } + /** + * Fetches the canonical Money account balance, selecting the Money API or + * RPC source according to the `moneyAccountBalanceSource` remote feature + * flag (default: API primary with RPC fallback). + * + * Callers must not select a source. Provenance is returned on the result so + * fallback is never silent. + * + * @param accountAddress - The Money account's Ethereum address. + * @returns Canonical balance amounts with source provenance. + * @throws {@link MoneyAccountBalanceFetchError} when every eligible source + * fails. Never returns a synthetic zero balance. + */ + async fetchBalanceWithFallback( + accountAddress: Hex, + ): Promise { + const { primary, fallback } = resolveBalanceRouting( + this.#balanceSourcePolicy, + ); + const errors: unknown[] = []; + + try { + return await this.#fetchBalanceFromSource( + accountAddress, + primary, + false, + ); + } catch (primaryError) { + errors.push(primaryError); + balanceLogger('Primary balance source failed', { + primary, + fallback, + primaryError, + }); + if (fallback === null) { + throw new MoneyAccountBalanceFetchError(errors); + } + } + + try { + return await this.#fetchBalanceFromSource( + accountAddress, + fallback, + true, + ); + } catch (fallbackError) { + errors.push(fallbackError); + balanceLogger('Fallback balance source failed', { + primary, + fallback, + fallbackError, + }); + throw new MoneyAccountBalanceFetchError(errors); + } + } + + /** + * Fetches and validates balance from a single source. + * + * @param accountAddress - The Money account's Ethereum address. + * @param source - Balance source to query. + * @param usedFallback - Whether this attempt is a fallback after primary failure. + * @returns Canonical balance result for the source. + */ + async #fetchBalanceFromSource( + accountAddress: Hex, + source: BalanceSource, + usedFallback: boolean, + ): Promise { + if (source === 'api') { + return await this.#fetchBalanceFromApi(accountAddress, usedFallback); + } + return await this.#fetchBalanceFromRpc(accountAddress, usedFallback); + } + + /** + * Reads balance from MoneyAccountApiDataService positions and maps it to + * the canonical result. + * + * @param accountAddress - The Money account's Ethereum address. + * @param usedFallback - Whether this attempt is a fallback. + * @returns Canonical balance from the Money API. + * @throws {@link MoneyAccountBalanceUnavailableError} when `balance` is null + * or absent. + * @throws {@link MoneyAccountBalanceValidationError} when amounts fail + * semantic validation. + */ + async #fetchBalanceFromApi( + accountAddress: Hex, + usedFallback: boolean, + ): Promise { + const positions: PositionResponse = await this.messenger.call( + 'MoneyAccountApiDataService:fetchPositions', + accountAddress, + ); + + if (positions.balance == null) { + throw new MoneyAccountBalanceUnavailableError( + 'Money API returned a null or missing balance', + ); + } + + const amounts: MoneyAccountBalanceResponse = { + musdBalance: positions.balance.musd_balance, + vmusdValueInMusd: positions.balance.vmusd_value_in_musd, + totalBalance: positions.balance.total_balance, + }; + assertValidBalanceAmounts(amounts); + + return { + ...amounts, + source: 'api', + usedFallback, + }; + } + + /** + * Reads balance via the existing Multicall3 RPC path and maps it to the + * canonical result. + * + * @param accountAddress - The Money account's Ethereum address. + * @param usedFallback - Whether this attempt is a fallback. + * @returns Canonical balance from RPC. + */ + async #fetchBalanceFromRpc( + accountAddress: Hex, + usedFallback: boolean, + ): Promise { + const amounts = await this.getMoneyAccountBalance(accountAddress); + assertValidBalanceAmounts(amounts); + + return { + ...amounts, + source: 'rpc', + usedFallback, + }; + } + /** * Fetches the mUSD ERC-20 balance for the given account address via RPC. * diff --git a/packages/money-account-balance-service/src/response.types.ts b/packages/money-account-balance-service/src/response.types.ts index 6e1559febd8..f0f60676339 100644 --- a/packages/money-account-balance-service/src/response.types.ts +++ b/packages/money-account-balance-service/src/response.types.ts @@ -23,6 +23,16 @@ export type MoneyAccountBalanceResponse = { totalBalance: string; }; +/** + * Canonical balance result from + * {@link MoneyAccountBalanceService.fetchBalanceWithFallback}. + */ +export type CanonicalMoneyAccountBalanceResponse = + MoneyAccountBalanceResponse & { + source: 'api' | 'rpc'; + usedFallback: boolean; + }; + /** * Response from {@link MoneyAccountBalanceService.getVaultApy}. * All APY / fee values are decimals (multiply by 100 for percentage). 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" } ], diff --git a/yarn.lock b/yarn.lock index 6b51e9580a4..13fa4e049c0 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7485,7 +7485,7 @@ __metadata: languageName: node linkType: hard -"@metamask/money-account-api-data-service@workspace:packages/money-account-api-data-service": +"@metamask/money-account-api-data-service@npm:^0.1.0, @metamask/money-account-api-data-service@workspace:packages/money-account-api-data-service": version: 0.0.0-use.local resolution: "@metamask/money-account-api-data-service@workspace:packages/money-account-api-data-service" dependencies: @@ -7520,6 +7520,7 @@ __metadata: "@metamask/controller-utils": "npm:^12.3.0" "@metamask/messenger": "npm:^2.0.0" "@metamask/metamask-eth-abis": "npm:^3.1.1" + "@metamask/money-account-api-data-service": "npm:^0.1.0" "@metamask/network-controller": "npm:^34.0.0" "@metamask/remote-feature-flag-controller": "npm:^4.2.2" "@metamask/superstruct": "npm:^3.1.0" From 8ab4ba2535021468cfb458057b6170c0686b9623 Mon Sep 17 00:00:00 2001 From: ffmcgee Date: Mon, 20 Jul 2026 13:52:03 +0200 Subject: [PATCH 2/4] chore: update README and fix failing ci jobs --- README.md | 1 + .../CHANGELOG.md | 2 +- ...nt-api-data-service-method-action-types.ts | 3 +- .../CHANGELOG.md | 6 +- .../src/money-account-balance-service.test.ts | 130 +++++++++++++----- .../src/money-account-balance-service.ts | 46 +++---- 6 files changed, 124 insertions(+), 64 deletions(-) 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; diff --git a/packages/money-account-api-data-service/CHANGELOG.md b/packages/money-account-api-data-service/CHANGELOG.md index 0a74ade6c5f..21e6aea47e9 100644 --- a/packages/money-account-api-data-service/CHANGELOG.md +++ b/packages/money-account-api-data-service/CHANGELOG.md @@ -9,7 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- Add optional nullable `balance` field to the positions response (`musd_balance`, `vmusd_value_in_musd`, `total_balance`), matching the Money Account API contract. Export `PositionBalance` type. +- Add optional nullable `balance` field to the positions response (`musd_balance`, `vmusd_value_in_musd`, `total_balance`), matching the Money Account API contract. Export `PositionBalance` type. ([#9554](https://github.com/MetaMask/core/pull/9554)) ## [0.1.0] diff --git a/packages/money-account-api-data-service/src/money-account-api-data-service-method-action-types.ts b/packages/money-account-api-data-service/src/money-account-api-data-service-method-action-types.ts index a969d0b4d37..ca39db55193 100644 --- a/packages/money-account-api-data-service/src/money-account-api-data-service-method-action-types.ts +++ b/packages/money-account-api-data-service/src/money-account-api-data-service-method-action-types.ts @@ -9,7 +9,8 @@ import type { MoneyAccountApiDataService } from './money-account-api-data-servic * Fetches the current vault positions for a given user address. * * @param address - The user's Ethereum address. - * @returns The position response containing vault positions. + * @returns The position response containing vault positions and an optional + * `balance` summary (`null` when the API balance path is unavailable). */ export type MoneyAccountApiDataServiceFetchPositionsAction = { type: `MoneyAccountApiDataService:fetchPositions`; diff --git a/packages/money-account-balance-service/CHANGELOG.md b/packages/money-account-balance-service/CHANGELOG.md index 83167197c20..e47c8d0fbb3 100644 --- a/packages/money-account-balance-service/CHANGELOG.md +++ b/packages/money-account-balance-service/CHANGELOG.md @@ -9,9 +9,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- Add `fetchBalanceWithFallback` facade that selects Money API or RPC balance sources from the `moneyAccountBalanceSource` remote feature flag (`api` | `rpc` | `api-only` | `rpc-only`; default `api` = API primary with RPC fallback). Returns canonical amounts plus `source` and `usedFallback` provenance; throws `MoneyAccountBalanceFetchError` when all eligible sources fail. -- Permit `MoneyAccountApiDataService:fetchPositions` on the balance service messenger so the facade can read Money API balances. -- Export `CanonicalMoneyAccountBalanceResponse`, balance-source constants/types, and `MoneyAccountBalanceFetchError` / `MoneyAccountBalanceUnavailableError` / `MoneyAccountBalanceValidationError`. +- Add `fetchBalanceWithFallback` facade that selects Money API or RPC balance sources from the `moneyAccountBalanceSource` remote feature flag (`api` | `rpc` | `api-only` | `rpc-only`; default `api` = API primary with RPC fallback). Returns canonical amounts plus `source` and `usedFallback` provenance; throws `MoneyAccountBalanceFetchError` when all eligible sources fail. ([#9554](https://github.com/MetaMask/core/pull/9554)) +- Permit `MoneyAccountApiDataService:fetchPositions` on the balance service messenger so the facade can read Money API balances. ([#9554](https://github.com/MetaMask/core/pull/9554)) +- Export `CanonicalMoneyAccountBalanceResponse`, balance-source constants/types, and `MoneyAccountBalanceFetchError` / `MoneyAccountBalanceUnavailableError` / `MoneyAccountBalanceValidationError`. ([#9554](https://github.com/MetaMask/core/pull/9554)) ## [2.2.0] 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 c775bc61358..0f11e263a83 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 @@ -1641,12 +1641,13 @@ describe('MoneyAccountBalanceService', () => { }; it('returns API balance by default (API primary) without using fallback', async () => { - const mockFetchPositions = jest.fn().mockResolvedValue(MOCK_API_POSITIONS); + const mockFetchPositions = jest + .fn() + .mockResolvedValue(MOCK_API_POSITIONS); const { service } = createService({ mockFetchPositions }); - const result = await service.fetchBalanceWithFallback( - MOCK_ACCOUNT_ADDRESS, - ); + const result = + await service.fetchBalanceWithFallback(MOCK_ACCOUNT_ADDRESS); expect(result).toStrictEqual({ musdBalance: '2', @@ -1675,9 +1676,8 @@ describe('MoneyAccountBalanceService', () => { mockFetchPositions, }); - const result = await service.fetchBalanceWithFallback( - MOCK_ACCOUNT_ADDRESS, - ); + const result = + await service.fetchBalanceWithFallback(MOCK_ACCOUNT_ADDRESS); expect(result).toStrictEqual({ musdBalance: '5000000', @@ -1688,6 +1688,36 @@ describe('MoneyAccountBalanceService', () => { }); }); + it('falls back to RPC when API balance is omitted', async () => { + mockMoneyAccountBalanceMulticall({ + musdBalance: '9', + vmusdValueInMusd: '1', + }); + const { balance: _omittedBalance, ...positionsWithoutBalance } = + MOCK_API_POSITIONS; + const mockFetchPositions = jest + .fn() + .mockResolvedValue(positionsWithoutBalance); + const { service } = createService({ + rffcFlags: { + [VAULT_CONFIG_FEATURE_FLAG_KEY]: + MOCK_VAULT_CONFIG_WITH_UNDERLYING_TOKEN, + }, + mockFetchPositions, + }); + + const result = + await service.fetchBalanceWithFallback(MOCK_ACCOUNT_ADDRESS); + + expect(result).toStrictEqual({ + musdBalance: '9', + vmusdValueInMusd: '1', + totalBalance: '10', + source: 'rpc', + usedFallback: true, + }); + }); + it('falls back to RPC when the API call fails', async () => { mockMoneyAccountBalanceMulticall({ musdBalance: '7', @@ -1704,9 +1734,8 @@ describe('MoneyAccountBalanceService', () => { mockFetchPositions, }); - const result = await service.fetchBalanceWithFallback( - MOCK_ACCOUNT_ADDRESS, - ); + const result = + await service.fetchBalanceWithFallback(MOCK_ACCOUNT_ADDRESS); expect(result).toStrictEqual({ musdBalance: '7', @@ -1738,9 +1767,8 @@ describe('MoneyAccountBalanceService', () => { mockFetchPositions, }); - const result = await service.fetchBalanceWithFallback( - MOCK_ACCOUNT_ADDRESS, - ); + const result = + await service.fetchBalanceWithFallback(MOCK_ACCOUNT_ADDRESS); expect(result).toStrictEqual({ musdBalance: '1', @@ -1751,6 +1779,39 @@ describe('MoneyAccountBalanceService', () => { }); }); + it('falls back to RPC when API balance contains a non-integer amount', async () => { + mockMoneyAccountBalanceMulticall({ + musdBalance: '5', + vmusdValueInMusd: '5', + }); + const mockFetchPositions = jest.fn().mockResolvedValue({ + ...MOCK_API_POSITIONS, + balance: { + musd_balance: '1.5', + vmusd_value_in_musd: '2', + total_balance: '3.5', + }, + }); + const { service } = createService({ + rffcFlags: { + [VAULT_CONFIG_FEATURE_FLAG_KEY]: + MOCK_VAULT_CONFIG_WITH_UNDERLYING_TOKEN, + }, + mockFetchPositions, + }); + + const result = + await service.fetchBalanceWithFallback(MOCK_ACCOUNT_ADDRESS); + + expect(result).toStrictEqual({ + musdBalance: '5', + vmusdValueInMusd: '5', + totalBalance: '10', + source: 'rpc', + usedFallback: true, + }); + }); + it('uses RPC as primary when the flag is set to rpc', async () => { mockMoneyAccountBalanceMulticall({ musdBalance: '5000000', @@ -1766,9 +1827,8 @@ describe('MoneyAccountBalanceService', () => { mockFetchPositions, }); - const result = await service.fetchBalanceWithFallback( - MOCK_ACCOUNT_ADDRESS, - ); + const result = + await service.fetchBalanceWithFallback(MOCK_ACCOUNT_ADDRESS); expect(result).toStrictEqual({ musdBalance: '5000000', @@ -1781,7 +1841,9 @@ describe('MoneyAccountBalanceService', () => { }); it('falls back to API when RPC primary fails and the flag is rpc', async () => { - const mockFetchPositions = jest.fn().mockResolvedValue(MOCK_API_POSITIONS); + const mockFetchPositions = jest + .fn() + .mockResolvedValue(MOCK_API_POSITIONS); const { service } = createService({ rffcFlags: { [VAULT_CONFIG_FEATURE_FLAG_KEY]: @@ -1806,9 +1868,8 @@ describe('MoneyAccountBalanceService', () => { }) as unknown as Contract, ); - const result = await service.fetchBalanceWithFallback( - MOCK_ACCOUNT_ADDRESS, - ); + const result = + await service.fetchBalanceWithFallback(MOCK_ACCOUNT_ADDRESS); expect(result).toStrictEqual({ musdBalance: '2', @@ -1852,7 +1913,9 @@ describe('MoneyAccountBalanceService', () => { }, }) as unknown as Contract, ); - const mockFetchPositions = jest.fn().mockResolvedValue(MOCK_API_POSITIONS); + const mockFetchPositions = jest + .fn() + .mockResolvedValue(MOCK_API_POSITIONS); const { service } = createService({ rffcFlags: { [VAULT_CONFIG_FEATURE_FLAG_KEY]: @@ -1906,7 +1969,9 @@ describe('MoneyAccountBalanceService', () => { }); it('defaults to api policy when the source flag is malformed', async () => { - const mockFetchPositions = jest.fn().mockResolvedValue(MOCK_API_POSITIONS); + const mockFetchPositions = jest + .fn() + .mockResolvedValue(MOCK_API_POSITIONS); const { service } = createService({ rffcFlags: { [VAULT_CONFIG_FEATURE_FLAG_KEY]: MOCK_VAULT_CONFIG, @@ -1915,9 +1980,8 @@ describe('MoneyAccountBalanceService', () => { mockFetchPositions, }); - const result = await service.fetchBalanceWithFallback( - MOCK_ACCOUNT_ADDRESS, - ); + const result = + await service.fetchBalanceWithFallback(MOCK_ACCOUNT_ADDRESS); expect(result.source).toBe('api'); expect(result.usedFallback).toBe(false); @@ -1928,7 +1992,9 @@ describe('MoneyAccountBalanceService', () => { musdBalance: '5000000', vmusdValueInMusd: '2200000', }); - const mockFetchPositions = jest.fn().mockResolvedValue(MOCK_API_POSITIONS); + const mockFetchPositions = jest + .fn() + .mockResolvedValue(MOCK_API_POSITIONS); const { service, rootMessenger } = createService({ rffcFlags: { [VAULT_CONFIG_FEATURE_FLAG_KEY]: @@ -1938,20 +2004,22 @@ describe('MoneyAccountBalanceService', () => { }); publishRFFCStateChange(rootMessenger, { - [VAULT_CONFIG_FEATURE_FLAG_KEY]: MOCK_VAULT_CONFIG_WITH_UNDERLYING_TOKEN, + [VAULT_CONFIG_FEATURE_FLAG_KEY]: + MOCK_VAULT_CONFIG_WITH_UNDERLYING_TOKEN, [MONEY_ACCOUNT_BALANCE_SOURCE_FEATURE_FLAG_KEY]: 'rpc-only', }); - const result = await service.fetchBalanceWithFallback( - MOCK_ACCOUNT_ADDRESS, - ); + const result = + await service.fetchBalanceWithFallback(MOCK_ACCOUNT_ADDRESS); expect(result.source).toBe('rpc'); expect(mockFetchPositions).not.toHaveBeenCalled(); }); it('is callable via messenger action', async () => { - const mockFetchPositions = jest.fn().mockResolvedValue(MOCK_API_POSITIONS); + const mockFetchPositions = jest + .fn() + .mockResolvedValue(MOCK_API_POSITIONS); const { rootMessenger, service } = createService({ mockFetchPositions }); const result = await rootMessenger.call( 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 224a828b40e..c3c8c62c3bb 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 @@ -128,9 +128,7 @@ const NON_NEGATIVE_INTEGER_STRING_PATTERN = /^\d+$/u; * @param balance - Balance amounts to validate. * @throws {@link MoneyAccountBalanceValidationError} when validation fails. */ -function assertValidBalanceAmounts( - balance: MoneyAccountBalanceResponse, -): void { +function assertValidBalanceAmounts(balance: MoneyAccountBalanceResponse): void { const entries: [keyof MoneyAccountBalanceResponse, string][] = [ ['musdBalance', balance.musdBalance], ['vmusdValueInMusd', balance.vmusdValueInMusd], @@ -155,6 +153,19 @@ function assertValidBalanceAmounts( } } +/** + * Routing table from policy to primary/fallback sources. + */ +const BALANCE_ROUTING_BY_POLICY: Record< + BalanceSourcePolicy, + { primary: BalanceSource; fallback: BalanceSource | null } +> = { + api: { primary: 'api', fallback: 'rpc' }, + rpc: { primary: 'rpc', fallback: 'api' }, + 'api-only': { primary: 'api', fallback: null }, + 'rpc-only': { primary: 'rpc', fallback: null }, +}; + /** * Resolves primary and optional fallback sources from a routing policy. * @@ -165,20 +176,7 @@ function resolveBalanceRouting(policy: BalanceSourcePolicy): { primary: BalanceSource; fallback: BalanceSource | null; } { - switch (policy) { - case 'api': - return { primary: 'api', fallback: 'rpc' }; - case 'rpc': - return { primary: 'rpc', fallback: 'api' }; - case 'api-only': - return { primary: 'api', fallback: null }; - case 'rpc-only': - return { primary: 'rpc', fallback: null }; - default: { - const _exhaustive: never = policy; - return _exhaustive; - } - } + return BALANCE_ROUTING_BY_POLICY[policy]; } // === MESSENGER === @@ -758,11 +756,7 @@ export class MoneyAccountBalanceService extends BaseDataService< const errors: unknown[] = []; try { - return await this.#fetchBalanceFromSource( - accountAddress, - primary, - false, - ); + return await this.#fetchBalanceFromSource(accountAddress, primary, false); } catch (primaryError) { errors.push(primaryError); balanceLogger('Primary balance source failed', { @@ -776,11 +770,7 @@ export class MoneyAccountBalanceService extends BaseDataService< } try { - return await this.#fetchBalanceFromSource( - accountAddress, - fallback, - true, - ); + return await this.#fetchBalanceFromSource(accountAddress, fallback, true); } catch (fallbackError) { errors.push(fallbackError); balanceLogger('Fallback balance source failed', { @@ -832,7 +822,7 @@ export class MoneyAccountBalanceService extends BaseDataService< accountAddress, ); - if (positions.balance == null) { + if (positions.balance === undefined || positions.balance === null) { throw new MoneyAccountBalanceUnavailableError( 'Money API returned a null or missing balance', ); From 23befc6f1806fb3fd8af4e2fb8a72346ca6adcd9 Mon Sep 17 00:00:00 2001 From: ffmcgee Date: Mon, 20 Jul 2026 16:40:57 +0200 Subject: [PATCH 3/4] refactor: code cleanup and improvements --- .../CHANGELOG.md | 2 +- .../money-account-balance-service/README.md | 52 ++++- .../src/constants.ts | 7 +- .../src/errors.ts | 6 +- ...unt-balance-service-method-action-types.ts | 5 +- .../src/money-account-balance-service.test.ts | 191 +++++++++++------- .../src/money-account-balance-service.ts | 33 ++- 7 files changed, 214 insertions(+), 82 deletions(-) diff --git a/packages/money-account-balance-service/CHANGELOG.md b/packages/money-account-balance-service/CHANGELOG.md index e47c8d0fbb3..f2327cfda04 100644 --- a/packages/money-account-balance-service/CHANGELOG.md +++ b/packages/money-account-balance-service/CHANGELOG.md @@ -9,7 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- Add `fetchBalanceWithFallback` facade that selects Money API or RPC balance sources from the `moneyAccountBalanceSource` remote feature flag (`api` | `rpc` | `api-only` | `rpc-only`; default `api` = API primary with RPC fallback). Returns canonical amounts plus `source` and `usedFallback` provenance; throws `MoneyAccountBalanceFetchError` when all eligible sources fail. ([#9554](https://github.com/MetaMask/core/pull/9554)) +- Add `fetchBalanceWithFallback` facade that selects Money API or RPC balance sources from the `moneyAccountBalanceSource` remote feature flag (`api` | `rpc` | `api-only` | `rpc-only`; default `rpc` = RPC primary with Money API fallback). Returns canonical amounts plus `source` and `usedFallback` provenance; reports validation/unavailable source defects via messenger `captureException`; throws `MoneyAccountBalanceFetchError` when all eligible sources fail. ([#9554](https://github.com/MetaMask/core/pull/9554)) - Permit `MoneyAccountApiDataService:fetchPositions` on the balance service messenger so the facade can read Money API balances. ([#9554](https://github.com/MetaMask/core/pull/9554)) - Export `CanonicalMoneyAccountBalanceResponse`, balance-source constants/types, and `MoneyAccountBalanceFetchError` / `MoneyAccountBalanceUnavailableError` / `MoneyAccountBalanceValidationError`. ([#9554](https://github.com/MetaMask/core/pull/9554)) diff --git a/packages/money-account-balance-service/README.md b/packages/money-account-balance-service/README.md index 636f8b2412a..609c790c871 100644 --- a/packages/money-account-balance-service/README.md +++ b/packages/money-account-balance-service/README.md @@ -1,6 +1,56 @@ # `@metamask/money-account-balance-service` -Data service for fetching Money account balances via on-chain RPC reads, the Veda Accountant exchange rate, and the Veda vault APY from Veda's REST API. +Data service for Money account balances. For presentation, prefer +`fetchBalanceWithFallback`, which selects between the Money Account API and +on-chain Multicall3 RPC according to the `moneyAccountBalanceSource` remote +feature flag. + +Also provides lower-level RPC helpers (mUSD / vmUSD / exchange rate), plus +vault APY from Veda's REST API. + +## Canonical balance facade + +```ts +const result = await service.fetchBalanceWithFallback(accountAddress); +// { +// musdBalance, vmusdValueInMusd, totalBalance, +// source: 'api' | 'rpc', +// usedFallback: boolean, +// } +``` + +### Feature flag: `moneyAccountBalanceSource` + +| Value | Behavior | +| ------------------------------------- | ------------------------------- | +| `rpc` (default when absent/malformed) | RPC primary, Money API fallback | +| `api` | Money API primary, RPC fallback | +| `rpc-only` | RPC only (no fallback) | +| `api-only` | Money API only (no fallback) | + +Callers must not select a source. Provenance (`source`, `usedFallback`) is +always returned so fallback is never silent. When both eligible sources fail, +the service throws `MoneyAccountBalanceFetchError` (with `causes`) and never +invents a zero balance. + +Malformed or unavailable source balances +(`MoneyAccountBalanceValidationError` / `MoneyAccountBalanceUnavailableError`) +are reported via the messenger's `captureException` before fallback. + +### Messenger wiring + +The facade calls `MoneyAccountApiDataService:fetchPositions`. Client +composition must permit that action on the balance-service messenger (same +pattern as NetworkController / RemoteFeatureFlagController actions). + +### POC resilience note + +Balance RPC and third-party vault APY currently share one `BaseDataService` +retry / circuit-breaker policy. A Veda APY outage can affect RPC balance +availability (including facade fallback). Splitting those failure domains is +planned before production reliance on the facade. Source equivalence between +Money API and RPC totals is also not yet proven — keep the default `rpc` +policy until shadow comparison and rollout gates from the ADR are met. ## Installation diff --git a/packages/money-account-balance-service/src/constants.ts b/packages/money-account-balance-service/src/constants.ts index a11d33f1552..2b0874d3b0e 100644 --- a/packages/money-account-balance-service/src/constants.ts +++ b/packages/money-account-balance-service/src/constants.ts @@ -49,10 +49,11 @@ export const BALANCE_SOURCE_POLICIES = [ export type BalanceSourcePolicy = (typeof BALANCE_SOURCE_POLICIES)[number]; /** - * Hard default when the routing flag is absent or malformed: Money API - * primary with RPC fallback. + * Hard default when the routing flag is absent or malformed: RPC primary + * with Money API fallback. API-primary remains opt-in via the feature flag + * until source equivalence is proven. */ -export const DEFAULT_BALANCE_SOURCE_POLICY: BalanceSourcePolicy = 'api'; +export const DEFAULT_BALANCE_SOURCE_POLICY: BalanceSourcePolicy = 'rpc'; /** * Balance source used in the canonical facade result. diff --git a/packages/money-account-balance-service/src/errors.ts b/packages/money-account-balance-service/src/errors.ts index a8a4d3c76b2..22118c9c787 100644 --- a/packages/money-account-balance-service/src/errors.ts +++ b/packages/money-account-balance-service/src/errors.ts @@ -8,6 +8,8 @@ export class VedaResponseValidationError extends Error { /** * Thrown when a balance source returns data that fails semantic validation * (e.g. non-integer amounts, or `totalBalance !== musdBalance + vmusdValueInMusd`). + * Reported via the messenger's `captureException` when encountered by + * {@link MoneyAccountBalanceService.fetchBalanceWithFallback}. */ export class MoneyAccountBalanceValidationError extends Error { constructor(message: string) { @@ -18,7 +20,9 @@ export class MoneyAccountBalanceValidationError extends Error { /** * Thrown when a balance source is transport-successful but has no usable - * balance (e.g. Money API `balance: null`). + * balance (e.g. Money API `balance: null`). Reported via the messenger's + * `captureException` when encountered by + * {@link MoneyAccountBalanceService.fetchBalanceWithFallback}. */ export class MoneyAccountBalanceUnavailableError extends Error { constructor(message: string) { 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 84f9466b486..9c0affdc8cc 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 @@ -8,10 +8,11 @@ import type { MoneyAccountBalanceService } from './money-account-balance-service /** * Fetches the canonical Money account balance, selecting the Money API or * RPC source according to the `moneyAccountBalanceSource` remote feature - * flag (default: API primary with RPC fallback). + * flag (default: RPC primary with Money API fallback). * * Callers must not select a source. Provenance is returned on the result so - * fallback is never silent. + * fallback is never silent. Malformed or unavailable source balances are + * reported via the messenger's `captureException` before fallback. * * @param accountAddress - The Money account's Ethereum address. * @returns Canonical balance amounts with source provenance. 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 0f11e263a83..4fb4c11138f 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 @@ -1640,11 +1640,46 @@ describe('MoneyAccountBalanceService', () => { positions: [], }; - it('returns API balance by default (API primary) without using fallback', async () => { + const apiPrimaryFlags = { + [VAULT_CONFIG_FEATURE_FLAG_KEY]: MOCK_VAULT_CONFIG_WITH_UNDERLYING_TOKEN, + [MONEY_ACCOUNT_BALANCE_SOURCE_FEATURE_FLAG_KEY]: 'api', + }; + + it('returns RPC balance by default (RPC primary) without using fallback', async () => { + mockMoneyAccountBalanceMulticall({ + musdBalance: '5000000', + vmusdValueInMusd: '2200000', + }); + const mockFetchPositions = jest.fn(); + const { service } = createService({ + rffcFlags: { + [VAULT_CONFIG_FEATURE_FLAG_KEY]: + MOCK_VAULT_CONFIG_WITH_UNDERLYING_TOKEN, + }, + mockFetchPositions, + }); + + const result = + await service.fetchBalanceWithFallback(MOCK_ACCOUNT_ADDRESS); + + expect(result).toStrictEqual({ + musdBalance: '5000000', + vmusdValueInMusd: '2200000', + totalBalance: '7200000', + source: 'rpc', + usedFallback: false, + }); + expect(mockFetchPositions).not.toHaveBeenCalled(); + }); + + it('returns API balance when the flag is set to api', async () => { const mockFetchPositions = jest .fn() .mockResolvedValue(MOCK_API_POSITIONS); - const { service } = createService({ mockFetchPositions }); + const { service } = createService({ + rffcFlags: apiPrimaryFlags, + mockFetchPositions, + }); const result = await service.fetchBalanceWithFallback(MOCK_ACCOUNT_ADDRESS); @@ -1668,12 +1703,11 @@ describe('MoneyAccountBalanceService', () => { ...MOCK_API_POSITIONS, balance: null, }); + const captureException = jest.fn(); const { service } = createService({ - rffcFlags: { - [VAULT_CONFIG_FEATURE_FLAG_KEY]: - MOCK_VAULT_CONFIG_WITH_UNDERLYING_TOKEN, - }, + rffcFlags: apiPrimaryFlags, mockFetchPositions, + captureException, }); const result = @@ -1686,6 +1720,9 @@ describe('MoneyAccountBalanceService', () => { source: 'rpc', usedFallback: true, }); + expect(captureException).toHaveBeenCalledWith( + expect.any(MoneyAccountBalanceUnavailableError), + ); }); it('falls back to RPC when API balance is omitted', async () => { @@ -1698,12 +1735,11 @@ describe('MoneyAccountBalanceService', () => { const mockFetchPositions = jest .fn() .mockResolvedValue(positionsWithoutBalance); + const captureException = jest.fn(); const { service } = createService({ - rffcFlags: { - [VAULT_CONFIG_FEATURE_FLAG_KEY]: - MOCK_VAULT_CONFIG_WITH_UNDERLYING_TOKEN, - }, + rffcFlags: apiPrimaryFlags, mockFetchPositions, + captureException, }); const result = @@ -1716,6 +1752,9 @@ describe('MoneyAccountBalanceService', () => { source: 'rpc', usedFallback: true, }); + expect(captureException).toHaveBeenCalledWith( + expect.any(MoneyAccountBalanceUnavailableError), + ); }); it('falls back to RPC when the API call fails', async () => { @@ -1726,12 +1765,11 @@ describe('MoneyAccountBalanceService', () => { const mockFetchPositions = jest .fn() .mockRejectedValue(new Error('network down')); + const captureException = jest.fn(); const { service } = createService({ - rffcFlags: { - [VAULT_CONFIG_FEATURE_FLAG_KEY]: - MOCK_VAULT_CONFIG_WITH_UNDERLYING_TOKEN, - }, + rffcFlags: apiPrimaryFlags, mockFetchPositions, + captureException, }); const result = @@ -1744,6 +1782,7 @@ describe('MoneyAccountBalanceService', () => { source: 'rpc', usedFallback: true, }); + expect(captureException).not.toHaveBeenCalled(); }); it('falls back to RPC when API balance fails semantic validation', async () => { @@ -1759,12 +1798,11 @@ describe('MoneyAccountBalanceService', () => { total_balance: '999', }, }); + const captureException = jest.fn(); const { service } = createService({ - rffcFlags: { - [VAULT_CONFIG_FEATURE_FLAG_KEY]: - MOCK_VAULT_CONFIG_WITH_UNDERLYING_TOKEN, - }, + rffcFlags: apiPrimaryFlags, mockFetchPositions, + captureException, }); const result = @@ -1777,6 +1815,9 @@ describe('MoneyAccountBalanceService', () => { source: 'rpc', usedFallback: true, }); + expect(captureException).toHaveBeenCalledWith( + expect.any(MoneyAccountBalanceValidationError), + ); }); it('falls back to RPC when API balance contains a non-integer amount', async () => { @@ -1792,12 +1833,11 @@ describe('MoneyAccountBalanceService', () => { total_balance: '3.5', }, }); + const captureException = jest.fn(); const { service } = createService({ - rffcFlags: { - [VAULT_CONFIG_FEATURE_FLAG_KEY]: - MOCK_VAULT_CONFIG_WITH_UNDERLYING_TOKEN, - }, + rffcFlags: apiPrimaryFlags, mockFetchPositions, + captureException, }); const result = @@ -1810,37 +1850,12 @@ describe('MoneyAccountBalanceService', () => { source: 'rpc', usedFallback: true, }); + expect(captureException).toHaveBeenCalledWith( + expect.any(MoneyAccountBalanceValidationError), + ); }); - it('uses RPC as primary when the flag is set to rpc', async () => { - mockMoneyAccountBalanceMulticall({ - musdBalance: '5000000', - vmusdValueInMusd: '2200000', - }); - const mockFetchPositions = jest.fn(); - const { service } = createService({ - rffcFlags: { - [VAULT_CONFIG_FEATURE_FLAG_KEY]: - MOCK_VAULT_CONFIG_WITH_UNDERLYING_TOKEN, - [MONEY_ACCOUNT_BALANCE_SOURCE_FEATURE_FLAG_KEY]: 'rpc', - }, - mockFetchPositions, - }); - - const result = - await service.fetchBalanceWithFallback(MOCK_ACCOUNT_ADDRESS); - - expect(result).toStrictEqual({ - musdBalance: '5000000', - vmusdValueInMusd: '2200000', - totalBalance: '7200000', - source: 'rpc', - usedFallback: false, - }); - expect(mockFetchPositions).not.toHaveBeenCalled(); - }); - - it('falls back to API when RPC primary fails and the flag is rpc', async () => { + it('falls back to API when RPC primary fails', async () => { const mockFetchPositions = jest .fn() .mockResolvedValue(MOCK_API_POSITIONS); @@ -1848,7 +1863,6 @@ describe('MoneyAccountBalanceService', () => { rffcFlags: { [VAULT_CONFIG_FEATURE_FLAG_KEY]: MOCK_VAULT_CONFIG_WITH_UNDERLYING_TOKEN, - [MONEY_ACCOUNT_BALANCE_SOURCE_FEATURE_FLAG_KEY]: 'rpc', }, mockFetchPositions, }); @@ -1885,17 +1899,31 @@ describe('MoneyAccountBalanceService', () => { ...MOCK_API_POSITIONS, balance: null, }); + const captureException = jest.fn(); const { service } = createService({ rffcFlags: { [VAULT_CONFIG_FEATURE_FLAG_KEY]: MOCK_VAULT_CONFIG, [MONEY_ACCOUNT_BALANCE_SOURCE_FEATURE_FLAG_KEY]: 'api-only', }, mockFetchPositions, + captureException, }); - await expect( - service.fetchBalanceWithFallback(MOCK_ACCOUNT_ADDRESS), - ).rejects.toThrow(MoneyAccountBalanceFetchError); + let thrown: unknown; + try { + await service.fetchBalanceWithFallback(MOCK_ACCOUNT_ADDRESS); + } catch (error) { + thrown = error; + } + + expect(thrown).toBeInstanceOf(MoneyAccountBalanceFetchError); + expect((thrown as MoneyAccountBalanceFetchError).causes).toHaveLength(1); + expect( + (thrown as MoneyAccountBalanceFetchError).causes[0], + ).toBeInstanceOf(MoneyAccountBalanceUnavailableError); + expect(captureException).toHaveBeenCalledWith( + expect.any(MoneyAccountBalanceUnavailableError), + ); }); it('does not fall back when the flag is rpc-only', async () => { @@ -1946,15 +1974,18 @@ describe('MoneyAccountBalanceService', () => { }, }) as unknown as Contract, ); - const mockFetchPositions = jest - .fn() - .mockRejectedValue(new Error('api down')); + const mockFetchPositions = jest.fn().mockResolvedValue({ + ...MOCK_API_POSITIONS, + balance: null, + }); + const captureException = jest.fn(); const { service } = createService({ rffcFlags: { [VAULT_CONFIG_FEATURE_FLAG_KEY]: MOCK_VAULT_CONFIG_WITH_UNDERLYING_TOKEN, }, mockFetchPositions, + captureException, }); let thrown: unknown; @@ -1965,16 +1996,26 @@ describe('MoneyAccountBalanceService', () => { } expect(thrown).toBeInstanceOf(MoneyAccountBalanceFetchError); - expect((thrown as MoneyAccountBalanceFetchError).causes).toHaveLength(2); + const { causes } = thrown as MoneyAccountBalanceFetchError; + expect(causes).toHaveLength(2); + expect(causes[0]).toBeInstanceOf(Error); + expect((causes[0] as Error).message).toBe('execution reverted'); + expect(causes[1]).toBeInstanceOf(MoneyAccountBalanceUnavailableError); + expect(captureException).toHaveBeenCalledWith( + expect.any(MoneyAccountBalanceUnavailableError), + ); }); - it('defaults to api policy when the source flag is malformed', async () => { - const mockFetchPositions = jest - .fn() - .mockResolvedValue(MOCK_API_POSITIONS); + it('defaults to rpc policy when the source flag is malformed', async () => { + mockMoneyAccountBalanceMulticall({ + musdBalance: '5000000', + vmusdValueInMusd: '2200000', + }); + const mockFetchPositions = jest.fn(); const { service } = createService({ rffcFlags: { - [VAULT_CONFIG_FEATURE_FLAG_KEY]: MOCK_VAULT_CONFIG, + [VAULT_CONFIG_FEATURE_FLAG_KEY]: + MOCK_VAULT_CONFIG_WITH_UNDERLYING_TOKEN, [MONEY_ACCOUNT_BALANCE_SOURCE_FEATURE_FLAG_KEY]: 'not-a-policy', }, mockFetchPositions, @@ -1983,8 +2024,9 @@ describe('MoneyAccountBalanceService', () => { const result = await service.fetchBalanceWithFallback(MOCK_ACCOUNT_ADDRESS); - expect(result.source).toBe('api'); + expect(result.source).toBe('rpc'); expect(result.usedFallback).toBe(false); + expect(mockFetchPositions).not.toHaveBeenCalled(); }); it('updates the source policy on RemoteFeatureFlagController:stateChange', async () => { @@ -1999,6 +2041,7 @@ describe('MoneyAccountBalanceService', () => { rffcFlags: { [VAULT_CONFIG_FEATURE_FLAG_KEY]: MOCK_VAULT_CONFIG_WITH_UNDERLYING_TOKEN, + [MONEY_ACCOUNT_BALANCE_SOURCE_FEATURE_FLAG_KEY]: 'api', }, mockFetchPositions, }); @@ -2017,17 +2060,25 @@ describe('MoneyAccountBalanceService', () => { }); it('is callable via messenger action', async () => { - const mockFetchPositions = jest - .fn() - .mockResolvedValue(MOCK_API_POSITIONS); - const { rootMessenger, service } = createService({ mockFetchPositions }); + mockMoneyAccountBalanceMulticall({ + musdBalance: '5000000', + vmusdValueInMusd: '2200000', + }); + const mockFetchPositions = jest.fn(); + const { rootMessenger, service } = createService({ + rffcFlags: { + [VAULT_CONFIG_FEATURE_FLAG_KEY]: + MOCK_VAULT_CONFIG_WITH_UNDERLYING_TOKEN, + }, + mockFetchPositions, + }); const result = await rootMessenger.call( 'MoneyAccountBalanceService:fetchBalanceWithFallback', MOCK_ACCOUNT_ADDRESS, ); - expect(result.source).toBe('api'); + expect(result.source).toBe('rpc'); service.destroy(); }); }); 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 c3c8c62c3bb..3acbd71d64f 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 @@ -255,7 +255,8 @@ export type MoneyAccountBalanceServiceMessenger = Messenger< * Data service responsible for fetching Money account balances (mUSD and * vmUSD). Prefer {@link MoneyAccountBalanceService.fetchBalanceWithFallback} * for presentation — it selects between the Money API and Multicall3 RPC - * sources via the `moneyAccountBalanceSource` remote feature flag. + * sources via the `moneyAccountBalanceSource` remote feature flag (default: + * RPC primary with Money API fallback). * * Lower-level methods remain available for diagnostics and source-specific * use cases: on-chain RPC reads (`getMoneyAccountBalance`, etc.), the Veda @@ -266,6 +267,12 @@ export type MoneyAccountBalanceServiceMessenger = Messenger< * {@link BaseDataService}) and protected by a service policy that provides * automatic retries and circuit-breaking. * + * **POC resilience note:** balance RPC and third-party vault APY currently + * share the same `BaseDataService` retry / circuit-breaker policy. A Veda APY + * outage can therefore affect RPC balance availability (including facade + * fallback). Splitting those failure domains is planned follow-up work before + * production reliance on the facade. + * * Vault configuration (addresses, chain ID, decimals) is read from the * remote feature flag via {@link RemoteFeatureFlagControllerGetStateAction}. * Methods throw {@link VaultConfigNotAvailableError} until flags have been fetched and a @@ -299,7 +306,7 @@ export class MoneyAccountBalanceService extends BaseDataService< /** * Preferred balance source routing policy. Overridable via remote feature - * flag; defaults to Money API primary with RPC fallback. + * flag; defaults to RPC primary with Money API fallback. */ #balanceSourcePolicy: BalanceSourcePolicy = DEFAULT_BALANCE_SOURCE_POLICY; @@ -737,10 +744,11 @@ export class MoneyAccountBalanceService extends BaseDataService< /** * Fetches the canonical Money account balance, selecting the Money API or * RPC source according to the `moneyAccountBalanceSource` remote feature - * flag (default: API primary with RPC fallback). + * flag (default: RPC primary with Money API fallback). * * Callers must not select a source. Provenance is returned on the result so - * fallback is never silent. + * fallback is never silent. Malformed or unavailable source balances are + * reported via the messenger's `captureException` before fallback. * * @param accountAddress - The Money account's Ethereum address. * @returns Canonical balance amounts with source provenance. @@ -759,6 +767,7 @@ export class MoneyAccountBalanceService extends BaseDataService< return await this.#fetchBalanceFromSource(accountAddress, primary, false); } catch (primaryError) { errors.push(primaryError); + this.#reportBalanceSourceDefect(primaryError); balanceLogger('Primary balance source failed', { primary, fallback, @@ -773,6 +782,7 @@ export class MoneyAccountBalanceService extends BaseDataService< return await this.#fetchBalanceFromSource(accountAddress, fallback, true); } catch (fallbackError) { errors.push(fallbackError); + this.#reportBalanceSourceDefect(fallbackError); balanceLogger('Fallback balance source failed', { primary, fallback, @@ -782,6 +792,21 @@ export class MoneyAccountBalanceService extends BaseDataService< } } + /** + * Reports high-severity balance source defects (malformed or unavailable + * balances) to error monitoring without interrupting fallback. + * + * @param error - Error thrown by a balance source attempt. + */ + #reportBalanceSourceDefect(error: unknown): void { + if ( + error instanceof MoneyAccountBalanceValidationError || + error instanceof MoneyAccountBalanceUnavailableError + ) { + this.messenger.captureException?.(error); + } + } + /** * Fetches and validates balance from a single source. * From 12f2a711efbef2c54c37350969cf429d6cf63e91 Mon Sep 17 00:00:00 2001 From: ffmcgee Date: Mon, 20 Jul 2026 16:51:41 +0200 Subject: [PATCH 4/4] docs: minor documentation update --- .../src/money-account-balance-service.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) 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 3acbd71d64f..f2bf34bb391 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 @@ -285,7 +285,12 @@ export type MoneyAccountBalanceServiceMessenger = Messenger< * messenger: moneyAccountBalanceServiceMessenger, * }); * - * const balance = await service.fetchBalanceWithFallback('0xYourMoneyAccount...'); + * const result = await service.fetchBalanceWithFallback('0xYourMoneyAccount...'); + * // { + * // musdBalance, vmusdValueInMusd, totalBalance, + * // source: 'api' | 'rpc', + * // usedFallback: boolean, + * // } * ``` */