diff --git a/packages/keyring-api/CHANGELOG.md b/packages/keyring-api/CHANGELOG.md index c7b3cfff3..e9eec2723 100644 --- a/packages/keyring-api/CHANGELOG.md +++ b/packages/keyring-api/CHANGELOG.md @@ -19,6 +19,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +- **BREAKING**: Add required `rawAmount` field to `Balance` and `FungibleAssetAmount` types for precision in balance calculations ([#426](https://github.com/MetaMask/accounts/pull/426)) - Refine `EthAddressStruct` in order to make it compatible with the `Hex` type from `@metamask/utils` ([#405](https://github.com/MetaMask/accounts/pull/405)) ## [21.3.0] diff --git a/packages/keyring-api/src/api/asset.test-d.ts b/packages/keyring-api/src/api/asset.test-d.ts index f150b7210..209f25b8d 100644 --- a/packages/keyring-api/src/api/asset.test-d.ts +++ b/packages/keyring-api/src/api/asset.test-d.ts @@ -2,37 +2,54 @@ import { expectAssignable, expectNotAssignable } from 'tsd'; import type { Asset } from './asset'; +// Valid fungible asset expectAssignable({ fungible: true, type: 'eip155:1/slip44:60', unit: 'ETH', amount: '0.01', + rawAmount: '10000000000000000', }); +// Valid non-fungible asset expectAssignable({ fungible: false, id: 'hedera:mainnet/nft:0.0.55492/12', }); +// Missing rawAmount for fungible asset expectNotAssignable({ fungible: true, type: 'eip155:1/slip44:60', unit: 'ETH', + amount: '0.01', +}); + +// Fungible asset with non-fungible id field +expectNotAssignable({ + fungible: true, + type: 'eip155:1/slip44:60', + unit: 'ETH', + amount: '0.01', + rawAmount: '10000000000000000', id: 'hedera:mainnet/nft:0.0.55492/12', }); +// Non-fungible asset with unit field expectNotAssignable({ fungible: false, id: 'hedera:mainnet/nft:0.0.55492/12', unit: 'ETH', }); +// Non-fungible asset with type and unit expectNotAssignable({ fungible: false, type: 'eip155:1/slip44:60', unit: 'ETH', }); +// Fungible asset with id instead of type expectNotAssignable({ fungible: true, id: 'hedera:mainnet/nft:0.0.55492/12', diff --git a/packages/keyring-api/src/api/asset.test.ts b/packages/keyring-api/src/api/asset.test.ts index 572086554..c2477820b 100644 --- a/packages/keyring-api/src/api/asset.test.ts +++ b/packages/keyring-api/src/api/asset.test.ts @@ -6,7 +6,7 @@ describe('AssetStruct', () => { it.each([ // Missing `fungible` { asset: {}, expected: false }, - // Valid + // Valid non-fungible asset { asset: { fungible: false, @@ -14,19 +14,19 @@ describe('AssetStruct', () => { }, expected: true, }, - // Missing `unit` and `amount` + // Missing `unit`, `amount`, and `rawAmount` { asset: { fungible: true, type: 'eip155:1/slip44:60' }, expected: false }, - // Missing `amount` + // Missing `amount` and `rawAmount` { asset: { fungible: true, type: 'eip155:1/slip44:60', unit: 'ETH' }, expected: false, }, - // Missing `unit` + // Missing `unit` and `rawAmount` { asset: { fungible: true, type: 'eip155:1/slip44:60', amount: '0.01' }, expected: false, }, - // Valid + // Missing `rawAmount` { asset: { fungible: true, @@ -34,6 +34,17 @@ describe('AssetStruct', () => { unit: 'ETH', amount: '0.01', }, + expected: false, + }, + // Valid fungible asset + { + asset: { + fungible: true, + type: 'eip155:1/slip44:60', + unit: 'ETH', + amount: '0.01', + rawAmount: '10000000000000000', + }, expected: true, }, ])('returns $expected for is($asset, AssetStruct)', ({ asset, expected }) => { diff --git a/packages/keyring-api/src/api/asset.ts b/packages/keyring-api/src/api/asset.ts index 154a33905..0f68e3cd4 100644 --- a/packages/keyring-api/src/api/asset.ts +++ b/packages/keyring-api/src/api/asset.ts @@ -16,14 +16,21 @@ import { */ export const FungibleAssetAmountStruct = object({ /** - * Asset unit. + * Asset unit (symbol). */ unit: string(), /** - * Asset amount. + * The human-readable asset amount with decimals applied (e.g., "1.5" for 1.5 SOL). + * This is kept for backward compatibility with existing consumers. */ amount: StringNumberStruct, + + /** + * The raw blockchain balance without decimals applied (e.g., "1500000000" for 1.5 SOL). + * This provides precision for calculations using BigInt/BigNumber. + */ + rawAmount: string(), }); /** @@ -83,6 +90,7 @@ export const NonFungibleAssetStruct = object({ * type: 'eip155:1/slip44:60', * unit: 'ETH', * amount: '0.01', + * rawAmount: '10000000000000000', * } * ``` * diff --git a/packages/keyring-api/src/api/balance.test-d.ts b/packages/keyring-api/src/api/balance.test-d.ts index ccb0cc662..de2734bdb 100644 --- a/packages/keyring-api/src/api/balance.test-d.ts +++ b/packages/keyring-api/src/api/balance.test-d.ts @@ -2,18 +2,86 @@ import { expectAssignable, expectNotAssignable } from 'tsd'; import type { Balance } from './balance'; -expectAssignable({ amount: '1.0', unit: 'ETH' }); -expectAssignable({ amount: '0.1', unit: 'BTC' }); -expectAssignable({ amount: '.1', unit: 'gwei' }); -expectAssignable({ amount: '.1', unit: 'wei' }); -expectAssignable({ amount: '1.', unit: 'sat' }); +// Valid balances with all required fields +expectAssignable({ + amount: '1.0', + unit: 'ETH', + rawAmount: '1000000000000000000', +}); +expectAssignable({ + amount: '0.1', + unit: 'BTC', + rawAmount: '10000000', +}); +expectAssignable({ + amount: '.1', + unit: 'gwei', + rawAmount: '100000000', +}); +expectAssignable({ + amount: '.1', + unit: 'wei', + rawAmount: '100000000', +}); +expectAssignable({ + amount: '1.', + unit: 'sat', + rawAmount: '100000000', +}); -expectNotAssignable({ amount: 1, unit: 'ETH' }); -expectNotAssignable({ amount: true, unit: 'ETH' }); -expectNotAssignable({ amount: undefined, unit: 'ETH' }); -expectNotAssignable({ amount: null, unit: 'ETH' }); +// Missing rawAmount +expectNotAssignable({ amount: '1.0', unit: 'ETH' }); -expectNotAssignable({ amount: '1.0', unit: 1 }); -expectNotAssignable({ amount: '1.0', unit: true }); -expectNotAssignable({ amount: '1.0', unit: undefined }); -expectNotAssignable({ amount: '1.0', unit: null }); +// Invalid amount types +expectNotAssignable({ + amount: 1, + unit: 'ETH', + rawAmount: '1000000000000000000', +}); +expectNotAssignable({ + amount: true, + unit: 'ETH', + rawAmount: '1000000000000000000', +}); +expectNotAssignable({ + amount: undefined, + unit: 'ETH', + rawAmount: '1000000000000000000', +}); +expectNotAssignable({ + amount: null, + unit: 'ETH', + rawAmount: '1000000000000000000', +}); + +// Invalid unit types +expectNotAssignable({ + amount: '1.0', + unit: 1, + rawAmount: '1000000000000000000', +}); +expectNotAssignable({ + amount: '1.0', + unit: true, + rawAmount: '1000000000000000000', +}); +expectNotAssignable({ + amount: '1.0', + unit: undefined, + rawAmount: '1000000000000000000', +}); +expectNotAssignable({ + amount: '1.0', + unit: null, + rawAmount: '1000000000000000000', +}); + +// Invalid rawAmount types +expectNotAssignable({ amount: '1.0', unit: 'ETH', rawAmount: 1 }); +expectNotAssignable({ amount: '1.0', unit: 'ETH', rawAmount: true }); +expectNotAssignable({ + amount: '1.0', + unit: 'ETH', + rawAmount: undefined, +}); +expectNotAssignable({ amount: '1.0', unit: 'ETH', rawAmount: null }); diff --git a/packages/keyring-api/src/api/balance.test.ts b/packages/keyring-api/src/api/balance.test.ts index 27ce466fb..4bff25248 100644 --- a/packages/keyring-api/src/api/balance.test.ts +++ b/packages/keyring-api/src/api/balance.test.ts @@ -5,20 +5,62 @@ import { BalanceStruct } from './balance'; describe('BalanceStruct', () => { it.each([ // Valid - { balance: { amount: '1.0', unit: 'ETH' }, expected: true }, - { balance: { amount: '0.1', unit: 'BTC' }, expected: true }, + { + balance: { amount: '1.0', unit: 'ETH', rawAmount: '1000000000000000000' }, + expected: true, + }, + { + balance: { amount: '0.1', unit: 'BTC', rawAmount: '10000000' }, + expected: true, + }, // Missing amount - { balance: { unit: 'ETH' }, expected: false }, + { + balance: { unit: 'ETH', rawAmount: '1000000000000000000' }, + expected: false, + }, // Missing unit - { balance: { amount: '1.0' }, expected: false }, + { + balance: { amount: '1.0', rawAmount: '1000000000000000000' }, + expected: false, + }, + // Missing rawAmount + { balance: { amount: '1.0', unit: 'ETH' }, expected: false }, // Invalid amount type - { balance: { amount: 1, unit: 'ETH' }, expected: false }, - { balance: { amount: true, unit: 'ETH' }, expected: false }, - { balance: { amount: null, unit: 'ETH' }, expected: false }, + { + balance: { amount: 1, unit: 'ETH', rawAmount: '1000000000000000000' }, + expected: false, + }, + { + balance: { amount: true, unit: 'ETH', rawAmount: '1000000000000000000' }, + expected: false, + }, + { + balance: { amount: null, unit: 'ETH', rawAmount: '1000000000000000000' }, + expected: false, + }, // Invalid unit type - { balance: { amount: '1.0', unit: 1 }, expected: false }, - { balance: { amount: '1.0', unit: true }, expected: false }, - { balance: { amount: '1.0', unit: null }, expected: false }, + { + balance: { amount: '1.0', unit: 1, rawAmount: '1000000000000000000' }, + expected: false, + }, + { + balance: { amount: '1.0', unit: true, rawAmount: '1000000000000000000' }, + expected: false, + }, + { + balance: { amount: '1.0', unit: null, rawAmount: '1000000000000000000' }, + expected: false, + }, + // Invalid rawAmount type + { balance: { amount: '1.0', unit: 'ETH', rawAmount: 1 }, expected: false }, + { + balance: { amount: '1.0', unit: 'ETH', rawAmount: true }, + expected: false, + }, + { + balance: { amount: '1.0', unit: 'ETH', rawAmount: null }, + expected: false, + }, ])( 'returns $expected for is($balance, BalanceStruct)', ({ balance, expected }) => { diff --git a/packages/keyring-api/src/api/balance.ts b/packages/keyring-api/src/api/balance.ts index 32b7573f8..cc636c22e 100644 --- a/packages/keyring-api/src/api/balance.ts +++ b/packages/keyring-api/src/api/balance.ts @@ -3,8 +3,20 @@ import type { Infer } from '@metamask/superstruct'; import { string } from '@metamask/superstruct'; export const BalanceStruct = object({ + /** + * The human-readable balance amount with decimals applied (e.g., "1.5" for 1.5 SOL). + * This is kept for backward compatibility with existing consumers. + */ amount: StringNumberStruct, + /** + * The token/asset symbol or unit (e.g., "SOL", "TRX"). + */ unit: string(), + /** + * The raw blockchain balance without decimals applied (e.g., "1500000000" for 1.5 SOL). + * This provides precision for calculations using BigInt/BigNumber. + */ + rawAmount: string(), }); export type Balance = Infer; diff --git a/packages/keyring-api/src/api/transaction.test-d.ts b/packages/keyring-api/src/api/transaction.test-d.ts index 0b5d8e2e2..9bf7e062d 100644 --- a/packages/keyring-api/src/api/transaction.test-d.ts +++ b/packages/keyring-api/src/api/transaction.test-d.ts @@ -32,6 +32,7 @@ expectAssignable({ type: 'eip155:1/slip44:60', unit: 'ETH', amount: '0.0001', + rawAmount: '100000000000000', }, }, { @@ -41,6 +42,7 @@ expectAssignable({ type: 'eip155:1/slip44:60', unit: 'ETH', amount: '0.0001', + rawAmount: '100000000000000', }, }, ], @@ -62,6 +64,7 @@ expectAssignable({ type: 'bip122:000000000019d6689c085ae165831e93/slip44:0', unit: 'BTC', amount: '0.002', + rawAmount: '200000', }, }, { @@ -80,6 +83,7 @@ expectAssignable({ type: 'bip122:000000000019d6689c085ae165831e93/slip44:0', unit: 'BTC', amount: '0.001', + rawAmount: '100000', }, }, { @@ -98,6 +102,7 @@ expectAssignable({ type: 'bip122:000000000019d6689c085ae165831e93/slip44:0', unit: 'BTC', amount: '0.001', + rawAmount: '100000', }, }, ], diff --git a/packages/keyring-api/src/api/transaction.ts b/packages/keyring-api/src/api/transaction.ts index 3d3f38566..9fc1070a8 100644 --- a/packages/keyring-api/src/api/transaction.ts +++ b/packages/keyring-api/src/api/transaction.ts @@ -19,6 +19,7 @@ import type { Paginated } from './pagination'; * type: 'eip155:1/slip44:60', * unit: 'ETH', * amount: '0.01', + * rawAmount: '10000000000000000', * }, * }, * ``` @@ -211,6 +212,7 @@ export const TransactionEventStruct = object({ * type: 'bip122:000000000019d6689c085ae165831e93/slip44:0', * unit: 'BTC', * amount: '0.1', + * rawAmount: '10000000', * }, * }, * ], @@ -222,6 +224,7 @@ export const TransactionEventStruct = object({ * type: 'bip122:000000000019d6689c085ae165831e93/slip44:0', * unit: 'BTC', * amount: '0.1', + * rawAmount: '10000000', * }, * }, * { @@ -231,6 +234,7 @@ export const TransactionEventStruct = object({ * type: 'bip122:000000000019d6689c085ae165831e93/slip44:0', * unit: 'BTC', * amount: '0.1', + * rawAmount: '10000000', * }, * }, * ], @@ -242,6 +246,7 @@ export const TransactionEventStruct = object({ * type: 'bip122:000000000019d6689c085ae165831e93/slip44:0', * unit: 'BTC', * amount: '0.1', + * rawAmount: '10000000', * }, * }, * ], diff --git a/packages/keyring-api/src/events.test-d.ts b/packages/keyring-api/src/events.test-d.ts index 72eee185f..2abc355f1 100644 --- a/packages/keyring-api/src/events.test-d.ts +++ b/packages/keyring-api/src/events.test-d.ts @@ -209,6 +209,7 @@ expectAssignable>({ 'bip122:000000000019d6689c085ae165831e93/slip44:0': { amount: '0.0001', unit: 'BTC', + rawAmount: '10000', }, }, }, @@ -220,6 +221,7 @@ expectNotAssignable>({ 'bip122:000000000019d6689c085ae165831e93/slip44:0': { amount: '0.0001', unit: 'BTC', + rawAmount: '10000', }, }, }); @@ -230,6 +232,7 @@ expectNotAssignable>({ 'bip122:000000000019d6689c085ae165831e93/slip44:0': { amount: '0.0001', unit: 'BTC', + rawAmount: '10000', }, }, }); @@ -241,6 +244,19 @@ expectNotAssignable>({ bitcoin: { amount: '0.0001', unit: 'BTC', + rawAmount: '10000', + }, + }, + }, +}); + +expectNotAssignable>({ + balances: { + '11027d05-12f8-4ec0-b03f-151d86a8089e': { + 'bip122:000000000019d6689c085ae165831e93/slip44:0': { + // Missing `rawAmount` + amount: '0.0001', + unit: 'BTC', }, }, }, diff --git a/packages/keyring-snap-bridge/src/SnapKeyring.test.ts b/packages/keyring-snap-bridge/src/SnapKeyring.test.ts index 7db510cfb..4b0e446f6 100644 --- a/packages/keyring-snap-bridge/src/SnapKeyring.test.ts +++ b/packages/keyring-snap-bridge/src/SnapKeyring.test.ts @@ -681,6 +681,7 @@ describe('SnapKeyring', () => { 'bip122:000000000019d6689c085ae165831e93/slip44:0': { amount: '0.1', unit: 'BTC', + rawAmount: '10000000', }, }, }, @@ -706,6 +707,7 @@ describe('SnapKeyring', () => { 'bip122:000000000019d6689c085ae165831e93/slip44:0': { amount: '0.1', unit: 'BTC', + rawAmount: '10000000', }, }; const event: AccountBalancesUpdatedEventPayload = { @@ -761,6 +763,7 @@ describe('SnapKeyring', () => { type: 'eip155:1/slip44:60', unit: 'ETH', amount: '0.0001', + rawAmount: '100000000000000', }, }, { @@ -770,6 +773,7 @@ describe('SnapKeyring', () => { type: 'eip155:1/slip44:60', unit: 'ETH', amount: '0.0001', + rawAmount: '100000000000000', }, }, ], @@ -813,6 +817,7 @@ describe('SnapKeyring', () => { type: 'eip155:1/slip44:60', unit: 'ETH', amount: '0.0001', + rawAmount: '100000000000000', }, }, { @@ -822,6 +827,7 @@ describe('SnapKeyring', () => { type: 'eip155:1/slip44:60', unit: 'ETH', amount: '0.0001', + rawAmount: '100000000000000', }, }, ], diff --git a/packages/keyring-snap-client/src/KeyringClient.test.ts b/packages/keyring-snap-client/src/KeyringClient.test.ts index 71ed612b3..67d4b18f1 100644 --- a/packages/keyring-snap-client/src/KeyringClient.test.ts +++ b/packages/keyring-snap-client/src/KeyringClient.test.ts @@ -431,6 +431,7 @@ describe('KeyringClient', () => { type: 'eip155:1/slip44:60', unit: 'ETH', amount: 'invalid-amount', // Should be a numeric string + rawAmount: '100000000000000', }, }, ], @@ -554,6 +555,7 @@ describe('KeyringClient', () => { [assets[0] as string]: { amount: '1234', unit: 'sat', + rawAmount: '1234', }, }; @@ -579,6 +581,7 @@ describe('KeyringClient', () => { [assets[0] as string]: { amount: 1234, // Should be a `StringNumber` unit: 'sat', + rawAmount: '1234', }, }; @@ -597,6 +600,7 @@ describe('KeyringClient', () => { [assets[0] as string]: { amount: 'not-a-string-number', // Should be a `StringNumber` unit: 'sat', + rawAmount: '1234', }, }; @@ -605,6 +609,25 @@ describe('KeyringClient', () => { 'At path: bip122:000000000019d6689c085ae165831e93/slip44:0.amount -- Expected a value of type `StringNumber`, but received: `"not-a-string-number"`', ); }); + + it('throws an error because the rawAmount is missing', async () => { + const assets: CaipAssetType[] = [ + 'bip122:000000000019d6689c085ae165831e93/slip44:0', + ]; + const id = '1617ea08-d4b6-48bf-ba83-901ef1e45ed7'; + const expectedResponse = { + [assets[0] as string]: { + amount: '1234', + unit: 'sat', + // Missing rawAmount + }, + }; + + mockSender.send.mockResolvedValue(expectedResponse); + await expect(client.getAccountBalances(id, assets)).rejects.toThrow( + 'At path: bip122:000000000019d6689c085ae165831e93/slip44:0.rawAmount -- Expected a string, but received: undefined', + ); + }); }); describe('resolveAccountAddress', () => {