diff --git a/packages/money-account-api-data-service/CHANGELOG.md b/packages/money-account-api-data-service/CHANGELOG.md index eb37fcf0aa4..7d5bf714e09 100644 --- a/packages/money-account-api-data-service/CHANGELOG.md +++ b/packages/money-account-api-data-service/CHANGELOG.md @@ -13,6 +13,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +- Response validation now tolerates unknown fields returned by the Money Account API instead of throwing `MoneyAccountApiResponseValidationError`, so additive backend changes no longer break clients ([#10538](https://github.com/MetaMask/core/pull/10538)) - Bump `@tanstack/query-core` from `^5.89.0` to `^5.103.2` ([#10511](https://github.com/MetaMask/core/pull/10511)) ## [2.0.0] 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 b2b44981d19..69e5dc9fff7 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 @@ -1298,6 +1298,124 @@ describe('MoneyAccountApiDataService', () => { }); }); + describe('additive API fields', () => { + it('accepts unknown fields on positions responses without stripping them', async () => { + const { service } = createService(Env.DEV); + const responseWithExtras = { + ...MOCK_POSITION_RESPONSE, + future_top_level: 'ok', + balance: { + ...MOCK_POSITION_RESPONSE.balance, + future_balance_field: 1, + by_asset: [ + { + ...MOCK_POSITION_RESPONSE.balance.by_asset[0], + future_asset_field: true, + }, + ], + }, + positions: [ + { + ...MOCK_POSITION_RESPONSE.positions[0], + future_position_field: 'premium', + }, + ], + }; + + nock(MONEY_ACCOUNT_API_URL_MAP[Env.DEV]) + .get(`/v1/positions/${MOCK_ADDRESS}`) + .reply(200, responseWithExtras); + + const result = await service.fetchPositions(MOCK_ADDRESS); + expect(result).toStrictEqual(responseWithExtras); + service.destroy(); + }); + + it('accepts unknown fields on interest responses without stripping them', async () => { + const { service } = createService(Env.DEV); + const responseWithExtras = { + ...MOCK_INTEREST_RESPONSE, + future_interest_field: 'ok', + }; + + nock(MONEY_ACCOUNT_API_URL_MAP[Env.DEV]) + .get(`/v1/positions/${MOCK_ADDRESS}/interest`) + .query({ + vault_address: MOCK_VAULT_ADDRESS, + window: '7d', + }) + .reply(200, responseWithExtras); + + const result = await service.fetchInterest(MOCK_ADDRESS, { + vaultAddress: MOCK_VAULT_ADDRESS, + window: '7d', + }); + expect(result).toStrictEqual(responseWithExtras); + service.destroy(); + }); + + it('accepts unknown fields on history responses without stripping them', async () => { + const { service } = createService(Env.DEV); + const responseWithExtras = { + ...MOCK_HISTORY_RESPONSE, + future_history_field: 'ok', + cash_flows: [ + { + ...MOCK_HISTORY_RESPONSE.cash_flows[0], + future_cash_flow_field: 42, + }, + ], + }; + + nock(MONEY_ACCOUNT_API_URL_MAP[Env.DEV]) + .get(`/v1/positions/${MOCK_ADDRESS}/history`) + .reply(200, responseWithExtras); + + const result = await service.fetchHistory(MOCK_ADDRESS); + expect(result).toStrictEqual(responseWithExtras); + service.destroy(); + }); + + it('accepts unknown fields on rate-history responses without stripping them', async () => { + const { service } = createService(Env.DEV); + const responseWithExtras = { + ...MOCK_RATE_HISTORY_RESPONSE, + future_rate_history_field: 'ok', + rates: [ + { + ...MOCK_RATE_HISTORY_RESPONSE.rates[0], + future_rate_entry_field: true, + }, + MOCK_RATE_HISTORY_RESPONSE.rates[1], + ], + }; + + nock(MONEY_ACCOUNT_API_URL_MAP[Env.DEV]) + .get(`/v1/vaults/${MOCK_VAULT_ADDRESS}/rate-history`) + .reply(200, responseWithExtras); + + const result = await service.fetchRateHistory(MOCK_VAULT_ADDRESS); + expect(result).toStrictEqual(responseWithExtras); + service.destroy(); + }); + + it('accepts unknown fields on vault-rate responses without stripping them', async () => { + const { service } = createService(Env.DEV); + const responseWithExtras = { + ...MOCK_VAULT_RATE_RESPONSE, + future_vault_rate_field: 'ok', + }; + + nock(MONEY_ACCOUNT_API_URL_MAP[Env.DEV]) + .get(`/v1/vaults/${MOCK_VAULT_ADDRESS}/rate`) + .reply(200, responseWithExtras); + + const result = await service.fetchVaultRate(MOCK_VAULT_ADDRESS); + expect(result).toStrictEqual(responseWithExtras); + service.destroy(); + }); + }); + describe('invalidateQueries', () => { it('invalidates cached queries', async () => { const { service } = createService(Env.DEV); diff --git a/packages/money-account-api-data-service/src/structs.ts b/packages/money-account-api-data-service/src/structs.ts index 995b59b5e40..a6f5e042b2d 100644 --- a/packages/money-account-api-data-service/src/structs.ts +++ b/packages/money-account-api-data-service/src/structs.ts @@ -4,14 +4,18 @@ import { enums, nullable, number, - object, optional, string, + type, } from '@metamask/superstruct'; +// Every schema here uses `type()` rather than `object()` so that fields the +// Money Account API adds later pass validation instead of throwing. Additive +// backend changes should not be breaking changes for clients. + const DataFreshnessStruct = enums(['live', 'degraded']); -const VaultPositionStruct = object({ +const VaultPositionStruct = type({ chain_id: number(), vault_key: string(), name: string(), @@ -35,7 +39,7 @@ const VaultPositionStruct = object({ /** * One underlying asset in the positions balance breakdown. */ -const AssetBalanceStruct = object({ +const AssetBalanceStruct = type({ asset_contract_address: string(), asset_symbol: string(), asset_decimals: number(), @@ -52,7 +56,7 @@ const AssetBalanceStruct = object({ * `musd_balance`, `vmusd_value_in_musd`, and `total_balance` are mUSD-only * and deprecated on the API in favor of `by_asset` and `total_balance_usd`. */ -const PositionBalanceStruct = object({ +const PositionBalanceStruct = type({ musd_balance: string(), vmusd_value_in_musd: string(), total_balance: string(), @@ -62,7 +66,7 @@ const PositionBalanceStruct = object({ total_balance_usd: string(), }); -export const PositionResponseStruct = object({ +export const PositionResponseStruct = type({ address: string(), as_of_block: number(), as_of_timestamp: string(), @@ -74,7 +78,7 @@ export const PositionResponseStruct = object({ positions: array(VaultPositionStruct), }); -export const InterestResponseStruct = object({ +export const InterestResponseStruct = type({ address: string(), vault_address: string(), window: string(), @@ -89,7 +93,7 @@ export const InterestResponseStruct = object({ indexer_lag_seconds: number(), }); -const CashFlowEntryStruct = object({ +const CashFlowEntryStruct = type({ type: enums(['deposit', 'withdraw', 'transfer_in', 'transfer_out']), chain_id: number(), vault_address: string(), @@ -110,7 +114,7 @@ const CashFlowEntryStruct = object({ ]), }); -export const HistoryResponseStruct = object({ +export const HistoryResponseStruct = type({ address: string(), cash_flows: array(CashFlowEntryStruct), next_cursor: nullable(string()), @@ -121,14 +125,14 @@ export const HistoryResponseStruct = object({ indexer_lag_seconds: number(), }); -const RateHistoryEntryStruct = object({ +const RateHistoryEntryStruct = type({ timestamp: string(), block_number: number(), rate: string(), tx_hash: string(), }); -export const RateHistoryResponseStruct = object({ +export const RateHistoryResponseStruct = type({ vault_address: string(), chain_id: number(), range_start: string(), @@ -140,7 +144,7 @@ export const RateHistoryResponseStruct = object({ indexer_lag_seconds: number(), }); -export const VaultRateResponseStruct = object({ +export const VaultRateResponseStruct = type({ vault_address: string(), chain_id: number(), rate: string(),