diff --git a/src/api/balance.test-d.ts b/src/api/balance.test-d.ts new file mode 100644 index 000000000..ccb0cc662 --- /dev/null +++ b/src/api/balance.test-d.ts @@ -0,0 +1,19 @@ +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' }); + +expectNotAssignable({ amount: 1, unit: 'ETH' }); +expectNotAssignable({ amount: true, unit: 'ETH' }); +expectNotAssignable({ amount: undefined, unit: 'ETH' }); +expectNotAssignable({ amount: null, 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 }); diff --git a/src/api/balance.ts b/src/api/balance.ts new file mode 100644 index 000000000..4a5925921 --- /dev/null +++ b/src/api/balance.ts @@ -0,0 +1,12 @@ +import type { Infer } from 'superstruct'; +import { string } from 'superstruct'; + +import { object } from '../superstruct'; +import { StringNumberStruct } from '../utils'; + +export const BalanceStruct = object({ + amount: StringNumberStruct, + unit: string(), +}); + +export type Balance = Infer; diff --git a/src/api/index.ts b/src/api/index.ts index e1ca839a9..c0c60a360 100644 --- a/src/api/index.ts +++ b/src/api/index.ts @@ -1,4 +1,5 @@ export * from './account'; +export * from './balance'; export * from './export'; export * from './keyring'; export * from './request'; diff --git a/src/api/keyring.ts b/src/api/keyring.ts index de3b0bd13..ad1274f60 100644 --- a/src/api/keyring.ts +++ b/src/api/keyring.ts @@ -1,6 +1,8 @@ import type { Json } from '@metamask/utils'; +import type { CaipAssetType } from '../utils'; import type { KeyringAccount } from './account'; +import type { Balance } from './balance'; import type { KeyringAccountData } from './export'; import type { KeyringRequest } from './request'; import type { KeyringResponse } from './response'; @@ -44,6 +46,38 @@ export type Keyring = { */ createAccount(options?: Record): Promise; + /** + * Retrieve the balances of a given account. + * + * This method fetches the balances of specified assets for a given account + * ID. It returns a promise that resolves to an object where the keys are + * asset types and the values are balance objects containing the amount and + * unit. + * + * @example + * ```ts + * await keyring.getAccountBalances( + * '43550276-c7d6-4fac-87c7-00390ad0ce90', + * ['bip122:000000000019d6689c085ae165831e93/slip44:0'] + * ); + * // Returns something similar to: + * // { + * // 'bip122:000000000019d6689c085ae165831e93/slip44:0': { + * // amount: '0.0001', + * // unit: 'BTC', + * // } + * // } + * ``` + * @param id - ID of the account to retrieve the balances for. + * @param assets - Array of asset types (CAIP-19) to retrieve balances for. + * @returns A promise that resolves to an object mapping asset types to their + * respective balances. + */ + getAccountBalances?( + id: string, + assets: CaipAssetType[], + ): Promise>; + /** * Filter supported chains for a given account. * diff --git a/src/internal/api.ts b/src/internal/api.ts index 434a759a8..7fc3f8e00 100644 --- a/src/internal/api.ts +++ b/src/internal/api.ts @@ -1,22 +1,17 @@ import { JsonStruct } from '@metamask/utils'; import type { Infer } from 'superstruct'; -import { - array, - literal, - number, - object, - record, - string, - union, -} from 'superstruct'; +import { array, literal, number, record, string, union } from 'superstruct'; import { + BalanceStruct, KeyringAccountDataStruct, KeyringAccountStruct, KeyringRequestStruct, KeyringResponseStruct, } from '../api'; -import { UuidStruct } from '../utils'; +import { object } from '../superstruct'; +import { CaipAssetTypeStruct, UuidStruct } from '../utils'; +import { KeyringRpcMethod } from './rpc'; const CommonHeader = { jsonrpc: literal('2.0'), @@ -71,6 +66,31 @@ export const CreateAccountResponseStruct = KeyringAccountStruct; export type CreateAccountResponse = Infer; +// ---------------------------------------------------------------------------- +// Get account balances + +export const GetAccountBalancesRequestStruct = object({ + ...CommonHeader, + method: literal(`${KeyringRpcMethod.GetAccountBalances}`), + params: object({ + id: UuidStruct, + assets: array(CaipAssetTypeStruct), + }), +}); + +export type GetAccountBalancesRequest = Infer< + typeof GetAccountBalancesRequestStruct +>; + +export const GetAccountBalancesResponseStruct = record( + CaipAssetTypeStruct, + BalanceStruct, +); + +export type GetAccountBalancesResponse = Infer< + typeof GetAccountBalancesResponseStruct +>; + // ---------------------------------------------------------------------------- // Filter account chains diff --git a/src/internal/rpc.ts b/src/internal/rpc.ts index 9f007ab23..dfb16ab7b 100644 --- a/src/internal/rpc.ts +++ b/src/internal/rpc.ts @@ -5,6 +5,7 @@ export enum KeyringRpcMethod { ListAccounts = 'keyring_listAccounts', GetAccount = 'keyring_getAccount', CreateAccount = 'keyring_createAccount', + GetAccountBalances = 'keyring_getAccountBalances', FilterAccountChains = 'keyring_filterAccountChains', UpdateAccount = 'keyring_updateAccount', DeleteAccount = 'keyring_deleteAccount', diff --git a/src/rpc-handler.test.ts b/src/rpc-handler.test.ts index 5726915a9..29918ada1 100644 --- a/src/rpc-handler.test.ts +++ b/src/rpc-handler.test.ts @@ -1,4 +1,5 @@ import type { Keyring } from './api'; +import type { GetAccountBalancesRequest } from './internal'; import { KeyringRpcMethod, isKeyringRpcMethod } from './internal/rpc'; import type { JsonRpcRequest } from './JsonRpcRequest'; import { handleKeyringRequest } from './rpc-handler'; @@ -8,6 +9,7 @@ describe('handleKeyringRequest', () => { listAccounts: jest.fn(), getAccount: jest.fn(), createAccount: jest.fn(), + getAccountBalances: jest.fn(), filterAccountChains: jest.fn(), updateAccount: jest.fn(), deleteAccount: jest.fn(), @@ -453,6 +455,90 @@ describe('handleKeyringRequest', () => { 'An unknown error occurred while handling the keyring request', ); }); + + describe('getAccountBalances', () => { + it('successfully calls `keyring_getAccountBalances`', async () => { + const request: GetAccountBalancesRequest = { + jsonrpc: '2.0', + id: '2ac49e1a-4f5b-4dad-889c-73f3ca34fd3b', + method: 'keyring_getAccountBalances', + params: { + id: '987910cc-2d23-48c2-a362-c37f0715793e', + assets: ['bip122:000000000019d6689c085ae165831e93/slip44:0'], + }, + }; + + await handleKeyringRequest(keyring, request); + expect(keyring.getAccountBalances).toHaveBeenCalledWith( + request.params.id, + request.params.assets, + ); + }); + + it('fails because the account ID is not provided', async () => { + const request: JsonRpcRequest = { + jsonrpc: '2.0', + id: '2ac49e1a-4f5b-4dad-889c-73f3ca34fd3b', + method: 'keyring_getAccountBalances', + params: { + assets: ['bip122:000000000019d6689c085ae165831e93/slip44:0'], + }, + }; + + await expect(handleKeyringRequest(keyring, request)).rejects.toThrow( + 'At path: params.id -- Expected a value of type `UuidV4`, but received: `undefined`', + ); + }); + + it('fails because the assets are not provided', async () => { + const request: JsonRpcRequest = { + jsonrpc: '2.0', + id: '2ac49e1a-4f5b-4dad-889c-73f3ca34fd3b', + method: 'keyring_getAccountBalances', + params: { + id: '987910cc-2d23-48c2-a362-c37f0715793e', + }, + }; + + await expect(handleKeyringRequest(keyring, request)).rejects.toThrow( + 'At path: params.assets -- Expected an array value, but received: undefined', + ); + }); + + it('fails because the assets are not strings', async () => { + const request: JsonRpcRequest = { + jsonrpc: '2.0', + id: '2ac49e1a-4f5b-4dad-889c-73f3ca34fd3b', + method: 'keyring_getAccountBalances', + params: { + id: '987910cc-2d23-48c2-a362-c37f0715793e', + assets: [1, 2, 3], + }, + }; + + await expect(handleKeyringRequest(keyring, request)).rejects.toThrow( + 'At path: params.assets.0 -- Expected a value of type `CaipAssetType`, but received: `1`', + ); + }); + + it('fails because `keyring_getAccountBalances` is not implemented', async () => { + const request: GetAccountBalancesRequest = { + jsonrpc: '2.0', + id: '2ac49e1a-4f5b-4dad-889c-73f3ca34fd3b', + method: 'keyring_getAccountBalances', + params: { + id: '987910cc-2d23-48c2-a362-c37f0715793e', + assets: ['bip122:000000000019d6689c085ae165831e93/slip44:0'], + }, + }; + + const { getAccountBalances, ...partialKeyring } = keyring; + + await expect( + handleKeyringRequest(partialKeyring, request), + ).rejects.toThrow('Method not supported: keyring_getAccountBalances'); + }); + }); }); describe('isKeyringRpcMethod', () => { diff --git a/src/rpc-handler.ts b/src/rpc-handler.ts index 99153fc0b..495f2c427 100644 --- a/src/rpc-handler.ts +++ b/src/rpc-handler.ts @@ -15,6 +15,7 @@ import { FilterAccountChainsStruct, ListAccountsRequestStruct, ListRequestsRequestStruct, + GetAccountBalancesRequestStruct, } from './internal/api'; import { KeyringRpcMethod } from './internal/rpc'; import type { JsonRpcRequest } from './JsonRpcRequest'; @@ -61,6 +62,17 @@ async function dispatchRequest( return keyring.createAccount(request.params.options); } + case KeyringRpcMethod.GetAccountBalances: { + if (keyring.getAccountBalances === undefined) { + throw new MethodNotSupportedError(request.method); + } + assert(request, GetAccountBalancesRequestStruct); + return keyring.getAccountBalances( + request.params.id, + request.params.assets, + ); + } + case KeyringRpcMethod.FilterAccountChains: { assert(request, FilterAccountChainsStruct); return keyring.filterAccountChains( diff --git a/src/utils/index.ts b/src/utils/index.ts index c1a9cc9a1..1a3d7c47b 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -1,4 +1,3 @@ export * from './caip'; +export * from './types'; export * from './typing'; -export * from './url'; -export * from './uuid'; diff --git a/src/utils/types.test.ts b/src/utils/types.test.ts new file mode 100644 index 000000000..7aef6117a --- /dev/null +++ b/src/utils/types.test.ts @@ -0,0 +1,19 @@ +import { is } from 'superstruct'; + +import { StringNumberStruct } from './types'; + +describe('StringNumber', () => { + it.each(['0', '0.0', '0.1', '0.19', '00.19', '0.000000000000000000000'])( + 'validates basic number: %s', + (input: string) => { + expect(is(input, StringNumberStruct)).toBe(true); + }, + ); + + it.each(['foobar', 'NaN', '0.123.4', '1e3', undefined, null, 1, true])( + 'fails to validate wrong number: %s', + (input: any) => { + expect(is(input, StringNumberStruct)).toBe(false); + }, + ); +}); diff --git a/src/utils/types.ts b/src/utils/types.ts new file mode 100644 index 000000000..5416c41a8 --- /dev/null +++ b/src/utils/types.ts @@ -0,0 +1,35 @@ +import { define, type Infer } from 'superstruct'; + +import { definePattern } from '../superstruct'; + +/** + * UUIDv4 struct. + */ +export const UuidStruct = definePattern( + 'UuidV4', + /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/iu, +); + +/** + * Validates if a given value is a valid URL. + * + * @param value - The value to be validated. + * @returns A boolean indicating if the value is a valid URL. + */ +export const UrlStruct = define('Url', (value: unknown) => { + try { + const url = new URL(value as string); + return url.protocol === 'http:' || url.protocol === 'https:'; + } catch (_) { + return false; + } +}); + +/** + * A string which contains a positive float number. + */ +export const StringNumberStruct = definePattern( + 'StringNumber', + /^\d+(\.\d+)?$/u, +); +export type StringNumber = Infer; diff --git a/src/utils/url.ts b/src/utils/url.ts deleted file mode 100644 index 3037dea7d..000000000 --- a/src/utils/url.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { define } from 'superstruct'; - -/** - * Validates if a given value is a valid URL. - * - * @param value - The value to be validated. - * @returns A boolean indicating if the value is a valid URL. - */ -export const UrlStruct = define('Url', (value: unknown) => { - let url; - - try { - url = new URL(value as string); - } catch (_) { - return false; - } - - return url.protocol === 'http:' || url.protocol === 'https:'; -}); diff --git a/src/utils/uuid.ts b/src/utils/uuid.ts deleted file mode 100644 index 954cf9e2e..000000000 --- a/src/utils/uuid.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { definePattern } from '../superstruct'; - -/** - * UUIDv4 struct. - */ -export const UuidStruct = definePattern( - 'UuidV4', - /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/iu, -);