Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add chain_getBalances #1

Merged
merged 20 commits into from
Apr 30, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
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
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