Skip to content
This repository has been archived by the owner on Oct 7, 2024. It is now read-only.

feat: add getAccountBalances method to Keyring #320

Merged
merged 7 commits into from
May 29, 2024
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
19 changes: 19 additions & 0 deletions src/api/balance.test-d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { expectAssignable, expectNotAssignable } from 'tsd';

import type { Balance } from './balance';

expectAssignable<Balance>({ amount: '1.0', unit: 'ETH' });
expectAssignable<Balance>({ amount: '0.1', unit: 'BTC' });
expectAssignable<Balance>({ amount: '.1', unit: 'gwei' });
expectAssignable<Balance>({ amount: '.1', unit: 'wei' });
expectAssignable<Balance>({ amount: '1.', unit: 'sat' });

expectNotAssignable<Balance>({ amount: 1, unit: 'ETH' });
expectNotAssignable<Balance>({ amount: true, unit: 'ETH' });
expectNotAssignable<Balance>({ amount: undefined, unit: 'ETH' });
expectNotAssignable<Balance>({ amount: null, unit: 'ETH' });

expectNotAssignable<Balance>({ amount: '1.0', unit: 1 });
expectNotAssignable<Balance>({ amount: '1.0', unit: true });
expectNotAssignable<Balance>({ amount: '1.0', unit: undefined });
expectNotAssignable<Balance>({ amount: '1.0', unit: null });
12 changes: 12 additions & 0 deletions src/api/balance.ts
Original file line number Diff line number Diff line change
@@ -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<typeof BalanceStruct>;
1 change: 1 addition & 0 deletions src/api/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
export * from './account';
export * from './balance';
export * from './export';
export * from './keyring';
export * from './request';
Expand Down
34 changes: 34 additions & 0 deletions src/api/keyring.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -44,6 +46,38 @@ export type Keyring = {
*/
createAccount(options?: Record<string, Json>): Promise<KeyringAccount>;

/**
* 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<Record<string, Balance>>;
danroc marked this conversation as resolved.
Show resolved Hide resolved

/**
* Filter supported chains for a given account.
*
Expand Down
38 changes: 29 additions & 9 deletions src/internal/api.ts
Original file line number Diff line number Diff line change
@@ -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';
danroc marked this conversation as resolved.
Show resolved Hide resolved
import { KeyringRpcMethod } from './rpc';
danroc marked this conversation as resolved.
Show resolved Hide resolved

const CommonHeader = {
jsonrpc: literal('2.0'),
Expand Down Expand Up @@ -71,6 +66,31 @@ export const CreateAccountResponseStruct = KeyringAccountStruct;

export type CreateAccountResponse = Infer<typeof CreateAccountResponseStruct>;

// ----------------------------------------------------------------------------
// Get account balances

export const GetAccountBalancesRequestStruct = object({
...CommonHeader,
method: literal(`${KeyringRpcMethod.GetAccountBalances}`),
params: object({
id: UuidStruct,
assets: array(string()),
danroc marked this conversation as resolved.
Show resolved Hide resolved
}),
});

export type GetAccountBalancesRequest = Infer<
typeof GetAccountBalancesRequestStruct
>;

export const GetAccountBalancesResponseStruct = record(
string(),
record(string(), BalanceStruct),
);
danroc marked this conversation as resolved.
Show resolved Hide resolved

export type GetAccountBalancesResponse = Infer<
typeof GetAccountBalancesResponseStruct
>;

// ----------------------------------------------------------------------------
// Filter account chains

Expand Down
1 change: 1 addition & 0 deletions src/internal/rpc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
86 changes: 86 additions & 0 deletions src/rpc-handler.test.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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(),
Expand Down Expand Up @@ -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', () => {
Expand Down
12 changes: 12 additions & 0 deletions src/rpc-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
FilterAccountChainsStruct,
ListAccountsRequestStruct,
ListRequestsRequestStruct,
GetAccountBalancesRequestStruct,
} from './internal/api';
import { KeyringRpcMethod } from './internal/rpc';
import type { JsonRpcRequest } from './JsonRpcRequest';
Expand Down Expand Up @@ -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(
Expand Down
3 changes: 1 addition & 2 deletions src/utils/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
export * from './caip';
export * from './types';
export * from './typing';
export * from './url';
export * from './uuid';
19 changes: 19 additions & 0 deletions src/utils/types.test.ts
Original file line number Diff line number Diff line change
@@ -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);
},
);
});
35 changes: 35 additions & 0 deletions src/utils/types.ts
Original file line number Diff line number Diff line change
@@ -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<string>('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<typeof StringNumberStruct>;
19 changes: 0 additions & 19 deletions src/utils/url.ts

This file was deleted.

9 changes: 0 additions & 9 deletions src/utils/uuid.ts

This file was deleted.

Loading