Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
10 changes: 10 additions & 0 deletions packages/money-account-balance-service/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions packages/money-account-balance-service/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
25 changes: 25 additions & 0 deletions packages/money-account-balance-service/src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Hex, string> = {
'0xa4b1': 'arbitrum',
'0x8f': 'monad',
Expand Down
16 changes: 16 additions & 0 deletions packages/money-account-balance-service/src/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
}
}
8 changes: 7 additions & 1 deletion packages/money-account-balance-service/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ export type {
MoneyAccountBalanceServiceTraceRequest,
} from './money-account-balance-service';
export type {
MoneyAccountBalanceServiceGetBalanceAction,
MoneyAccountBalanceServiceGetMoneyAccountBalanceAction,
MoneyAccountBalanceServiceGetMusdBalanceAction,
MoneyAccountBalanceServiceGetVmusdBalanceAction,
Expand All @@ -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';
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down Expand Up @@ -87,6 +100,7 @@ export type MoneyAccountBalanceServiceGetVaultApyAction = {
* Union of all MoneyAccountBalanceService action types.
*/
export type MoneyAccountBalanceServiceMethodActions =
| MoneyAccountBalanceServiceGetBalanceAction
| MoneyAccountBalanceServiceGetMusdBalanceAction
| MoneyAccountBalanceServiceGetMoneyAccountBalanceAction
| MoneyAccountBalanceServiceGetVmusdBalanceAction
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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: [
Expand Down Expand Up @@ -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);
Expand All @@ -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',
Expand All @@ -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'],
Expand All @@ -271,6 +298,7 @@ function createService({
mockGetNetworkConfig,
mockGetNetworkClient,
mockGetRFFCState,
mockFetchPositions,
captureException,
};
}
Expand Down Expand Up @@ -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<string, unknown>,
): Record<string, Json> => ({
...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({
Expand Down
Loading
Loading