From b6882dab491494fe3653294dbdcc10589d27cc63 Mon Sep 17 00:00:00 2001 From: Daniel Rocha <68558152+danroc@users.noreply.github.com> Date: Mon, 27 May 2024 21:24:31 +0200 Subject: [PATCH 1/7] refactor: split `api.ts` --- src/api/balance.test-d.ts | 19 +++++++++ src/api/balance.ts | 12 ++++++ src/api/index.ts | 1 + src/api/keyring.ts | 34 ++++++++++++++++ src/internal/api.ts | 38 +++++++++++++---- src/internal/rpc.ts | 1 + src/rpc-handler.test.ts | 86 +++++++++++++++++++++++++++++++++++++++ src/rpc-handler.ts | 12 ++++++ src/utils/index.ts | 3 +- src/utils/types.test.ts | 19 +++++++++ src/utils/types.ts | 35 ++++++++++++++++ src/utils/url.ts | 19 --------- src/utils/uuid.ts | 9 ---- 13 files changed, 249 insertions(+), 39 deletions(-) create mode 100644 src/api/balance.test-d.ts create mode 100644 src/api/balance.ts create mode 100644 src/utils/types.test.ts create mode 100644 src/utils/types.ts delete mode 100644 src/utils/url.ts delete mode 100644 src/utils/uuid.ts 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..c68a78250 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..a0805ee1d 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 { object } from '../superstruct'; import { 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(string()), + }), +}); + +export type GetAccountBalancesRequest = Infer< + typeof GetAccountBalancesRequestStruct +>; + +export const GetAccountBalancesResponseStruct = record( + string(), + record(string(), 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..9233c27a8 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 string, 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..8983519de --- /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', + /^[0-9]+(\.[0-9]+)?$/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, -); From c2c2ad844ef5b1be2af1e0d25c31746d756a483a Mon Sep 17 00:00:00 2001 From: Daniel Rocha Date: Wed, 29 May 2024 10:10:28 +0200 Subject: [PATCH 2/7] chore: add missing return type Co-authored-by: Charly Chevalier --- src/api/keyring.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/api/keyring.ts b/src/api/keyring.ts index c68a78250..ad1274f60 100644 --- a/src/api/keyring.ts +++ b/src/api/keyring.ts @@ -76,7 +76,7 @@ export type Keyring = { getAccountBalances?( id: string, assets: CaipAssetType[], - ): Promise>; + ): Promise>; /** * Filter supported chains for a given account. From b5c66bdd71453494f490f20745a55c3a9d2e2fb7 Mon Sep 17 00:00:00 2001 From: Daniel Rocha Date: Wed, 29 May 2024 10:34:58 +0200 Subject: [PATCH 3/7] chore: add import to use `CaipAssetTypeStruct` Co-authored-by: Charly Chevalier --- src/internal/api.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/internal/api.ts b/src/internal/api.ts index a0805ee1d..541c7e89c 100644 --- a/src/internal/api.ts +++ b/src/internal/api.ts @@ -10,7 +10,7 @@ import { KeyringResponseStruct, } from '../api'; import { object } from '../superstruct'; -import { UuidStruct } from '../utils'; +import { CaipAssetTypeStruct, UuidStruct } from '../utils'; import { KeyringRpcMethod } from './rpc'; const CommonHeader = { From a0390c6bfbc15b1cecfb17710418945ef2949291 Mon Sep 17 00:00:00 2001 From: Daniel Rocha Date: Wed, 29 May 2024 10:35:17 +0200 Subject: [PATCH 4/7] chore: update asset type Co-authored-by: Charly Chevalier --- src/internal/api.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/internal/api.ts b/src/internal/api.ts index 541c7e89c..210183e8a 100644 --- a/src/internal/api.ts +++ b/src/internal/api.ts @@ -83,8 +83,8 @@ export type GetAccountBalancesRequest = Infer< >; export const GetAccountBalancesResponseStruct = record( - string(), - record(string(), BalanceStruct), + CaipAssetTypeStruct, + BalanceStruct, ); export type GetAccountBalancesResponse = Infer< From 71ecd1941f8e01b7084591aeeba7a090ae85184e Mon Sep 17 00:00:00 2001 From: Daniel Rocha Date: Wed, 29 May 2024 10:35:40 +0200 Subject: [PATCH 5/7] chore: update assets array type Co-authored-by: Charly Chevalier --- src/internal/api.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/internal/api.ts b/src/internal/api.ts index 210183e8a..7fc3f8e00 100644 --- a/src/internal/api.ts +++ b/src/internal/api.ts @@ -74,7 +74,7 @@ export const GetAccountBalancesRequestStruct = object({ method: literal(`${KeyringRpcMethod.GetAccountBalances}`), params: object({ id: UuidStruct, - assets: array(string()), + assets: array(CaipAssetTypeStruct), }), }); From 7d5adc0dfb700f3dce52394d3ec94977b5abf1d7 Mon Sep 17 00:00:00 2001 From: Daniel Rocha <68558152+danroc@users.noreply.github.com> Date: Wed, 29 May 2024 10:38:15 +0200 Subject: [PATCH 6/7] chore: update error message in unit test --- src/rpc-handler.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/rpc-handler.test.ts b/src/rpc-handler.test.ts index 9233c27a8..29918ada1 100644 --- a/src/rpc-handler.test.ts +++ b/src/rpc-handler.test.ts @@ -517,7 +517,7 @@ describe('handleKeyringRequest', () => { }; await expect(handleKeyringRequest(keyring, request)).rejects.toThrow( - 'At path: params.assets.0 -- Expected a string, but received: 1', + 'At path: params.assets.0 -- Expected a value of type `CaipAssetType`, but received: `1`', ); }); From 605a461a07d051e6de027f41f146ea4a8cdda756 Mon Sep 17 00:00:00 2001 From: Daniel Rocha <68558152+danroc@users.noreply.github.com> Date: Wed, 29 May 2024 10:47:51 +0200 Subject: [PATCH 7/7] chore: fix Sonar warning --- src/utils/types.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/utils/types.ts b/src/utils/types.ts index 8983519de..5416c41a8 100644 --- a/src/utils/types.ts +++ b/src/utils/types.ts @@ -30,6 +30,6 @@ export const UrlStruct = define('Url', (value: unknown) => { */ export const StringNumberStruct = definePattern( 'StringNumber', - /^[0-9]+(\.[0-9]+)?$/u, + /^\d+(\.\d+)?$/u, ); export type StringNumber = Infer;