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
4 changes: 4 additions & 0 deletions packages/assets-controller/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added

- Add optional `metadata` on fungible `assetsBalance` entries from the Accounts API and Account Activity websocket ([#10194](https://github.com/MetaMask/core/pull/10194))

### Changed

- Bump `@metamask/account-tree-controller` from `^10.0.0` to `^10.0.1` ([#10166](https://github.com/MetaMask/core/pull/10166))
Expand Down
33 changes: 33 additions & 0 deletions packages/assets-controller/src/AssetsController.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2764,6 +2764,39 @@ describe('AssetsController', () => {
});
});

it('keeps existing metadata when a merge update omits it', async () => {
const stellarMetadata = {
spendableBalance: '8944804518',
minimumReserveBalance: '200000000',
decimal: 7,
};
const initialState: Partial<AssetsControllerState> = {
assetsBalance: {
[MOCK_ACCOUNT_ID]: {
[MOCK_ASSET_ID]: { amount: '1', metadata: stellarMetadata },
},
},
};

await withController({ state: initialState }, async ({ controller }) => {
await controller.handleAssetsUpdate(
{
updateMode: 'merge',
assetsBalance: {
[MOCK_ACCOUNT_ID]: {
[MOCK_ASSET_ID]: { amount: '2' },
},
},
},
'TestSource',
);

expect(
controller.state.assetsBalance[MOCK_ACCOUNT_ID]?.[MOCK_ASSET_ID],
).toStrictEqual({ amount: '2', metadata: stellarMetadata });
});
});

it('updates state from AccountActivityService:balanceUpdated', async () => {
const arbNative = 'eip155:42161/slip44:60' as Caip19AssetId;
const initialState: Partial<AssetsControllerState> = {
Expand Down
9 changes: 8 additions & 1 deletion packages/assets-controller/src/AssetsController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2814,7 +2814,14 @@ export class AssetsController extends BaseController<
(balance as { amount: unknown }).amount,
assetDecimals,
);
effective[assetId] = { ...balance, amount: newAmount };
// Keep existing metadata when the incoming update is
// amount-only (e.g. Account Activity websocket). Incoming
// metadata still wins when present.
effective[assetId] = {
...previousBalance,
...balance,
amount: newAmount,
};
const oldAmount = previousBalance?.amount;
const isNewDefaultNativeZero =
oldAmount === undefined &&
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -341,6 +341,55 @@ describe('AccountActivityDataSource', () => {
cleanup();
});

it('persists Stellar trustline metadata from postBalance', async () => {
const STELLAR_CHAIN = 'stellar:pubnet' as ChainId;
const STELLAR_ADDRESS =
'GCRTHNJHYCV4F4JOAIMUE2ALYPE3C7Q53XTSUVGYJ4UXYIKWZAK7FWPG';
const STELLAR_USDC =
'stellar:pubnet/asset:USDC-GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN' as Caip19AssetId;
const trustlineMetadata = {
authorized: true,
limit: '9223372036854775807',
};
const account = createMockAccount({
address: STELLAR_ADDRESS,
type: 'stellar:ss58',
scopes: [STELLAR_CHAIN],
});
const { onAssetsUpdate, triggerBalanceUpdated, cleanup } = setup({
groupAccounts: [account],
});

triggerBalanceUpdated({
address: STELLAR_ADDRESS,
chain: STELLAR_CHAIN,
updates: [
createBalanceUpdate({
asset: {
type: STELLAR_USDC,
unit: 'USDC',
decimals: 7,
},
postBalance: {
amount: '201421',
metadata: trustlineMetadata,
},
}),
],
});

await Promise.resolve();

expect(onAssetsUpdate).toHaveBeenCalledTimes(1);
const [response] = onAssetsUpdate.mock.calls[0];
expect(response.assetsBalance[account.id][STELLAR_USDC]).toStrictEqual({
amount: '0.0201421',
metadata: trustlineMetadata,
});

cleanup();
});

it('resolves the asset type via the injected getAssetType', async () => {
const { getAssetType, triggerBalanceUpdated, cleanup } = setup({
getAssetType: () => 'erc20',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,7 @@ function processAccountActivityBalanceUpdates(

assetsBalance[accountId][assetId] = {
amount: humanReadableAmount,
...(postBalance.metadata ? { metadata: postBalance.metadata } : {}),
};

assetsMetadata[assetId] = {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -93,17 +93,31 @@ function createMockV6BalanceItem(
assetId: string,
balance: string,
object: 'token' | 'defi' = 'token',
type: 'native' | 'erc20' = 'erc20',
type: string = 'erc20',
metadata?: V6BalanceItem['metadata'],
): V6BalanceItem {
return { accountId, object, type, assetId, balance } as V6BalanceItem;
return {
accountId,
object,
type,
assetId,
balance,
...(metadata ? { metadata } : {}),
} as V6BalanceItem;
}

function createMockBalanceItem(
accountId: string,
assetId: string,
balance: string,
metadata?: V5BalanceItem['metadata'],
): V5BalanceItem {
return { accountId, assetId, balance } as V5BalanceItem;
return {
accountId,
assetId,
balance,
...(metadata ? { metadata } : {}),
} as V5BalanceItem;
}

function createDataRequest(
Expand Down Expand Up @@ -636,6 +650,80 @@ describe('AccountsApiDataSource', () => {
controller.destroy();
});

it('fetch persists Stellar native and trustline metadata from v5 balances', async () => {
const STELLAR_CHAIN_ID = 'stellar:pubnet' as ChainId;
const stellarAddress =
'GCRTHNJHYCV4F4JOAIMUE2ALYPE3C7Q53XTSUVGYJ4UXYIKWZAK7FWPG';
const nativeAssetId = 'stellar:pubnet/slip44:148';
const usdcAssetId =
'stellar:pubnet/asset:USDC-GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN';
const nativeMetadata = {
spendableBalance: '8944804518',
minimumReserveBalance: '200000000',
decimal: 7,
};
const trustlineMetadata = {
limit: '9223372036854775807',
authorized: true,
sponsored: false,
};

const { controller } = await setupController({
supportedChains: [1, STELLAR_CHAIN_ID as unknown as number],
remoteFeatureFlags: {
[SNAPS_ASSETS_MIGRATION_FLAG_KEYS.stellar]: {
stage: SnapsAssetsMigrationStage.ReadAssetsControllerWithFallback,
},
},
balances: [
createMockBalanceItem(
`${STELLAR_CHAIN_ID}:${stellarAddress}`,
nativeAssetId,
'914.4804518',
nativeMetadata,
),
createMockBalanceItem(
`${STELLAR_CHAIN_ID}:${stellarAddress}`,
usdcAssetId,
'0',
trustlineMetadata,
),
],
});

const response = await controller.fetch(
createDataRequest({
chainIds: [STELLAR_CHAIN_ID],
accounts: [
createMockAccount({
address: stellarAddress,
type: 'stellar:ss58',
scopes: [STELLAR_CHAIN_ID],
}),
],
}),
);

expect(
response.assetsBalance?.['mock-account-id']?.[
nativeAssetId as Caip19AssetId
],
).toStrictEqual({
amount: '914.4804518',
metadata: nativeMetadata,
});
expect(
response.assetsBalance?.['mock-account-id']?.[
usdcAssetId as Caip19AssetId
],
).toStrictEqual({
amount: '0',
metadata: trustlineMetadata,
});

controller.destroy();
});

it('excludes staking contract asset IDs from v5 balance response', async () => {
const stakingAssetId =
'eip155:1/erc20:0x4fef9d741011476750a243ac70b9789a63dd47df';
Expand Down Expand Up @@ -826,6 +914,85 @@ describe('AccountsApiDataSource', () => {
controller.destroy();
});

it('persists Stellar native and trustline metadata from v6 balances', async () => {
const STELLAR_CHAIN_ID = 'stellar:pubnet' as ChainId;
const stellarAddress =
'GDZRSRB4DOK3372HO2OKYVJKGTL5MYF5VUSO5CD5CJJNQVG35HMBQT6U';
const nativeAssetId = 'stellar:pubnet/slip44:148';
const aquaAssetId =
'stellar:pubnet/asset:AQUA-GBNZILSTVQZ4R7IKQDGHYGY2QXL5QOFJYQMXPKWRRM5PAV7Y4M67AQUA';
const nativeMetadata = {
spendableBalance: '8944803018',
minimumReserveBalance: '200000000',
decimal: 7,
};
const trustlineMetadata = {
limit: '9223372036854775807',
authorized: true,
sponsored: false,
};

const { controller } = await setupController({
supportedChains: [1, STELLAR_CHAIN_ID as unknown as number],
remoteFeatureFlags: {
assetsAccountsApiV6: { value: true },
[SNAPS_ASSETS_MIGRATION_FLAG_KEYS.stellar]: {
stage: SnapsAssetsMigrationStage.ReadAssetsControllerWithFallback,
},
},
v6Balances: [
createMockV6BalanceItem(
`${STELLAR_CHAIN_ID}:${stellarAddress}`,
aquaAssetId,
'0',
'token',
'token',
trustlineMetadata,
),
createMockV6BalanceItem(
`${STELLAR_CHAIN_ID}:${stellarAddress}`,
nativeAssetId,
'914.4803018',
'token',
'native',
nativeMetadata,
),
],
});

const response = await controller.fetch(
createDataRequest({
chainIds: [STELLAR_CHAIN_ID],
accounts: [
createMockAccount({
address: stellarAddress,
type: 'stellar:ss58',
scopes: [STELLAR_CHAIN_ID],
}),
],
}),
);

expect(
response.assetsBalance?.['mock-account-id']?.[
aquaAssetId as Caip19AssetId
],
).toStrictEqual({
amount: '0',
metadata: trustlineMetadata,
});
expect(
response.assetsBalance?.['mock-account-id']?.[
nativeAssetId as Caip19AssetId
],
).toStrictEqual({
amount: '914.4803018',
metadata: nativeMetadata,
});

controller.destroy();
});

it('ignores v6 defi positions', async () => {
const accountId = `eip155:1:${MOCK_ADDRESS}`;
const { controller } = await setupController({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import type {
RemoteFeatureFlagControllerGetStateAction,
RemoteFeatureFlagControllerStateChangeEvent,
} from '@metamask/remote-feature-flag-controller';
import type { Json } from '@metamask/utils';
import {
isCaipChainId,
KnownCaipNamespace,
Expand Down Expand Up @@ -660,9 +661,11 @@ export class AccountsApiDataSource extends AbstractDataSource<
continue;
}

// Store balance as returned by API
// Store balance as returned by API, along with any network-specific
// metadata (e.g. Stellar trustline / native reserve fields).
assetsBalance[accountId][normalizedAssetId] = {
amount: item.balance,
...(item.metadata ? { metadata: item.metadata as Json } : {}),
};
}

Expand Down Expand Up @@ -730,9 +733,11 @@ export class AccountsApiDataSource extends AbstractDataSource<
continue;
}

// Store balance as returned by API
// Store balance as returned by API, along with any network-specific
// metadata (e.g. Stellar trustline / native reserve fields).
assetsBalance[accountId][normalizedAssetId] = {
amount: item.balance,
...(item.metadata ? { metadata: item.metadata as Json } : {}),
};
}

Expand Down
6 changes: 6 additions & 0 deletions packages/assets-controller/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -276,6 +276,12 @@ export type AssetPrice = FungibleAssetPrice | NFTAssetPrice;
export type FungibleAssetBalance = {
/** Raw balance amount as string (e.g., "1000000000" for 1000 USDC) */
amount: string;
/**
* Network-specific balance fields, when the source provides them. Stellar
* native rows carry `spendableBalance` / `minimumReserveBalance`, and
* trustlines carry `limit` / `authorized` / `sponsored`.
*/
metadata?: Json;
};

/**
Expand Down
5 changes: 5 additions & 0 deletions packages/core-backend/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added

- Add optional `metadata` to `V5BalanceItem` and to the Account Activity `Balance` type ([#10194](https://github.com/MetaMask/core/pull/10194))
- Add optional `sponsored`, `spendableBalance`, and `minimumReserveBalance` Stellar fields to `V6TokenMetadata` ([#10194](https://github.com/MetaMask/core/pull/10194))

### Changed

- **BREAKING:** Accounts API `V2SupportedNetworksResponse` now uses CAIP-2 string arrays for both `fullSupport` and `partialSupport`, replacing decimal `fullSupport` and object-shaped `partialSupport.balances` ([#10144](https://github.com/MetaMask/core/pull/10144))
Expand Down
Loading