Skip to content
This repository has been archived by the owner on Jan 13, 2025. It is now read-only.

Commit

Permalink
feat: add chain_getBalances (#1)
Browse files Browse the repository at this point in the history
* feat: add Chain.getBalances method + add CAIP asset type

* chore: import JsonRpcRequest + superstruct helpers from keyring-api

Having an extra dependency to the keyring-api feels wrong... So for now,
let's copy them here to avoid this dependency.

* feat: add RPC dispatcher + chain_getBalances RPC

* chore: lint

* feat(caip): add support for asset id in CAIP asset type/id

* refactor: properly use MethodNotSupportedError

* test(caip): fix test cases

* test: add test for StringNumber + fix regex

* chore: import JsonRpcRequest + superstruct tests from keyring-api

* test: add rpc-handler tests

* test: exclude index.ts + *.test-d.ts from coverage

* refactor: do not re-export caip helpers from @metamask/utils

* chore: lint

* feat(caip): add CaipAssetTypeOrId

* feat(api): use CaipAssetTypeOrId for assets

* chore: downgrade superstruct to 1.0.3

We do use a resolution for this to make sure @metamask/utils also uses
the 1.0.3 (and avoid having different versions of superstruct, that
might cause typing issues see like:
https://github.com/MetaMask/chain-api/actions/runs/8836209841/job/24262243391?pr=1)

* feat(rpc): use CaipAssetTypeOrId for assets

* refactor: proper definition for AmountStruct

* test: add test for undefined JsonRpcRequest.id

* refactor: use JsonRpcRequest/superstruct helpers from @metamask/utils
  • Loading branch information
ccharly authored Apr 30, 2024
1 parent 737fba3 commit a6e2382
Show file tree
Hide file tree
Showing 14 changed files with 669 additions and 23 deletions.
6 changes: 5 additions & 1 deletion jest.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,11 @@ module.exports = {
collectCoverage: true,

// An array of glob patterns indicating a set of files for which coverage information should be collected
collectCoverageFrom: ['./src/**/*.ts'],
collectCoverageFrom: [
'./src/**/*.ts',
'!./src/**/index.ts',
'!./src/**/*.test-d.ts',
],

// The directory where Jest should output its coverage files
coverageDirectory: 'coverage',
Expand Down
7 changes: 7 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,13 @@
"test": "jest && jest-it-up",
"test:watch": "jest --watch"
},
"resolutions": {
"superstruct@^1.0.3": "1.0.3"
},
"dependencies": {
"@metamask/utils": "^8.4.0",
"superstruct": "1.0.3"
},
"devDependencies": {
"@lavamoat/allow-scripts": "^3.0.0",
"@lavamoat/preinstall-always-fail": "^2.0.0",
Expand Down
12 changes: 12 additions & 0 deletions src/api.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import type { CaipChainId } from '@metamask/utils';

import type { CaipAssetTypeOrId } from './caip-types';
import type { BalancesResult } from './types';

export type Chain = {
getBalances(
scope: CaipChainId,
accounts: string[],
assets: CaipAssetTypeOrId[],
): Promise<BalancesResult>;
};
104 changes: 104 additions & 0 deletions src/caip-types.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
import {
isCaipAssetType,
isCaipAssetId,
isCaipAssetTypeOrId,
} from './caip-types';

// Imported from: https://github.com/ChainAgnostic/CAIPs/blob/main/CAIPs/caip-19.md#test-cases
const good = {
assetTypes: [
'eip155:1/slip44:60',
'bip122:000000000019d6689c085ae165831e93/slip44:0',
'cosmos:cosmoshub-3/slip44:118',
'bip122:12a765e31ffd4059bada1e25190f6e98/slip44:2',
'cosmos:Binance-Chain-Tigris/slip44:714',
'cosmos:iov-mainnet/slip44:234',
'lip9:9ee11e9df416b18b/slip44:134',
'eip155:1/erc20:0x6b175474e89094c44da98b954eedeac495271d0f',
'eip155:1/erc721:0x06012c8cf97BEaD5deAe237070F9587f8E7A266d',
],
assetIds: [
'eip155:1/erc721:0x06012c8cf97BEaD5deAe237070F9587f8E7A266d/771769',
'hedera:mainnet/nft:0.0.55492/12',
],
};

const badAssets = [
true,
false,
null,
undefined,
1,
{},
[],
'',
'!@#$%^&*()',
'foo',
'eip155',
'eip155:',
'eip155:1',
'eip155:1:',
'eip155:1:0x0000000000000000000000000000000000000000:2',
'bip122',
'bip122:',
'bip122:000000000019d6689c085ae165831e93',
'bip122:000000000019d6689c085ae165831e93/',
'bip122:000000000019d6689c085ae165831e93/tooooooolong',
'bip122:000000000019d6689c085ae165831e93/tooooooolong:asset',
'eip155:1/erc721',
'eip155:1/erc721:',
'eip155:1/erc721:0x06012c8cf97BEaD5deAe237070F9587f8E7A266d/',
];
const bad = {
assetTypes: badAssets,
assetIds: [
...badAssets,
'eip155:1/erc721:0x06012c8cf97BEaD5deAe237070F9587f8E7A266d/tooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooolongasset',
],
};

const uniq = (data1: any[], data2: any[]): any[] => {
return Array.from(new Set(data1.concat(data2)));
};

describe('isCaipAssetType', () => {
it.each(good.assetTypes)(
'returns true for a valid asset type %s',
(asset) => {
expect(isCaipAssetType(asset)).toBe(true);
},
);

it.each(bad.assetTypes)(
'returns false for an invalid asset type %s',
(asset) => {
expect(isCaipAssetType(asset)).toBe(false);
},
);
});

describe('isCaipAssetId', () => {
it.each(good.assetIds)('returns true for a valid asset id %s', (asset) => {
expect(isCaipAssetId(asset)).toBe(true);
});

it.each(bad.assetIds)('returns false for an invalid asset id %s', (asset) => {
expect(isCaipAssetType(asset)).toBe(false);
});
});

describe('isCaipAssetTypeOrId', () => {
it.each(uniq(good.assetIds, good.assetTypes))(
'returns true for a valid asset %s',
(asset) => {
expect(isCaipAssetTypeOrId(asset)).toBe(true);
},
);

it.each(uniq(bad.assetIds, bad.assetIds))(
'returns false for an invalid asset %s',
(asset) => {
expect(isCaipAssetTypeOrId(asset)).toBe(false);
},
);
});
62 changes: 62 additions & 0 deletions src/caip-types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import type { Infer } from 'superstruct';
import { is, string, pattern } from 'superstruct';

export const CAIP_ASSET_TYPE_REGEX =
/^(?<chainId>(?<namespace>[-a-z0-9]{3,8}):(?<reference>[-_a-zA-Z0-9]{1,32}))\/(?<assetNamespace>[-a-z0-9]{3,8}):(?<assetReference>[-.%a-zA-Z0-9]{1,128})$/u;

export const CAIP_ASSET_ID_REGEX =
/^(?<chainId>(?<namespace>[-a-z0-9]{3,8}):(?<reference>[-_a-zA-Z0-9]{1,32}))\/(?<assetNamespace>[-a-z0-9]{3,8}):(?<assetReference>[-.%a-zA-Z0-9]{1,128})\/(?<tokenId>[-.%a-zA-Z0-9]{1,78})$/u;

export const CAIP_ASSET_TYPE_OR_ID_REGEX =
/^(?<chainId>(?<namespace>[-a-z0-9]{3,8}):(?<reference>[-_a-zA-Z0-9]{1,32}))\/(?<assetNamespace>[-a-z0-9]{3,8}):(?<assetReference>[-.%a-zA-Z0-9]{1,128})(\/(?<tokenId>[-.%a-zA-Z0-9]{1,78}))?$/u;

/**
* A CAIP-19 asset type identifier, i.e., a human-readable type of asset type identifier.
*/
export const CaipAssetTypeStruct = pattern(string(), CAIP_ASSET_TYPE_REGEX);
export type CaipAssetType = Infer<typeof CaipAssetTypeStruct>;

/**
* A CAIP-19 asset ID identifier, i.e., a human-readable type of asset ID identifier.
*/
export const CaipAssetIdStruct = pattern(string(), CAIP_ASSET_ID_REGEX);
export type CaipAssetId = Infer<typeof CaipAssetIdStruct>;

/**
* A CAIP-19 asset type or asset ID identifier, i.e., a human-readable type of asset identifier.
*/
export const CaipAssetTypeOrIdStruct = pattern(
string(),
CAIP_ASSET_TYPE_OR_ID_REGEX,
);
export type CaipAssetTypeOrId = Infer<typeof CaipAssetTypeOrIdStruct>;

/**
* Check if the given value is a {@link CaipAssetType}.
*
* @param value - The value to check.
* @returns Whether the value is a {@link CaipAssetType}.
*/
export function isCaipAssetType(value: unknown): value is CaipAssetType {
return is(value, CaipAssetTypeStruct);
}

/**
* Check if the given value is a {@link CaipAssetId}.
*
* @param value - The value to check.
* @returns Whether the value is a {@link CaipAssetId}.
*/
export function isCaipAssetId(value: unknown): value is CaipAssetId {
return is(value, CaipAssetIdStruct);
}

/**
* Check if the given value is a {@link CaipAssetTypeOrId}.
*
* @param value - The value to check.
* @returns Whether the value is a {@link CaipAssetTypeOrId}.
*/
export function isCaipAssetTypeOrId(value: unknown): value is CaipAssetId {
return is(value, CaipAssetTypeOrIdStruct);
}
9 changes: 0 additions & 9 deletions src/index.test.ts

This file was deleted.

14 changes: 5 additions & 9 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,5 @@
/**
* Example function that returns a greeting for the given name.
*
* @param name - The name to greet.
* @returns The greeting.
*/
export default function greeter(name: string): string {
return `Hello, ${name}!`;
}
export * from './api';
export * from './types';
export * from './caip-types';
export * from './rpc-handler';
export * from './rpc-types';
121 changes: 121 additions & 0 deletions src/rpc-handler.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
import type { JsonRpcRequest } from '@metamask/utils';

import {
ChainRpcMethod,
isChainRpcMethod,
MethodNotSupportedError,
handleChainRequest,
} from './rpc-handler';

describe('rpc-handler', () => {
const chain = {
getBalances: jest.fn(),
};

const params = {
scope: 'bip122:000000000019d6689c085ae165831e93',
accounts: [
'bc1qrp0yzgkf8rawkuvdlhnjfj2fnjwm0m8727kgah',
'bc1qf5n2h6mgelkls4497pkpemew55xpew90td2qae',
],
assets: [
'bip122:000000000019d6689c085ae165831e93/asset:0',
'bip122:000000000019d6689c085ae165831e93/asset:1',
'bip122:000000000019d6689c085ae165831e93/asset:2',
'bip122:000000000019d6689c085ae165831e93/asset:3',
'bip122:000000000019d6689c085ae165831e93/asset:4',
],
};

afterEach(() => {
jest.clearAllMocks();
});

it('should call chain_getBalances', async () => {
const request: JsonRpcRequest = {
jsonrpc: '2.0',
id: '7c507ff0-365f-4de0-8cd5-eb83c30ebda4',
method: 'chain_getBalances',
params,
};

chain.getBalances.mockResolvedValue('GetBalances result');
const result = await handleChainRequest(chain, request);

expect(chain.getBalances).toHaveBeenCalled();
expect(result).toBe('GetBalances result');
});

it('should fail to call chainRpcDispatcher with a non-JSON-RPC request', async () => {
const request = {
jsonrpc: '2.0',
id: '7c507ff0-365f-4de0-8cd5-eb83c30ebda4',
// Missing method name.
};

await expect(
handleChainRequest(chain, request as unknown as JsonRpcRequest),
).rejects.toThrow(
'At path: method -- Expected a string, but received: undefined',
);
});

it('calls the chain with a number request ID', async () => {
const request: JsonRpcRequest = {
jsonrpc: '2.0',
id: 1,
method: 'chain_getBalances',
params,
};

chain.getBalances.mockResolvedValue([]);
expect(await handleChainRequest(chain, request)).toStrictEqual([]);
});

it('calls the chain with a null request ID', async () => {
const request: JsonRpcRequest = {
jsonrpc: '2.0',
id: null,
method: 'chain_getBalances',
params,
};

chain.getBalances.mockResolvedValue([]);
expect(await handleChainRequest(chain, request)).toStrictEqual([]);
});

it('fails to call the chain with a boolean request ID', async () => {
const request: JsonRpcRequest = {
jsonrpc: '2.0',
id: true as any,
method: 'chain_getBalances',
};

chain.getBalances.mockResolvedValue([]);
await expect(handleChainRequest(chain, request)).rejects.toThrow(
'At path: id -- Expected the value to satisfy a union of `number | string`, but received: true',
);
});

it('should throw MethodNotSupportedError for an unknown method', async () => {
const request: JsonRpcRequest = {
jsonrpc: '2.0',
id: '7c507ff0-365f-4de0-8cd5-eb83c30ebda4',
method: 'unknown_method',
params,
};

await expect(handleChainRequest(chain, request)).rejects.toThrow(
MethodNotSupportedError,
);
});
});

describe('isChainRequestMethod', () => {
it.each([
[`${ChainRpcMethod.GetBalances}`, true],
[`chain_invalid`, false],
])(`%s should be %s`, (method, expected) => {
expect(isChainRpcMethod(method)).toBe(expected);
});
});
Loading

0 comments on commit a6e2382

Please sign in to comment.