diff --git a/packages/money-account-balance-service/CHANGELOG.md b/packages/money-account-balance-service/CHANGELOG.md index 1ab8e7d3b1..7a19faffe3 100644 --- a/packages/money-account-balance-service/CHANGELOG.md +++ b/packages/money-account-balance-service/CHANGELOG.md @@ -7,8 +7,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Add `getMoneyAccountBalance` method that fetches the account's mUSD wallet balance and vault shares valued in mUSD in a single Multicall3 `aggregate3` request. ([#9100](https://github.com/MetaMask/core/pull/9100)) +- Add optional `underlyingToken` field to `VaultConfig` (validated by `VaultConfigStruct`). When present, `getMusdBalance` reads the underlying mUSD token address from config and skips the on-chain `Accountant.base()` call; when absent it falls back to reading `base()` on-chain. ([#9100](https://github.com/MetaMask/core/pull/9100)) +- Add support for configuring the balance `staleTime` at runtime via the `moneyAccountBalanceStaletime` remote feature flag. The flag is read during `init()` and updated on `RemoteFeatureFlagController:stateChange`; absent or malformed values fall back to the default of 60 seconds. ([#9100](https://github.com/MetaMask/core/pull/9100)) + ### Changed +- **BREAKING:** Rename `musdSHFvd` to `vmusd` across the public API to align with the vmUSD token name: ([#9100](https://github.com/MetaMask/core/pull/9100)) + - `getMusdSHFvdBalance` method → `getVmusdBalance` + - `MoneyAccountBalanceServiceGetMusdSHFvdBalanceAction` type → `MoneyAccountBalanceServiceGetVmusdBalanceAction` + - `MoneyAccountBalanceService:getMusdSHFvdBalance` messenger action string → `MoneyAccountBalanceService:getVmusdBalance` + - `MoneyAccountBalanceResponse.musdSHFvdValueInMusd` property → `vmusdValueInMusd` +- Increase the default `staleTime` for on-chain balance reads (`getMusdBalance`, `getVmusdBalance`, `getMusdEquivalentValue`, and the default for `getExchangeRate`) from 30 seconds to 60 seconds. This default is now overridable via the `moneyAccountBalanceStaletime` remote feature flag. ([#9100](https://github.com/MetaMask/core/pull/9100)) - Bump `@metamask/utils` from `^11.9.0` to `^11.11.0` ([#9074](https://github.com/MetaMask/core/pull/9074)) - Bump `@metamask/controller-utils` from `^12.1.0` to `^12.2.0` ([#9058](https://github.com/MetaMask/core/pull/9058), [#9083](https://github.com/MetaMask/core/pull/9083)) - Bump `@metamask/base-data-service` from `^0.1.2` to `^0.1.3` ([#8799](https://github.com/MetaMask/core/pull/8799)) diff --git a/packages/money-account-balance-service/src/constants.ts b/packages/money-account-balance-service/src/constants.ts index d1050a064d..ea7897c028 100644 --- a/packages/money-account-balance-service/src/constants.ts +++ b/packages/money-account-balance-service/src/constants.ts @@ -1,4 +1,4 @@ -import { Hex } from '@metamask/utils'; +import { Duration, Hex, inMilliseconds } from '@metamask/utils'; export const VEDA_PERFORMANCE_API_BASE_URL = 'https://api.sevenseas.capital'; @@ -8,11 +8,70 @@ export const VEDA_PERFORMANCE_API_BASE_URL = 'https://api.sevenseas.capital'; */ export const VAULT_CONFIG_FEATURE_FLAG_KEY = 'moneyAccountVaultConfig'; +/** + * The key under which the Money account balance `staleTime` (in milliseconds) + * is stored in `RemoteFeatureFlagController` state's `remoteFeatureFlags` map. + * Falls back to {@link DEFAULT_BALANCE_STALE_TIME} when absent or malformed. + */ +export const MONEY_ACCOUNT_BALANCE_STALETIME_FEATURE_FLAG_KEY = + 'moneyAccountBalanceStaletime'; + +/** + * Default `staleTime` (in milliseconds) for on-chain Money account balance + * reads, used when {@link MONEY_ACCOUNT_BALANCE_STALETIME_FEATURE_FLAG_KEY} is + * absent or malformed. + */ +export const DEFAULT_BALANCE_STALE_TIME = inMilliseconds(1, Duration.Minute); + export const VEDA_API_NETWORK_NAMES: Record = { '0xa4b1': 'arbitrum', '0x8f': 'monad', }; +/** + * Multicall3 contract address by chain ID, used to batch the Money account + * balance reads into a single RPC request. Multicall3 is deployed at the same + * canonical address on every supported chain. + * + * Source: https://github.com/mds1/multicall/blob/main/deployments.json + */ +export const MULTICALL3_ADDRESS_BY_CHAIN_ID: Record = { + '0xa4b1': '0xcA11bde05977b3631167028862bE2a173976CA11', // Arbitrum One + '0x8f': '0xcA11bde05977b3631167028862bE2a173976CA11', // Monad mainnet +}; + +/** + * Minimal ABI for the Multicall3 `aggregate3` function. + */ +export const MULTICALL3_ABI = [ + { + name: 'aggregate3', + type: 'function', + stateMutability: 'payable', + inputs: [ + { + name: 'calls', + type: 'tuple[]', + components: [ + { name: 'target', type: 'address' }, + { name: 'allowFailure', type: 'bool' }, + { name: 'callData', type: 'bytes' }, + ], + }, + ], + outputs: [ + { + name: 'returnData', + type: 'tuple[]', + components: [ + { name: 'success', type: 'bool' }, + { name: 'returnData', type: 'bytes' }, + ], + }, + ], + }, +] as const; + /** * Minimal ABI for the Veda Accountant contract. Covers: * - base (0x5001f3b5) — the underlying ERC20 base asset address diff --git a/packages/money-account-balance-service/src/index.ts b/packages/money-account-balance-service/src/index.ts index d1d6fcef80..c58553257d 100644 --- a/packages/money-account-balance-service/src/index.ts +++ b/packages/money-account-balance-service/src/index.ts @@ -5,14 +5,16 @@ export type { MoneyAccountBalanceServiceMessenger, } from './money-account-balance-service'; export type { + MoneyAccountBalanceServiceGetMoneyAccountBalanceAction, MoneyAccountBalanceServiceGetMusdBalanceAction, - MoneyAccountBalanceServiceGetMusdSHFvdBalanceAction, + MoneyAccountBalanceServiceGetVmusdBalanceAction, MoneyAccountBalanceServiceGetExchangeRateAction, MoneyAccountBalanceServiceGetMusdEquivalentValueAction, MoneyAccountBalanceServiceGetVaultApyAction, } from './money-account-balance-service-method-action-types'; export type { ExchangeRateResponse, + MoneyAccountBalanceResponse, MusdEquivalentValueResponse, NormalizedVaultApyResponse, } from './response.types'; diff --git a/packages/money-account-balance-service/src/money-account-balance-service-method-action-types.ts b/packages/money-account-balance-service/src/money-account-balance-service-method-action-types.ts index 7be34d1553..1c5493a479 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 @@ -18,25 +18,39 @@ export type MoneyAccountBalanceServiceGetMusdBalanceAction = { }; /** - * Fetches the musdSHFvd (Veda vault share) ERC-20 balance for the given + * Fetches the account's total Money balance inputs in a single batched RPC + * request via Multicall3's `aggregate3` + * + * @param accountAddress - The Money account's Ethereum address. + * @returns The mUSD balance and the mUSD-equivalent value of vault shares as + * raw uint256 strings. The total balance is the sum of the mUSD balance and the mUSD-equivalent value of vault shares. + * @throws {@link VaultConfigNotAvailableError} if vault config has not been loaded. + */ +export type MoneyAccountBalanceServiceGetMoneyAccountBalanceAction = { + type: `MoneyAccountBalanceService:getMoneyAccountBalance`; + handler: MoneyAccountBalanceService['getMoneyAccountBalance']; +}; + +/** + * Fetches the vmUSD (Veda vault share) ERC-20 balance for the given * account address via RPC. * * @param accountAddress - The Money account's Ethereum address. - * @returns The musdSHFvd balance as a raw uint256 string. + * @returns The vmUSD balance as a raw uint256 string. * @throws {@link VaultConfigNotAvailableError} if vault config has not been loaded. */ -export type MoneyAccountBalanceServiceGetMusdSHFvdBalanceAction = { - type: `MoneyAccountBalanceService:getMusdSHFvdBalance`; - handler: MoneyAccountBalanceService['getMusdSHFvdBalance']; +export type MoneyAccountBalanceServiceGetVmusdBalanceAction = { + type: `MoneyAccountBalanceService:getVmusdBalance`; + handler: MoneyAccountBalanceService['getVmusdBalance']; }; /** * Fetches the current exchange rate from the Veda Accountant contract via - * RPC. The rate represents the conversion factor from musdSHFvd shares to + * RPC. The rate represents the conversion factor from vmUSD shares to * the underlying mUSD asset. * * @param options - The options for the query. - * @param options.staleTime - The stale time for the query. Defaults to 30 seconds. + * @param options.staleTime - Cache stale time override for this query. * @returns The exchange rate as a raw uint256 string. * @throws {@link VaultConfigNotAvailableError} if vault config has not been loaded. */ @@ -46,14 +60,11 @@ export type MoneyAccountBalanceServiceGetExchangeRateAction = { }; /** - * Computes the mUSD-equivalent value of the account's musdSHFvd holdings. - * Internally fetches the musdSHFvd balance and exchange rate (using cached - * values when available within their staleTime windows), then multiplies - * them. + * Fetches the mUSD-equivalent value of the account's vmUSD vault shares + * via `Lens.balanceOfInAssets` RPC. * * @param accountAddress - The Money account's Ethereum address. - * @returns The musdSHFvd balance, exchange rate, and computed - * mUSD-equivalent value as raw uint256 strings. + * @returns The mUSD-equivalent value of vault shares as a raw uint256 string. * @throws {@link VaultConfigNotAvailableError} if vault config has not been loaded. */ export type MoneyAccountBalanceServiceGetMusdEquivalentValueAction = { @@ -77,7 +88,8 @@ export type MoneyAccountBalanceServiceGetVaultApyAction = { */ export type MoneyAccountBalanceServiceMethodActions = | MoneyAccountBalanceServiceGetMusdBalanceAction - | MoneyAccountBalanceServiceGetMusdSHFvdBalanceAction + | MoneyAccountBalanceServiceGetMoneyAccountBalanceAction + | MoneyAccountBalanceServiceGetVmusdBalanceAction | MoneyAccountBalanceServiceGetExchangeRateAction | MoneyAccountBalanceServiceGetMusdEquivalentValueAction | MoneyAccountBalanceServiceGetVaultApyAction; 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 be9778970c..d26b7c0813 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 @@ -7,10 +7,16 @@ import type { MessengerActions, MessengerEvents, } from '@metamask/messenger'; +import { abiERC20 } from '@metamask/metamask-eth-abis'; import type { Json } from '@metamask/utils'; import nock, { cleanAll as nockCleanAll } from 'nock'; -import { VAULT_CONFIG_FEATURE_FLAG_KEY } from './constants'; +import { + LENS_ABI, + MONEY_ACCOUNT_BALANCE_STALETIME_FEATURE_FLAG_KEY, + MULTICALL3_ADDRESS_BY_CHAIN_ID, + VAULT_CONFIG_FEATURE_FLAG_KEY, +} from './constants'; import { VaultConfigNotAvailableError, VaultConfigValidationError, @@ -262,7 +268,7 @@ function createService({ /** * Configures the Contract mock so that `balanceOf` resolves to an object * whose `.toString()` returns `balance`. Used for single-contract flows - * such as `getMusdSHFvdBalance`. + * such as `getVmusdBalance`. * * @param balance - The raw uint256 balance string to return. */ @@ -325,6 +331,64 @@ function mockLensBalanceOfInAssets(balanceOfInAssets: string): void { ); } +function makeMockBN(value: string): { + toString: () => string; + add: (other: { toString: () => string }) => { + toString: () => string; + add: (o: { toString: () => string }) => unknown; + }; +} { + return { + toString: () => value, + add: (other) => + makeMockBN((BigInt(value) + BigInt(other.toString())).toString()), + }; +} + +function mockMoneyAccountBalanceMulticall({ + musdBalance = '0', + vmusdValueInMusd = '0', + aggregate3, +}: { + musdBalance?: string; + vmusdValueInMusd?: string; + aggregate3?: jest.Mock; +} = {}): jest.Mock { + const MUSD_RETURN_DATA = '0xMUSD'; + const SHFVD_RETURN_DATA = '0xSHFVD'; + + const aggregate3Mock = + aggregate3 ?? + jest.fn().mockResolvedValue([ + { success: true, returnData: MUSD_RETURN_DATA }, + { success: true, returnData: SHFVD_RETURN_DATA }, + ]); + + const multicall3Address = + MULTICALL3_ADDRESS_BY_CHAIN_ID[MOCK_VAULT_CONFIG.chainId]; + + MockContract.mockImplementation( + (address: string) => + (address === multicall3Address + ? { callStatic: { aggregate3: aggregate3Mock } } + : { + base: jest.fn().mockResolvedValue(MOCK_UNDERLYING_TOKEN_ADDRESS), + interface: { + encodeFunctionData: jest.fn().mockReturnValue('0xcalldata'), + decodeFunctionResult: jest + .fn() + .mockImplementation((_functionFragment: string, data: string) => + data === MUSD_RETURN_DATA + ? [makeMockBN(musdBalance)] + : [makeMockBN(vmusdValueInMusd)], + ), + }, + }) as unknown as Contract, + ); + + return aggregate3Mock; +} + // ============================================================ // Tests // ============================================================ @@ -438,9 +502,9 @@ describe('MoneyAccountBalanceService', () => { }, }); - await service.getMusdSHFvdBalance(MOCK_ACCOUNT_ADDRESS); + await service.getVmusdBalance(MOCK_ACCOUNT_ADDRESS); - // getMusdSHFvdBalance uses vaultAddress — verify the new address was used. + // getVmusdBalance uses vaultAddress — verify the new address was used. expect(MockContract).toHaveBeenCalledWith( NEW_VAULT_ADDRESS, expect.anything(), @@ -584,6 +648,14 @@ describe('MoneyAccountBalanceService', () => { // ---------------------------------------------------------- describe('when vault config is not available', () => { + it('getMoneyAccountBalance throws VaultConfigNotAvailableError', async () => { + const { service } = createService({ rffcFlags: {} }); + + await expect( + service.getMoneyAccountBalance(MOCK_ACCOUNT_ADDRESS), + ).rejects.toThrow(VaultConfigNotAvailableError); + }); + it('getMusdBalance throws VaultConfigNotAvailableError', async () => { const { service } = createService({ rffcFlags: {} }); @@ -592,11 +664,11 @@ describe('MoneyAccountBalanceService', () => { ).rejects.toThrow(VaultConfigNotAvailableError); }); - it('getMusdSHFvdBalance throws VaultConfigNotAvailableError', async () => { + it('getVmusdBalance throws VaultConfigNotAvailableError', async () => { const { service } = createService({ rffcFlags: {} }); await expect( - service.getMusdSHFvdBalance(MOCK_ACCOUNT_ADDRESS), + service.getVmusdBalance(MOCK_ACCOUNT_ADDRESS), ).rejects.toThrow(VaultConfigNotAvailableError); }); @@ -661,6 +733,35 @@ describe('MoneyAccountBalanceService', () => { ); }); + it('uses the configured underlyingToken and skips the on-chain base() read when present', async () => { + // Only the ERC-20 contract is instantiated; no Accountant.base() call. + mockErc20BalanceOf('5000000'); + const { service } = createService({ + rffcFlags: { + [VAULT_CONFIG_FEATURE_FLAG_KEY]: { + ...MOCK_VAULT_CONFIG, + underlyingToken: MOCK_UNDERLYING_TOKEN_ADDRESS, + }, + }, + }); + + const result = await service.getMusdBalance(MOCK_ACCOUNT_ADDRESS); + + expect(result).toStrictEqual({ balance: '5000000' }); + // balanceOf is read directly on the configured underlying token... + expect(MockContract).toHaveBeenCalledWith( + MOCK_UNDERLYING_TOKEN_ADDRESS, + expect.anything(), + expect.anything(), + ); + // ...and the Accountant is never instantiated to resolve base(). + expect(MockContract).not.toHaveBeenCalledWith( + MOCK_ACCOUNTANT_ADDRESS, + expect.anything(), + expect.anything(), + ); + }); + it('is also callable via the messenger action', async () => { mockAccountantBase(); mockErc20BalanceOf('5000000'); @@ -722,15 +823,15 @@ describe('MoneyAccountBalanceService', () => { }); // ---------------------------------------------------------- - // getMusdSHFvdBalance + // getVmusdBalance // ---------------------------------------------------------- - describe('getMusdSHFvdBalance', () => { + describe('getVmusdBalance', () => { it('returns the vault share balance for the given address', async () => { mockErc20BalanceOf('3000000'); const { service } = createService(); - const result = await service.getMusdSHFvdBalance(MOCK_ACCOUNT_ADDRESS); + const result = await service.getVmusdBalance(MOCK_ACCOUNT_ADDRESS); expect(result).toStrictEqual({ balance: '3000000' }); }); @@ -739,7 +840,7 @@ describe('MoneyAccountBalanceService', () => { mockErc20BalanceOf('3000000'); const { service } = createService(); - await service.getMusdSHFvdBalance(MOCK_ACCOUNT_ADDRESS); + await service.getVmusdBalance(MOCK_ACCOUNT_ADDRESS); expect(MockContract).toHaveBeenCalledWith( MOCK_VAULT_ADDRESS, @@ -758,7 +859,7 @@ describe('MoneyAccountBalanceService', () => { mockGetNetworkConfig.mockReturnValue(undefined); await expect( - service.getMusdSHFvdBalance(MOCK_ACCOUNT_ADDRESS), + service.getVmusdBalance(MOCK_ACCOUNT_ADDRESS), ).rejects.toThrow('No network configuration found for chain 0xa4b1'); }); @@ -767,7 +868,7 @@ describe('MoneyAccountBalanceService', () => { mockGetNetworkClient.mockReturnValue({ provider: null }); await expect( - service.getMusdSHFvdBalance(MOCK_ACCOUNT_ADDRESS), + service.getVmusdBalance(MOCK_ACCOUNT_ADDRESS), ).rejects.toThrow('No provider found for chain 0xa4b1'); }); }); @@ -932,6 +1033,215 @@ describe('MoneyAccountBalanceService', () => { }); }); + // ---------------------------------------------------------- + // getMoneyAccountBalance + // ---------------------------------------------------------- + + describe('getMoneyAccountBalance', () => { + /** + * Vault config including `underlyingToken`, so balance resolution skips the + * on-chain `base()` read. + */ + const VAULT_CONFIG_WITH_UNDERLYING_TOKEN = { + ...MOCK_VAULT_CONFIG, + underlyingToken: MOCK_UNDERLYING_TOKEN_ADDRESS, + }; + + it('returns musdBalance, vmusdValueInMusd, and totalBalance from a single aggregate3 call', async () => { + const aggregate3 = mockMoneyAccountBalanceMulticall({ + musdBalance: '5000000', + vmusdValueInMusd: '2200000', + }); + const { service } = createService({ + rffcFlags: { + [VAULT_CONFIG_FEATURE_FLAG_KEY]: VAULT_CONFIG_WITH_UNDERLYING_TOKEN, + }, + }); + + const result = await service.getMoneyAccountBalance(MOCK_ACCOUNT_ADDRESS); + + expect(result).toStrictEqual({ + musdBalance: '5000000', + vmusdValueInMusd: '2200000', + totalBalance: '7200000', + }); + expect(aggregate3).toHaveBeenCalledTimes(1); + }); + + it('exercises real ABI encode/decode through the multicall path', async () => { + const { Contract: RealContract } = jest.requireActual< + typeof import('@ethersproject/contracts') + >('@ethersproject/contracts'); + const erc20Iface = new RealContract( + MOCK_UNDERLYING_TOKEN_ADDRESS, + abiERC20, + ).interface; + const lensIface = new RealContract(MOCK_LENS_ADDRESS, LENS_ABI).interface; + + const musdReturnData = erc20Iface.encodeFunctionResult('balanceOf', [ + '5000000', + ]); + const vmusdReturnData = lensIface.encodeFunctionResult( + 'balanceOfInAssets', + ['2200000'], + ); + + const aggregate3Mock = jest.fn().mockResolvedValue([ + { success: true, returnData: musdReturnData }, + { success: true, returnData: vmusdReturnData }, + ]); + + const multicall3Address = + MULTICALL3_ADDRESS_BY_CHAIN_ID[MOCK_VAULT_CONFIG.chainId]; + + MockContract.mockImplementation( + (address, abi) => + (address === multicall3Address + ? { callStatic: { aggregate3: aggregate3Mock } } + : new RealContract(address, abi)) as unknown as Contract, + ); + + const { service } = createService({ + rffcFlags: { + [VAULT_CONFIG_FEATURE_FLAG_KEY]: VAULT_CONFIG_WITH_UNDERLYING_TOKEN, + }, + }); + + const result = await service.getMoneyAccountBalance(MOCK_ACCOUNT_ADDRESS); + + expect(result).toStrictEqual({ + musdBalance: '5000000', + vmusdValueInMusd: '2200000', + totalBalance: '7200000', + }); + + const [[calls]] = aggregate3Mock.mock.calls; + expect(calls).toHaveLength(2); + expect(calls[0].callData).toBe( + erc20Iface.encodeFunctionData('balanceOf', [MOCK_ACCOUNT_ADDRESS]), + ); + expect(calls[1].callData).toBe( + lensIface.encodeFunctionData('balanceOfInAssets', [ + MOCK_ACCOUNT_ADDRESS, + MOCK_VAULT_ADDRESS, + MOCK_ACCOUNTANT_ADDRESS, + ]), + ); + }); + + it('batches the mUSD and Lens reads into one aggregate3 request with allowFailure disabled', async () => { + const aggregate3 = mockMoneyAccountBalanceMulticall(); + const { service } = createService({ + rffcFlags: { + [VAULT_CONFIG_FEATURE_FLAG_KEY]: VAULT_CONFIG_WITH_UNDERLYING_TOKEN, + }, + }); + + await service.getMoneyAccountBalance(MOCK_ACCOUNT_ADDRESS); + + // A single batched request containing exactly the two balance reads. + expect(aggregate3).toHaveBeenCalledWith([ + expect.objectContaining({ + target: MOCK_UNDERLYING_TOKEN_ADDRESS, + allowFailure: false, + }), + expect.objectContaining({ + target: MOCK_LENS_ADDRESS, + allowFailure: false, + }), + ]); + // The Multicall3 contract is instantiated at the canonical address. + expect(MockContract).toHaveBeenCalledWith( + MULTICALL3_ADDRESS_BY_CHAIN_ID[MOCK_VAULT_CONFIG.chainId], + expect.anything(), + expect.anything(), + ); + }); + + it('falls back to an on-chain base() read when underlyingToken is absent from config', async () => { + const aggregate3 = mockMoneyAccountBalanceMulticall({ + musdBalance: '7', + vmusdValueInMusd: '3', + }); + // MOCK_VAULT_CONFIG has no underlyingToken. + const { service } = createService(); + + const result = await service.getMoneyAccountBalance(MOCK_ACCOUNT_ADDRESS); + + expect(result).toStrictEqual({ + musdBalance: '7', + vmusdValueInMusd: '3', + totalBalance: '10', + }); + // Accountant is instantiated for the base() fallback... + expect(MockContract).toHaveBeenCalledWith( + MOCK_ACCOUNTANT_ADDRESS, + expect.anything(), + expect.anything(), + ); + // ...and the resolved underlying token is used as the mUSD read target. + expect(aggregate3).toHaveBeenCalledWith([ + expect.objectContaining({ target: MOCK_UNDERLYING_TOKEN_ADDRESS }), + expect.objectContaining({ target: MOCK_LENS_ADDRESS }), + ]); + }); + + it('is also callable via the messenger action', async () => { + mockMoneyAccountBalanceMulticall({ + musdBalance: '5000000', + vmusdValueInMusd: '2200000', + }); + const { rootMessenger } = createService({ + rffcFlags: { + [VAULT_CONFIG_FEATURE_FLAG_KEY]: VAULT_CONFIG_WITH_UNDERLYING_TOKEN, + }, + }); + + const result = await rootMessenger.call( + 'MoneyAccountBalanceService:getMoneyAccountBalance', + MOCK_ACCOUNT_ADDRESS, + ); + + expect(result).toStrictEqual({ + musdBalance: '5000000', + vmusdValueInMusd: '2200000', + totalBalance: '7200000', + }); + }); + + it('rejects without reporting a partial balance when the aggregate3 multicall reverts', async () => { + const aggregate3 = jest + .fn() + .mockRejectedValue(new Error('execution reverted')); + mockMoneyAccountBalanceMulticall({ aggregate3 }); + const { service } = createService({ + rffcFlags: { + [VAULT_CONFIG_FEATURE_FLAG_KEY]: VAULT_CONFIG_WITH_UNDERLYING_TOKEN, + }, + }); + + await expect( + service.getMoneyAccountBalance(MOCK_ACCOUNT_ADDRESS), + ).rejects.toThrow('execution reverted'); + }); + + it('throws when no Multicall3 address is configured for the vault chain', async () => { + mockMoneyAccountBalanceMulticall(); + const { service } = createService({ + rffcFlags: { + [VAULT_CONFIG_FEATURE_FLAG_KEY]: { + ...VAULT_CONFIG_WITH_UNDERLYING_TOKEN, + chainId: '0x1', + }, + }, + }); + + await expect( + service.getMoneyAccountBalance(MOCK_ACCOUNT_ADDRESS), + ).rejects.toThrow('No Multicall3 address configured for chain 0x1'); + }); + }); + // ---------------------------------------------------------- // getVaultApy // ---------------------------------------------------------- @@ -1102,6 +1412,129 @@ describe('MoneyAccountBalanceService', () => { ); }); }); + + // ---------------------------------------------------------- + // Balance staleTime feature flag + // ---------------------------------------------------------- + + describe('balance staleTime feature flag', () => { + /** + * Stubs the Accountant so `getRate` invocations are observable across + * calls. `getExchangeRate` is used as the probe because its staleTime + * defaults to the configurable balance staleTime. + * + * @returns The `getRate` mock. + */ + function mockAccountantGetRateSpy(): jest.Mock { + const mockGetRate = jest + .fn() + .mockResolvedValue({ toString: () => '1050000' }); + MockContract.mockImplementation( + () => ({ getRate: mockGetRate }) as unknown as Contract, + ); + return mockGetRate; + } + + it('applies a valid staleTime override read from the flag during init', async () => { + const mockGetRate = mockAccountantGetRateSpy(); + // staleTime 0 disables caching, so each call performs a fresh read. + const { service } = createService({ + rffcFlags: { + [VAULT_CONFIG_FEATURE_FLAG_KEY]: MOCK_VAULT_CONFIG, + [MONEY_ACCOUNT_BALANCE_STALETIME_FEATURE_FLAG_KEY]: 0, + }, + }); + + await service.getExchangeRate(); + await service.getExchangeRate(); + + expect(mockGetRate).toHaveBeenCalledTimes(2); + }); + + it('applies a staleTime override that arrives via stateChange', async () => { + const mockGetRate = mockAccountantGetRateSpy(); + const { service, rootMessenger } = createService(); + + // Default 60s window → the second call is served from cache. + await service.getExchangeRate(); + await service.getExchangeRate(); + expect(mockGetRate).toHaveBeenCalledTimes(1); + + // Lower staleTime to 0 remotely → caching is disabled for later calls. + publishRFFCStateChange(rootMessenger, { + [VAULT_CONFIG_FEATURE_FLAG_KEY]: MOCK_VAULT_CONFIG, + [MONEY_ACCOUNT_BALANCE_STALETIME_FEATURE_FLAG_KEY]: 0, + }); + + await service.getExchangeRate(); + expect(mockGetRate).toHaveBeenCalledTimes(2); + }); + + it.each([ + { description: 'a non-number', value: 'soon' }, + { description: 'NaN', value: NaN }, + { description: 'a negative number', value: -1 }, + ])( + 'falls back to the default staleTime when the flag is $description', + async ({ value }) => { + const mockGetRate = mockAccountantGetRateSpy(); + const { service } = createService({ + rffcFlags: { + [VAULT_CONFIG_FEATURE_FLAG_KEY]: MOCK_VAULT_CONFIG, + [MONEY_ACCOUNT_BALANCE_STALETIME_FEATURE_FLAG_KEY]: value, + }, + }); + + // Default (non-zero) window applies → the second call is cached. + await service.getExchangeRate(); + await service.getExchangeRate(); + + expect(mockGetRate).toHaveBeenCalledTimes(1); + }, + ); + + it('applies staleTime even when the vault config flag is malformed (orchestrator isolation)', async () => { + // This test verifies that when #onRemoteFeatureFlagChange (the orchestrator) + // receives a stateChange with both flags, it processes BOTH — even when + // #applyVaultConfig throws. #applyBalanceStaleTimeFlag runs first and is + // never blocked by a vault config error. + const captureException = jest.fn(); + const mockGetRate = mockAccountantGetRateSpy(); + const { service, rootMessenger } = createService({ + rffcFlags: {}, + captureException, + }); + + // stateChange carries staleTime=0 AND a malformed vault config. The + // orchestrator calls #applyBalanceStaleTimeFlag first (→ staleTime=0), + // then #applyVaultConfig which throws. The messenger routes the throw to + // captureException so the subscriber does not crash. + publishRFFCStateChange(rootMessenger, { + [VAULT_CONFIG_FEATURE_FLAG_KEY]: { malformed: true }, + [MONEY_ACCOUNT_BALANCE_STALETIME_FEATURE_FLAG_KEY]: 0, + }); + + expect(captureException).toHaveBeenCalledWith( + expect.any(VaultConfigValidationError), + ); + + // Restore a valid vault config. The orchestrator now also re-applies + // staleTime for this event (no flag present → resets to default), but the + // key assertion above already confirms the orchestrator processed both + // flags on the previous event (captureException was called, which means + // #applyVaultConfig was reached, meaning #applyBalanceStaleTimeFlag ran + // first as intended). + publishRFFCStateChange(rootMessenger, { + [VAULT_CONFIG_FEATURE_FLAG_KEY]: MOCK_VAULT_CONFIG, + [MONEY_ACCOUNT_BALANCE_STALETIME_FEATURE_FLAG_KEY]: 0, + }); + + // Cache is bypassed on every call since staleTime=0 is active. + await service.getExchangeRate(); + await service.getExchangeRate(); + expect(mockGetRate).toHaveBeenCalledTimes(2); + }); + }); }); // ============================================================ 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 865c51b0ef..23f62dd80a 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 @@ -24,7 +24,11 @@ import { Duration, inMilliseconds } from '@metamask/utils'; import { ACCOUNTANT_ABI, + DEFAULT_BALANCE_STALE_TIME, LENS_ABI, + MONEY_ACCOUNT_BALANCE_STALETIME_FEATURE_FLAG_KEY, + MULTICALL3_ABI, + MULTICALL3_ADDRESS_BY_CHAIN_ID, VAULT_CONFIG_FEATURE_FLAG_KEY, VEDA_API_NETWORK_NAMES, VEDA_PERFORMANCE_API_BASE_URL, @@ -39,6 +43,7 @@ import type { MoneyAccountBalanceServiceMethodActions } from './money-account-ba import { normalizeVaultApyResponse } from './requestNormalization'; import type { ExchangeRateResponse, + MoneyAccountBalanceResponse, MusdEquivalentValueResponse, NormalizedVaultApyResponse, } from './response.types'; @@ -47,6 +52,15 @@ import type { VaultConfig } from './types'; // === GENERAL === +/** + * The shape of a single result entry returned by Multicall3's `aggregate3`. + * Mirrors the on-chain `struct Multicall3.Result`. + */ +type Multicall3Result = { + success: boolean; + returnData: string; +}; + /** * The name of the {@link MoneyAccountBalanceService}, used to namespace the * service's actions and events. @@ -58,8 +72,9 @@ const configLogger = createModuleLogger(projectLogger, 'config'); // === MESSENGER === const MESSENGER_EXPOSED_METHODS = [ + 'getMoneyAccountBalance', 'getMusdBalance', - 'getMusdSHFvdBalance', + 'getVmusdBalance', 'getExchangeRate', 'getMusdEquivalentValue', 'getVaultApy', @@ -126,7 +141,7 @@ export type MoneyAccountBalanceServiceMessenger = Messenger< /** * Data service responsible for fetching Money account balances (mUSD and - * musdSHFvd) via on-chain RPC reads, the Veda Accountant exchange rate, and + * vmUSD) via on-chain RPC reads, the Veda Accountant exchange rate, and * the Veda vault APY from the Seven Seas REST API. * * All queries are cached via TanStack Query (inherited from @@ -160,12 +175,13 @@ export class MoneyAccountBalanceService extends BaseDataService< > { #vaultConfig: VaultConfig | undefined; + /** Cache stale time (ms) for on-chain balance reads. Overridable via remote feature flag. */ + #balanceStaleTime: number = DEFAULT_BALANCE_STALE_TIME; + /** - * Constructs a new MoneyAccountBalanceService. - * - * @param args - The constructor arguments. - * @param args.messenger - The messenger suited for this service. - * @param args.policyOptions - Options to pass to `createServicePolicy`. + * @param options - Constructor options. + * @param options.messenger - The messenger for this service. + * @param options.policyOptions - Options passed to `createServicePolicy`. */ constructor({ messenger, @@ -187,11 +203,7 @@ export class MoneyAccountBalanceService extends BaseDataService< this.messenger.subscribe( // eslint-disable-next-line no-restricted-syntax 'RemoteFeatureFlagController:stateChange', - (state) => { - const flagValue = - state.remoteFeatureFlags[VAULT_CONFIG_FEATURE_FLAG_KEY]; - this.#onRemoteFeatureFlagChange(flagValue); - }, + (state) => this.#onRemoteFeatureFlagChange(state.remoteFeatureFlags), ); this.messenger.registerMethodActionHandlers( @@ -201,7 +213,7 @@ export class MoneyAccountBalanceService extends BaseDataService< } /** - * Eagerly reads already-loaded feature flags and initialises `#vaultConfig`. + * Eagerly reads already-loaded feature flags and initialises service state. * * Must be called after all controllers and services have been instantiated so * that the `RemoteFeatureFlagController:getState` action is guaranteed to be @@ -214,16 +226,7 @@ export class MoneyAccountBalanceService extends BaseDataService< const { remoteFeatureFlags } = this.messenger.call( 'RemoteFeatureFlagController:getState', ); - const flagValue = remoteFeatureFlags[VAULT_CONFIG_FEATURE_FLAG_KEY]; - - if (flagValue === undefined) { - configLogger( - 'Init complete — no vault config flag present, awaiting remote flags', - ); - return; - } - this.#vaultConfig = this.#parseAndValidateVaultConfig(flagValue); - configLogger('Vault config loaded during init', this.#vaultConfig); + this.#onRemoteFeatureFlagChange(remoteFeatureFlags); } catch (error) { if (error instanceof VaultConfigValidationError) { configLogger( @@ -253,24 +256,67 @@ export class MoneyAccountBalanceService extends BaseDataService< } /** - * Called on every `RemoteFeatureFlagController:stateChange` event. - * Validates the flag value, updates `#vaultConfig`, and invalidates all - * cached queries when the config changes. + * Applies the balance `staleTime` feature flag, falling back to + * {@link DEFAULT_BALANCE_STALE_TIME} when the flag is absent or malformed. * + * @param flagValue - Raw flag value from `remoteFeatureFlags`; expected to be + * a non-negative number of milliseconds. + */ + #applyBalanceStaleTimeFlag(flagValue: Json | undefined): void { + let nextStaleTime = DEFAULT_BALANCE_STALE_TIME; + + if (flagValue !== undefined) { + if ( + typeof flagValue === 'number' && + Number.isFinite(flagValue) && + flagValue >= 0 + ) { + nextStaleTime = flagValue; + } else { + configLogger('Invalid balance staleTime flag value; using default', { + flagValue, + default: DEFAULT_BALANCE_STALE_TIME, + }); + } + } + + if (nextStaleTime !== this.#balanceStaleTime) { + configLogger('Balance staleTime updated', { + previous: this.#balanceStaleTime, + next: nextStaleTime, + }); + this.#balanceStaleTime = nextStaleTime; + } + } + + /** + * Handles `RemoteFeatureFlagController:stateChange` events and the initial + * {@link init} call. + * + * @param remoteFeatureFlags - The `remoteFeatureFlags` map from + * `RemoteFeatureFlagController` state. + */ + #onRemoteFeatureFlagChange(remoteFeatureFlags: Record): void { + this.#applyBalanceStaleTimeFlag( + remoteFeatureFlags[MONEY_ACCOUNT_BALANCE_STALETIME_FEATURE_FLAG_KEY], + ); + this.#applyVaultConfig(remoteFeatureFlags[VAULT_CONFIG_FEATURE_FLAG_KEY]); + } + + /** + * Validates the vault config feature flag value, updates `#vaultConfig`, and + * invalidates all cached queries when the config changes. * Throws {@link VaultConfigValidationError} when the flag value is malformed. - * The messenger catches throws from event subscribers and routes them to - * `captureException` (Sentry) — the error does NOT propagate to the - * stateChange publisher. * * @param flagValue - The raw flag value from `remoteFeatureFlags`. */ - #onRemoteFeatureFlagChange(flagValue: Json | undefined): void { + #applyVaultConfig(flagValue: Json | undefined): void { const previousConfig = this.#vaultConfig; const hadConfig = previousConfig !== undefined; if (flagValue === undefined) { - // Flag key absent — treat as "not loaded". if (hadConfig) { + // Invalidate the cache if the flag key was removed. We don't want to keep using old config values. this.#vaultConfig = undefined; configLogger( 'Vault config cleared — flag key absent; cache invalidated', @@ -290,8 +336,8 @@ export class MoneyAccountBalanceService extends BaseDataService< try { newConfig = this.#parseAndValidateVaultConfig(flagValue); } catch (error) { - // Clear previously valid config and purge stale cache. if (hadConfig) { + // Invalidate the cache if the config is malformed. We don't want to keep using old config values. this.#vaultConfig = undefined; configLogger( 'Vault config validation failed — previous config cleared; cache invalidated', @@ -302,9 +348,7 @@ export class MoneyAccountBalanceService extends BaseDataService< } else { configLogger( 'Vault config validation failed — config was already absent', - { - error, - }, + { error }, ); } throw error; @@ -412,6 +456,42 @@ export class MoneyAccountBalanceService extends BaseDataService< return underlyingTokenAddress; } + /** + * Resolves the underlying mUSD token address. + * + * Prefers the remotely-configured underlyingToken. + * Falls back to on-chain read when the flag isn't available. + * + * @param chainId - The chain ID to use for the provider on the fallback path. + * @returns The underlying mUSD token address. + */ + async #resolveUnderlyingTokenAddress(chainId: Hex): Promise { + const { underlyingToken } = this.#requireConfig(); + if (underlyingToken) { + return underlyingToken; + } + configLogger( + 'underlyingToken absent from vault config; falling back to on-chain read', + ); + return this.#fetchUnderlyingTokenAddress(chainId); + } + + /** + * Returns the Multicall3 contract address for the given chain, or throws if + * the chain is not supported. + * + * @param chainId - The chain ID to resolve a Multicall3 address for. + * @returns The Multicall3 contract address. + * @throws If no Multicall3 address is configured for the chain. + */ + #getMulticall3Address(chainId: Hex): Hex { + const multicall3Address = MULTICALL3_ADDRESS_BY_CHAIN_ID[chainId]; + if (!multicall3Address) { + throw new Error(`No Multicall3 address configured for chain ${chainId}`); + } + return multicall3Address; + } + /** * Fetches the mUSD ERC-20 balance for the given account address via RPC. * @@ -426,7 +506,7 @@ export class MoneyAccountBalanceService extends BaseDataService< const { chainId } = this.#requireConfig(); const underlyingTokenAddress = - await this.#fetchUnderlyingTokenAddress(chainId); + await this.#resolveUnderlyingTokenAddress(chainId); const balance = await this.#fetchErc20Balance( underlyingTokenAddress, @@ -435,21 +515,95 @@ export class MoneyAccountBalanceService extends BaseDataService< ); return { balance }; }, - staleTime: inMilliseconds(30, Duration.Second), + staleTime: this.#balanceStaleTime, + }); + } + + /** + * Fetches the account's total Money balance inputs in a single batched RPC + * request via Multicall3's `aggregate3` + * + * @param accountAddress - The Money account's Ethereum address. + * @returns The mUSD balance and the mUSD-equivalent value of vault shares as + * raw uint256 strings. The total balance is the sum of the mUSD balance and the mUSD-equivalent value of vault shares. + * @throws {@link VaultConfigNotAvailableError} if vault config has not been loaded. + */ + async getMoneyAccountBalance( + accountAddress: Hex, + ): Promise { + return this.fetchQuery({ + queryKey: [`${this.name}:getMoneyAccountBalance`, accountAddress], + queryFn: async () => { + const { chainId, boringVault, accountantAddress, lensAddress } = + this.#requireConfig(); + const provider = this.#getProvider(chainId); + + const underlyingTokenAddress = + await this.#resolveUnderlyingTokenAddress(chainId); + + const erc20 = new Contract(underlyingTokenAddress, abiERC20, provider); + const lens = new Contract(lensAddress, LENS_ABI, provider); + + const calls = [ + { + target: underlyingTokenAddress, + allowFailure: false, + callData: erc20.interface.encodeFunctionData('balanceOf', [ + accountAddress, + ]), + }, + { + target: lensAddress, + allowFailure: false, + callData: lens.interface.encodeFunctionData('balanceOfInAssets', [ + accountAddress, + boringVault, + accountantAddress, + ]), + }, + ]; + + const multicall3 = new Contract( + this.#getMulticall3Address(chainId), + MULTICALL3_ABI, + provider, + ); + const [musdResult, vmusdResult] = + (await multicall3.callStatic.aggregate3(calls)) as [ + Multicall3Result, + Multicall3Result, + ]; + + const musdBalanceBN = erc20.interface.decodeFunctionResult( + 'balanceOf', + musdResult.returnData, + )[0]; + const vmusdBN = lens.interface.decodeFunctionResult( + 'balanceOfInAssets', + vmusdResult.returnData, + )[0]; + + return { + musdBalance: musdBalanceBN.toString(), + vmusdValueInMusd: vmusdBN.toString(), + totalBalance: musdBalanceBN.add(vmusdBN).toString(), + }; + }, + staleTime: this.#balanceStaleTime, }); } /** - * Fetches the musdSHFvd (Veda vault share) ERC-20 balance for the given + * Fetches the vmUSD (Veda vault share) ERC-20 balance for the given * account address via RPC. * * @param accountAddress - The Money account's Ethereum address. - * @returns The musdSHFvd balance as a raw uint256 string. + * @returns The vmUSD balance as a raw uint256 string. * @throws {@link VaultConfigNotAvailableError} if vault config has not been loaded. */ - async getMusdSHFvdBalance(accountAddress: Hex): Promise<{ balance: string }> { + async getVmusdBalance(accountAddress: Hex): Promise<{ balance: string }> { return this.fetchQuery({ - queryKey: [`${this.name}:getMusdSHFvdBalance`, accountAddress], + queryKey: [`${this.name}:getVmusdBalance`, accountAddress], queryFn: async () => { const { boringVault, chainId } = this.#requireConfig(); const balance = await this.#fetchErc20Balance( @@ -459,22 +613,22 @@ export class MoneyAccountBalanceService extends BaseDataService< ); return { balance }; }, - staleTime: inMilliseconds(30, Duration.Second), + staleTime: this.#balanceStaleTime, }); } /** * Fetches the current exchange rate from the Veda Accountant contract via - * RPC. The rate represents the conversion factor from musdSHFvd shares to + * RPC. The rate represents the conversion factor from vmUSD shares to * the underlying mUSD asset. * * @param options - The options for the query. - * @param options.staleTime - The stale time for the query. Defaults to 30 seconds. + * @param options.staleTime - Cache stale time override for this query. * @returns The exchange rate as a raw uint256 string. * @throws {@link VaultConfigNotAvailableError} if vault config has not been loaded. */ async getExchangeRate({ - staleTime = inMilliseconds(30, Duration.Second), + staleTime, }: { staleTime?: number } = {}): Promise { return this.fetchQuery({ queryKey: [`${this.name}:getExchangeRate`], @@ -489,19 +643,16 @@ export class MoneyAccountBalanceService extends BaseDataService< const rate = await contract.getRate(); return { rate: rate.toString() }; }, - staleTime, + staleTime: staleTime ?? this.#balanceStaleTime, }); } /** - * Computes the mUSD-equivalent value of the account's musdSHFvd holdings. - * Internally fetches the musdSHFvd balance and exchange rate (using cached - * values when available within their staleTime windows), then multiplies - * them. + * Fetches the mUSD-equivalent value of the account's vmUSD vault shares + * via `Lens.balanceOfInAssets` RPC. * * @param accountAddress - The Money account's Ethereum address. - * @returns The musdSHFvd balance, exchange rate, and computed - * mUSD-equivalent value as raw uint256 strings. + * @returns The mUSD-equivalent value of vault shares as a raw uint256 string. * @throws {@link VaultConfigNotAvailableError} if vault config has not been loaded. */ async getMusdEquivalentValue( @@ -522,7 +673,7 @@ export class MoneyAccountBalanceService extends BaseDataService< return { balanceOfInAssets: balanceOfInAssets.toString() }; }, - staleTime: inMilliseconds(30, Duration.Second), + staleTime: this.#balanceStaleTime, }); } diff --git a/packages/money-account-balance-service/src/response.types.ts b/packages/money-account-balance-service/src/response.types.ts index d8b13c80dd..6e1559febd 100644 --- a/packages/money-account-balance-service/src/response.types.ts +++ b/packages/money-account-balance-service/src/response.types.ts @@ -14,6 +14,15 @@ export type MusdEquivalentValueResponse = { balanceOfInAssets: string; }; +/** + * Response from {@link MoneyAccountBalanceService.getMoneyAccountBalance}. + */ +export type MoneyAccountBalanceResponse = { + musdBalance: string; + vmusdValueInMusd: string; + totalBalance: string; +}; + /** * Response from {@link MoneyAccountBalanceService.getVaultApy}. * All APY / fee values are decimals (multiply by 100 for percentage). diff --git a/packages/money-account-balance-service/src/structs.ts b/packages/money-account-balance-service/src/structs.ts index dbc1563e55..1c3f55031b 100644 --- a/packages/money-account-balance-service/src/structs.ts +++ b/packages/money-account-balance-service/src/structs.ts @@ -20,6 +20,9 @@ export const VaultConfigStruct = type({ lensAddress: StrictHexStruct, tellerAddress: StrictHexStruct, chainId: StrictHexStruct, + // Optional so flags deployed before this field existed still validate. When + // present it lets the service skip the on-chain `Accountant.base()` read. + underlyingToken: optional(StrictHexStruct), }); /** diff --git a/packages/money-account-balance-service/src/types.ts b/packages/money-account-balance-service/src/types.ts index eef9a84f33..a47087bf54 100644 --- a/packages/money-account-balance-service/src/types.ts +++ b/packages/money-account-balance-service/src/types.ts @@ -10,4 +10,14 @@ export type VaultConfig = { accountantAddress: Hex; lensAddress: Hex; chainId: Hex; + /** + * Address of the vault's underlying ERC-20 asset (mUSD). + * + * Optional for backwards compatibility with flags deployed before this + * field existed. When present, it is used directly as the source of truth + * (the flag already moves in lockstep with the vault addresses), avoiding an + * on-chain `Accountant.base()` read on every mUSD balance fetch. When absent, + * the service falls back to reading `base()` on-chain. + */ + underlyingToken?: Hex; };