Skip to content

Commit d4e1403

Browse files
authoredSep 27, 2024··
Add new snap_getCurrencyRate RPC method (#2763)
This PR adds a new permitted RPC method called `snap_getCurrencyRate`. It takes a cryptocurrency symbol under the `currency` param. (We currently only support `btc`) and return a rate object if the rate is available. Fixes: #2762
1 parent 63ea955 commit d4e1403

File tree

13 files changed

+353
-9
lines changed

13 files changed

+353
-9
lines changed
 

Diff for: ‎packages/examples/packages/browserify-plugin/snap.manifest.json

+1-1
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
"url": "https://github.com/MetaMask/snaps.git"
88
},
99
"source": {
10-
"shasum": "+w62Op5ur4nVLmQ0uKA0IsAQN2hkKOsgSm4VK9jxxYY=",
10+
"shasum": "zGgTjLWTEn796eXGvv66p8tGxZSa82yEEGnRtyVutEc=",
1111
"location": {
1212
"npm": {
1313
"filePath": "dist/bundle.js",

Diff for: ‎packages/examples/packages/browserify/snap.manifest.json

+1-1
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
"url": "https://github.com/MetaMask/snaps.git"
88
},
99
"source": {
10-
"shasum": "SL7kg2vwhtpuzNvOx7uQZNZbccRgfFtTi0xZdEVEP0s=",
10+
"shasum": "qfkidJLew8JNN2Enx4pDUgWNgLPqBkG0k3mGQRR1oaY=",
1111
"location": {
1212
"npm": {
1313
"filePath": "dist/bundle.js",

Diff for: ‎packages/snaps-rpc-methods/jest.config.js

+4-4
Original file line numberDiff line numberDiff line change
@@ -10,10 +10,10 @@ module.exports = deepmerge(baseConfig, {
1010
],
1111
coverageThreshold: {
1212
global: {
13-
branches: 92.68,
14-
functions: 97.17,
15-
lines: 97.71,
16-
statements: 97.21,
13+
branches: 92.77,
14+
functions: 97.2,
15+
lines: 97.76,
16+
statements: 97.26,
1717
},
1818
},
1919
});
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,159 @@
1+
import { JsonRpcEngine } from '@metamask/json-rpc-engine';
2+
import { type GetCurrencyRateResult } from '@metamask/snaps-sdk';
3+
import type { JsonRpcRequest, PendingJsonRpcResponse } from '@metamask/utils';
4+
5+
import type { GetCurrencyRateParameters } from './getCurrencyRate';
6+
import { getCurrencyRateHandler } from './getCurrencyRate';
7+
8+
describe('snap_getCurrencyRate', () => {
9+
describe('getCurrencyRateHandler', () => {
10+
it('has the expected shape', () => {
11+
expect(getCurrencyRateHandler).toMatchObject({
12+
methodNames: ['snap_getCurrencyRate'],
13+
implementation: expect.any(Function),
14+
hookNames: {
15+
getCurrencyRate: true,
16+
},
17+
});
18+
});
19+
});
20+
21+
describe('implementation', () => {
22+
it('returns the result from the `getCurrencyRate` hook', async () => {
23+
const { implementation } = getCurrencyRateHandler;
24+
25+
const getCurrencyRate = jest.fn().mockReturnValue({
26+
currency: 'usd',
27+
conversionRate: 1,
28+
conversionDate: 1,
29+
usdConversionRate: 1,
30+
});
31+
32+
const hooks = {
33+
getCurrencyRate,
34+
};
35+
36+
const engine = new JsonRpcEngine();
37+
38+
engine.push((request, response, next, end) => {
39+
const result = implementation(
40+
request as JsonRpcRequest<GetCurrencyRateParameters>,
41+
response as PendingJsonRpcResponse<GetCurrencyRateResult>,
42+
next,
43+
end,
44+
hooks,
45+
);
46+
47+
result?.catch(end);
48+
});
49+
50+
const response = await engine.handle({
51+
jsonrpc: '2.0',
52+
id: 1,
53+
method: 'snap_getCurrencyRate',
54+
params: {
55+
currency: 'btc',
56+
},
57+
});
58+
59+
expect(response).toStrictEqual({
60+
jsonrpc: '2.0',
61+
id: 1,
62+
result: {
63+
currency: 'usd',
64+
conversionRate: 1,
65+
conversionDate: 1,
66+
usdConversionRate: 1,
67+
},
68+
});
69+
});
70+
71+
it('returns null if there is no rate available', async () => {
72+
const { implementation } = getCurrencyRateHandler;
73+
74+
const getCurrencyRate = jest.fn().mockReturnValue(undefined);
75+
76+
const hooks = {
77+
getCurrencyRate,
78+
};
79+
80+
const engine = new JsonRpcEngine();
81+
82+
engine.push((request, response, next, end) => {
83+
const result = implementation(
84+
request as JsonRpcRequest<GetCurrencyRateParameters>,
85+
response as PendingJsonRpcResponse<GetCurrencyRateResult>,
86+
next,
87+
end,
88+
hooks,
89+
);
90+
91+
result?.catch(end);
92+
});
93+
94+
const response = await engine.handle({
95+
jsonrpc: '2.0',
96+
id: 1,
97+
method: 'snap_getCurrencyRate',
98+
params: {
99+
currency: 'btc',
100+
},
101+
});
102+
103+
expect(response).toStrictEqual({
104+
jsonrpc: '2.0',
105+
id: 1,
106+
result: null,
107+
});
108+
});
109+
110+
it('throws on invalid params', async () => {
111+
const { implementation } = getCurrencyRateHandler;
112+
113+
const getCurrencyRate = jest.fn().mockReturnValue({
114+
currency: 'usd',
115+
conversionRate: 1,
116+
conversionDate: 1,
117+
usdConversionRate: 1,
118+
});
119+
120+
const hooks = {
121+
getCurrencyRate,
122+
};
123+
124+
const engine = new JsonRpcEngine();
125+
126+
engine.push((request, response, next, end) => {
127+
const result = implementation(
128+
request as JsonRpcRequest<GetCurrencyRateParameters>,
129+
response as PendingJsonRpcResponse<GetCurrencyRateResult>,
130+
next,
131+
end,
132+
hooks,
133+
);
134+
135+
result?.catch(end);
136+
});
137+
138+
const response = await engine.handle({
139+
jsonrpc: '2.0',
140+
id: 1,
141+
method: 'snap_getCurrencyRate',
142+
params: {
143+
currency: 'eth',
144+
},
145+
});
146+
147+
expect(response).toStrictEqual({
148+
error: {
149+
code: -32602,
150+
message:
151+
'Invalid params: At path: currency -- Expected the value to satisfy a union of `literal`, but received: "eth".',
152+
stack: expect.any(String),
153+
},
154+
id: 1,
155+
jsonrpc: '2.0',
156+
});
157+
});
158+
});
159+
});
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
import type { JsonRpcEngineEndCallback } from '@metamask/json-rpc-engine';
2+
import type { PermittedHandlerExport } from '@metamask/permission-controller';
3+
import { rpcErrors } from '@metamask/rpc-errors';
4+
import type {
5+
AvailableCurrency,
6+
CurrencyRate,
7+
GetCurrencyRateParams,
8+
GetCurrencyRateResult,
9+
JsonRpcRequest,
10+
} from '@metamask/snaps-sdk';
11+
import { currency, type InferMatching } from '@metamask/snaps-utils';
12+
import { StructError, create, object, union } from '@metamask/superstruct';
13+
import type { PendingJsonRpcResponse } from '@metamask/utils';
14+
15+
import type { MethodHooksObject } from '../utils';
16+
17+
const hookNames: MethodHooksObject<GetCurrencyRateMethodHooks> = {
18+
getCurrencyRate: true,
19+
};
20+
21+
export type GetCurrencyRateMethodHooks = {
22+
/**
23+
* @param currency - The currency symbol.
24+
* Currently only 'btc' is supported.
25+
* @returns The {@link CurrencyRate} object.
26+
*/
27+
getCurrencyRate: (currency: AvailableCurrency) => CurrencyRate | undefined;
28+
};
29+
30+
export const getCurrencyRateHandler: PermittedHandlerExport<
31+
GetCurrencyRateMethodHooks,
32+
GetCurrencyRateParameters,
33+
GetCurrencyRateResult
34+
> = {
35+
methodNames: ['snap_getCurrencyRate'],
36+
implementation: getGetCurrencyRateImplementation,
37+
hookNames,
38+
};
39+
40+
const GetCurrencyRateParametersStruct = object({
41+
currency: union([currency('btc')]),
42+
});
43+
44+
export type GetCurrencyRateParameters = InferMatching<
45+
typeof GetCurrencyRateParametersStruct,
46+
GetCurrencyRateParams
47+
>;
48+
49+
/**
50+
* The `snap_getCurrencyRate` method implementation.
51+
*
52+
* @param req - The JSON-RPC request object.
53+
* @param res - The JSON-RPC response object.
54+
* @param _next - The `json-rpc-engine` "next" callback. Not used by this
55+
* function.
56+
* @param end - The `json-rpc-engine` "end" callback.
57+
* @param hooks - The RPC method hooks.
58+
* @param hooks.getCurrencyRate - The function to get the rate.
59+
* @returns Nothing.
60+
*/
61+
function getGetCurrencyRateImplementation(
62+
req: JsonRpcRequest<GetCurrencyRateParameters>,
63+
res: PendingJsonRpcResponse<GetCurrencyRateResult>,
64+
_next: unknown,
65+
end: JsonRpcEngineEndCallback,
66+
{ getCurrencyRate }: GetCurrencyRateMethodHooks,
67+
): void {
68+
const { params } = req;
69+
70+
try {
71+
const validatedParams = getValidatedParams(params);
72+
73+
const { currency: selectedCurrency } = validatedParams;
74+
75+
res.result = getCurrencyRate(selectedCurrency) ?? null;
76+
} catch (error) {
77+
return end(error);
78+
}
79+
80+
return end();
81+
}
82+
83+
/**
84+
* Validate the getCurrencyRate method `params` and returns them cast to the correct
85+
* type. Throws if validation fails.
86+
*
87+
* @param params - The unvalidated params object from the method request.
88+
* @returns The validated getCurrencyRate method parameter object.
89+
*/
90+
function getValidatedParams(params: unknown): GetCurrencyRateParameters {
91+
try {
92+
return create(params, GetCurrencyRateParametersStruct);
93+
} catch (error) {
94+
if (error instanceof StructError) {
95+
throw rpcErrors.invalidParams({
96+
message: `Invalid params: ${error.message}.`,
97+
});
98+
}
99+
/* istanbul ignore next */
100+
throw rpcErrors.internal();
101+
}
102+
}

Diff for: ‎packages/snaps-rpc-methods/src/permitted/handlers.ts

+2
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { createInterfaceHandler } from './createInterface';
22
import { getAllSnapsHandler } from './getAllSnaps';
33
import { getClientStatusHandler } from './getClientStatus';
4+
import { getCurrencyRateHandler } from './getCurrencyRate';
45
import { getFileHandler } from './getFile';
56
import { getInterfaceStateHandler } from './getInterfaceState';
67
import { getSnapsHandler } from './getSnaps';
@@ -23,6 +24,7 @@ export const methodHandlers = {
2324
snap_updateInterface: updateInterfaceHandler,
2425
snap_getInterfaceState: getInterfaceStateHandler,
2526
snap_resolveInterface: resolveInterfaceHandler,
27+
snap_getCurrencyRate: getCurrencyRateHandler,
2628
};
2729
/* eslint-enable @typescript-eslint/naming-convention */
2830

Diff for: ‎packages/snaps-rpc-methods/src/permitted/index.ts

+3-1
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import type { CreateInterfaceMethodHooks } from './createInterface';
22
import type { GetAllSnapsHooks } from './getAllSnaps';
33
import type { GetClientStatusHooks } from './getClientStatus';
4+
import type { GetCurrencyRateMethodHooks } from './getCurrencyRate';
45
import type { GetInterfaceStateMethodHooks } from './getInterfaceState';
56
import type { GetSnapsHooks } from './getSnaps';
67
import type { RequestSnapsHooks } from './requestSnaps';
@@ -14,7 +15,8 @@ export type PermittedRpcMethodHooks = GetAllSnapsHooks &
1415
CreateInterfaceMethodHooks &
1516
UpdateInterfaceMethodHooks &
1617
GetInterfaceStateMethodHooks &
17-
ResolveInterfaceMethodHooks;
18+
ResolveInterfaceMethodHooks &
19+
GetCurrencyRateMethodHooks;
1820

1921
export * from './handlers';
2022
export * from './middleware';
+34
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
export type Currency<Value extends string> =
2+
| Lowercase<Value>
3+
| Uppercase<Value>;
4+
5+
export type AvailableCurrency = Currency<'btc'>;
6+
7+
/**
8+
* The currency rate object.
9+
*
10+
* @property currency - The native currency symbol used for the conversion (e.g 'usd').
11+
* @property conversionRate - The conversion rate from the cryptocurrency to the native currency.
12+
* @property conversionDate - The date of the conversion rate as a UNIX timestamp.
13+
* @property usdConversionRate - The conversion rate to USD.
14+
*/
15+
export type CurrencyRate = {
16+
currency: string;
17+
conversionRate: number;
18+
conversionDate: number;
19+
usdConversionRate?: number;
20+
};
21+
22+
/**
23+
* The request parameters for the `snap_getCurrencyRate` method.
24+
*
25+
* @property currency - The currency symbol.
26+
*/
27+
export type GetCurrencyRateParams = {
28+
currency: AvailableCurrency;
29+
};
30+
31+
/**
32+
* The result returned by the `snap_getCurrencyRate` method, which is the {@link CurrencyRate} object.
33+
*/
34+
export type GetCurrencyRateResult = CurrencyRate | null;

Diff for: ‎packages/snaps-sdk/src/types/methods/index.ts

+1
Original file line numberDiff line numberDiff line change
@@ -19,3 +19,4 @@ export * from './notify';
1919
export * from './request-snaps';
2020
export * from './update-interface';
2121
export * from './resolve-interface';
22+
export * from './get-currency-rate';

Diff for: ‎packages/snaps-utils/coverage.json

+2-2
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"branches": 99.74,
3-
"functions": 98.9,
3+
"functions": 98.91,
44
"lines": 99.45,
5-
"statements": 96.29
5+
"statements": 96.3
66
}

Diff for: ‎packages/snaps-utils/src/currency.test.ts

+23
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import { create } from '@metamask/superstruct';
2+
3+
import { currency } from './currency';
4+
5+
describe('currency', () => {
6+
it('returns a struct that accepts the currency symbol in either case', () => {
7+
const CurrencyStruct = currency('usd');
8+
9+
expect(create('usd', CurrencyStruct)).toBe('usd');
10+
expect(create('USD', CurrencyStruct)).toBe('usd');
11+
});
12+
13+
it.each([undefined, 42, {}, [], 'eur'])(
14+
'returns a struct that rejects invalid currency symbols',
15+
(value) => {
16+
const CurrencyStruct = currency('usd');
17+
18+
expect(() => create(value, CurrencyStruct)).toThrow(
19+
/Expected the literal `"usd"`, but received: .*/u,
20+
);
21+
},
22+
);
23+
});

Diff for: ‎packages/snaps-utils/src/currency.ts

+20
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import type { Struct } from '@metamask/superstruct';
2+
import { coerce, create, literal } from '@metamask/superstruct';
3+
4+
/**
5+
* A wrapper of Superstruct's `literal` struct that accepts a value in either
6+
* completely lowercase or completely uppercase (i.e., "usd" or "USD").
7+
*
8+
* @param string - The currency symbol.
9+
* @returns The struct that accepts the currency symbol in either case. It will
10+
* return the currency symbol in lowercase.
11+
*/
12+
export function currency<Value extends string>(
13+
string: Value,
14+
): Struct<Lowercase<Value> | Uppercase<Value>> {
15+
const lowerCase = string.toLowerCase();
16+
17+
return coerce(literal(lowerCase), literal(string.toUpperCase()), (value) => {
18+
return create(value.toLowerCase(), literal(lowerCase));
19+
}) as Struct<Lowercase<Value> | Uppercase<Value>>;
20+
}

Diff for: ‎packages/snaps-utils/src/index.ts

+1
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ export * from './caveats';
66
export * from './checksum';
77
export * from './constants';
88
export * from './cronjob';
9+
export * from './currency';
910
export * from './deep-clone';
1011
export * from './default-endowments';
1112
export * from './derivation-paths';

0 commit comments

Comments
 (0)
Please sign in to comment.