diff --git a/packages/examples/packages/browserify-plugin/snap.manifest.json b/packages/examples/packages/browserify-plugin/snap.manifest.json index 5fe60b987f..304213343d 100644 --- a/packages/examples/packages/browserify-plugin/snap.manifest.json +++ b/packages/examples/packages/browserify-plugin/snap.manifest.json @@ -7,7 +7,7 @@ "url": "https://github.com/MetaMask/snaps.git" }, "source": { - "shasum": "+w62Op5ur4nVLmQ0uKA0IsAQN2hkKOsgSm4VK9jxxYY=", + "shasum": "zGgTjLWTEn796eXGvv66p8tGxZSa82yEEGnRtyVutEc=", "location": { "npm": { "filePath": "dist/bundle.js", diff --git a/packages/examples/packages/browserify/snap.manifest.json b/packages/examples/packages/browserify/snap.manifest.json index ae76aea210..80491ec47b 100644 --- a/packages/examples/packages/browserify/snap.manifest.json +++ b/packages/examples/packages/browserify/snap.manifest.json @@ -7,7 +7,7 @@ "url": "https://github.com/MetaMask/snaps.git" }, "source": { - "shasum": "SL7kg2vwhtpuzNvOx7uQZNZbccRgfFtTi0xZdEVEP0s=", + "shasum": "qfkidJLew8JNN2Enx4pDUgWNgLPqBkG0k3mGQRR1oaY=", "location": { "npm": { "filePath": "dist/bundle.js", diff --git a/packages/snaps-rpc-methods/jest.config.js b/packages/snaps-rpc-methods/jest.config.js index e48443e6ec..50b06f19c0 100644 --- a/packages/snaps-rpc-methods/jest.config.js +++ b/packages/snaps-rpc-methods/jest.config.js @@ -10,10 +10,10 @@ module.exports = deepmerge(baseConfig, { ], coverageThreshold: { global: { - branches: 92.68, - functions: 97.17, - lines: 97.71, - statements: 97.21, + branches: 92.77, + functions: 97.2, + lines: 97.76, + statements: 97.26, }, }, }); diff --git a/packages/snaps-rpc-methods/src/permitted/getCurrencyRate.test.ts b/packages/snaps-rpc-methods/src/permitted/getCurrencyRate.test.ts new file mode 100644 index 0000000000..da8191fcc4 --- /dev/null +++ b/packages/snaps-rpc-methods/src/permitted/getCurrencyRate.test.ts @@ -0,0 +1,159 @@ +import { JsonRpcEngine } from '@metamask/json-rpc-engine'; +import { type GetCurrencyRateResult } from '@metamask/snaps-sdk'; +import type { JsonRpcRequest, PendingJsonRpcResponse } from '@metamask/utils'; + +import type { GetCurrencyRateParameters } from './getCurrencyRate'; +import { getCurrencyRateHandler } from './getCurrencyRate'; + +describe('snap_getCurrencyRate', () => { + describe('getCurrencyRateHandler', () => { + it('has the expected shape', () => { + expect(getCurrencyRateHandler).toMatchObject({ + methodNames: ['snap_getCurrencyRate'], + implementation: expect.any(Function), + hookNames: { + getCurrencyRate: true, + }, + }); + }); + }); + + describe('implementation', () => { + it('returns the result from the `getCurrencyRate` hook', async () => { + const { implementation } = getCurrencyRateHandler; + + const getCurrencyRate = jest.fn().mockReturnValue({ + currency: 'usd', + conversionRate: 1, + conversionDate: 1, + usdConversionRate: 1, + }); + + const hooks = { + getCurrencyRate, + }; + + const engine = new JsonRpcEngine(); + + engine.push((request, response, next, end) => { + const result = implementation( + request as JsonRpcRequest, + response as PendingJsonRpcResponse, + next, + end, + hooks, + ); + + result?.catch(end); + }); + + const response = await engine.handle({ + jsonrpc: '2.0', + id: 1, + method: 'snap_getCurrencyRate', + params: { + currency: 'btc', + }, + }); + + expect(response).toStrictEqual({ + jsonrpc: '2.0', + id: 1, + result: { + currency: 'usd', + conversionRate: 1, + conversionDate: 1, + usdConversionRate: 1, + }, + }); + }); + + it('returns null if there is no rate available', async () => { + const { implementation } = getCurrencyRateHandler; + + const getCurrencyRate = jest.fn().mockReturnValue(undefined); + + const hooks = { + getCurrencyRate, + }; + + const engine = new JsonRpcEngine(); + + engine.push((request, response, next, end) => { + const result = implementation( + request as JsonRpcRequest, + response as PendingJsonRpcResponse, + next, + end, + hooks, + ); + + result?.catch(end); + }); + + const response = await engine.handle({ + jsonrpc: '2.0', + id: 1, + method: 'snap_getCurrencyRate', + params: { + currency: 'btc', + }, + }); + + expect(response).toStrictEqual({ + jsonrpc: '2.0', + id: 1, + result: null, + }); + }); + + it('throws on invalid params', async () => { + const { implementation } = getCurrencyRateHandler; + + const getCurrencyRate = jest.fn().mockReturnValue({ + currency: 'usd', + conversionRate: 1, + conversionDate: 1, + usdConversionRate: 1, + }); + + const hooks = { + getCurrencyRate, + }; + + const engine = new JsonRpcEngine(); + + engine.push((request, response, next, end) => { + const result = implementation( + request as JsonRpcRequest, + response as PendingJsonRpcResponse, + next, + end, + hooks, + ); + + result?.catch(end); + }); + + const response = await engine.handle({ + jsonrpc: '2.0', + id: 1, + method: 'snap_getCurrencyRate', + params: { + currency: 'eth', + }, + }); + + expect(response).toStrictEqual({ + error: { + code: -32602, + message: + 'Invalid params: At path: currency -- Expected the value to satisfy a union of `literal`, but received: "eth".', + stack: expect.any(String), + }, + id: 1, + jsonrpc: '2.0', + }); + }); + }); +}); diff --git a/packages/snaps-rpc-methods/src/permitted/getCurrencyRate.ts b/packages/snaps-rpc-methods/src/permitted/getCurrencyRate.ts new file mode 100644 index 0000000000..99b57e721a --- /dev/null +++ b/packages/snaps-rpc-methods/src/permitted/getCurrencyRate.ts @@ -0,0 +1,102 @@ +import type { JsonRpcEngineEndCallback } from '@metamask/json-rpc-engine'; +import type { PermittedHandlerExport } from '@metamask/permission-controller'; +import { rpcErrors } from '@metamask/rpc-errors'; +import type { + AvailableCurrency, + CurrencyRate, + GetCurrencyRateParams, + GetCurrencyRateResult, + JsonRpcRequest, +} from '@metamask/snaps-sdk'; +import { currency, type InferMatching } from '@metamask/snaps-utils'; +import { StructError, create, object, union } from '@metamask/superstruct'; +import type { PendingJsonRpcResponse } from '@metamask/utils'; + +import type { MethodHooksObject } from '../utils'; + +const hookNames: MethodHooksObject = { + getCurrencyRate: true, +}; + +export type GetCurrencyRateMethodHooks = { + /** + * @param currency - The currency symbol. + * Currently only 'btc' is supported. + * @returns The {@link CurrencyRate} object. + */ + getCurrencyRate: (currency: AvailableCurrency) => CurrencyRate | undefined; +}; + +export const getCurrencyRateHandler: PermittedHandlerExport< + GetCurrencyRateMethodHooks, + GetCurrencyRateParameters, + GetCurrencyRateResult +> = { + methodNames: ['snap_getCurrencyRate'], + implementation: getGetCurrencyRateImplementation, + hookNames, +}; + +const GetCurrencyRateParametersStruct = object({ + currency: union([currency('btc')]), +}); + +export type GetCurrencyRateParameters = InferMatching< + typeof GetCurrencyRateParametersStruct, + GetCurrencyRateParams +>; + +/** + * The `snap_getCurrencyRate` method implementation. + * + * @param req - The JSON-RPC request object. + * @param res - The JSON-RPC response object. + * @param _next - The `json-rpc-engine` "next" callback. Not used by this + * function. + * @param end - The `json-rpc-engine` "end" callback. + * @param hooks - The RPC method hooks. + * @param hooks.getCurrencyRate - The function to get the rate. + * @returns Nothing. + */ +function getGetCurrencyRateImplementation( + req: JsonRpcRequest, + res: PendingJsonRpcResponse, + _next: unknown, + end: JsonRpcEngineEndCallback, + { getCurrencyRate }: GetCurrencyRateMethodHooks, +): void { + const { params } = req; + + try { + const validatedParams = getValidatedParams(params); + + const { currency: selectedCurrency } = validatedParams; + + res.result = getCurrencyRate(selectedCurrency) ?? null; + } catch (error) { + return end(error); + } + + return end(); +} + +/** + * Validate the getCurrencyRate method `params` and returns them cast to the correct + * type. Throws if validation fails. + * + * @param params - The unvalidated params object from the method request. + * @returns The validated getCurrencyRate method parameter object. + */ +function getValidatedParams(params: unknown): GetCurrencyRateParameters { + try { + return create(params, GetCurrencyRateParametersStruct); + } catch (error) { + if (error instanceof StructError) { + throw rpcErrors.invalidParams({ + message: `Invalid params: ${error.message}.`, + }); + } + /* istanbul ignore next */ + throw rpcErrors.internal(); + } +} diff --git a/packages/snaps-rpc-methods/src/permitted/handlers.ts b/packages/snaps-rpc-methods/src/permitted/handlers.ts index 8304bc7e42..294192aa13 100644 --- a/packages/snaps-rpc-methods/src/permitted/handlers.ts +++ b/packages/snaps-rpc-methods/src/permitted/handlers.ts @@ -1,6 +1,7 @@ import { createInterfaceHandler } from './createInterface'; import { getAllSnapsHandler } from './getAllSnaps'; import { getClientStatusHandler } from './getClientStatus'; +import { getCurrencyRateHandler } from './getCurrencyRate'; import { getFileHandler } from './getFile'; import { getInterfaceStateHandler } from './getInterfaceState'; import { getSnapsHandler } from './getSnaps'; @@ -23,6 +24,7 @@ export const methodHandlers = { snap_updateInterface: updateInterfaceHandler, snap_getInterfaceState: getInterfaceStateHandler, snap_resolveInterface: resolveInterfaceHandler, + snap_getCurrencyRate: getCurrencyRateHandler, }; /* eslint-enable @typescript-eslint/naming-convention */ diff --git a/packages/snaps-rpc-methods/src/permitted/index.ts b/packages/snaps-rpc-methods/src/permitted/index.ts index 7600c4e573..604493ee55 100644 --- a/packages/snaps-rpc-methods/src/permitted/index.ts +++ b/packages/snaps-rpc-methods/src/permitted/index.ts @@ -1,6 +1,7 @@ import type { CreateInterfaceMethodHooks } from './createInterface'; import type { GetAllSnapsHooks } from './getAllSnaps'; import type { GetClientStatusHooks } from './getClientStatus'; +import type { GetCurrencyRateMethodHooks } from './getCurrencyRate'; import type { GetInterfaceStateMethodHooks } from './getInterfaceState'; import type { GetSnapsHooks } from './getSnaps'; import type { RequestSnapsHooks } from './requestSnaps'; @@ -14,7 +15,8 @@ export type PermittedRpcMethodHooks = GetAllSnapsHooks & CreateInterfaceMethodHooks & UpdateInterfaceMethodHooks & GetInterfaceStateMethodHooks & - ResolveInterfaceMethodHooks; + ResolveInterfaceMethodHooks & + GetCurrencyRateMethodHooks; export * from './handlers'; export * from './middleware'; diff --git a/packages/snaps-sdk/src/types/methods/get-currency-rate.ts b/packages/snaps-sdk/src/types/methods/get-currency-rate.ts new file mode 100644 index 0000000000..bbd75a229e --- /dev/null +++ b/packages/snaps-sdk/src/types/methods/get-currency-rate.ts @@ -0,0 +1,34 @@ +export type Currency = + | Lowercase + | Uppercase; + +export type AvailableCurrency = Currency<'btc'>; + +/** + * The currency rate object. + * + * @property currency - The native currency symbol used for the conversion (e.g 'usd'). + * @property conversionRate - The conversion rate from the cryptocurrency to the native currency. + * @property conversionDate - The date of the conversion rate as a UNIX timestamp. + * @property usdConversionRate - The conversion rate to USD. + */ +export type CurrencyRate = { + currency: string; + conversionRate: number; + conversionDate: number; + usdConversionRate?: number; +}; + +/** + * The request parameters for the `snap_getCurrencyRate` method. + * + * @property currency - The currency symbol. + */ +export type GetCurrencyRateParams = { + currency: AvailableCurrency; +}; + +/** + * The result returned by the `snap_getCurrencyRate` method, which is the {@link CurrencyRate} object. + */ +export type GetCurrencyRateResult = CurrencyRate | null; diff --git a/packages/snaps-sdk/src/types/methods/index.ts b/packages/snaps-sdk/src/types/methods/index.ts index 75908d229e..8ab24b16c7 100644 --- a/packages/snaps-sdk/src/types/methods/index.ts +++ b/packages/snaps-sdk/src/types/methods/index.ts @@ -19,3 +19,4 @@ export * from './notify'; export * from './request-snaps'; export * from './update-interface'; export * from './resolve-interface'; +export * from './get-currency-rate'; diff --git a/packages/snaps-utils/coverage.json b/packages/snaps-utils/coverage.json index deb8bde080..7e74294584 100644 --- a/packages/snaps-utils/coverage.json +++ b/packages/snaps-utils/coverage.json @@ -1,6 +1,6 @@ { "branches": 99.74, - "functions": 98.9, + "functions": 98.91, "lines": 99.45, - "statements": 96.29 + "statements": 96.3 } diff --git a/packages/snaps-utils/src/currency.test.ts b/packages/snaps-utils/src/currency.test.ts new file mode 100644 index 0000000000..08a9b8d9d6 --- /dev/null +++ b/packages/snaps-utils/src/currency.test.ts @@ -0,0 +1,23 @@ +import { create } from '@metamask/superstruct'; + +import { currency } from './currency'; + +describe('currency', () => { + it('returns a struct that accepts the currency symbol in either case', () => { + const CurrencyStruct = currency('usd'); + + expect(create('usd', CurrencyStruct)).toBe('usd'); + expect(create('USD', CurrencyStruct)).toBe('usd'); + }); + + it.each([undefined, 42, {}, [], 'eur'])( + 'returns a struct that rejects invalid currency symbols', + (value) => { + const CurrencyStruct = currency('usd'); + + expect(() => create(value, CurrencyStruct)).toThrow( + /Expected the literal `"usd"`, but received: .*/u, + ); + }, + ); +}); diff --git a/packages/snaps-utils/src/currency.ts b/packages/snaps-utils/src/currency.ts new file mode 100644 index 0000000000..e3a46c762a --- /dev/null +++ b/packages/snaps-utils/src/currency.ts @@ -0,0 +1,20 @@ +import type { Struct } from '@metamask/superstruct'; +import { coerce, create, literal } from '@metamask/superstruct'; + +/** + * A wrapper of Superstruct's `literal` struct that accepts a value in either + * completely lowercase or completely uppercase (i.e., "usd" or "USD"). + * + * @param string - The currency symbol. + * @returns The struct that accepts the currency symbol in either case. It will + * return the currency symbol in lowercase. + */ +export function currency( + string: Value, +): Struct | Uppercase> { + const lowerCase = string.toLowerCase(); + + return coerce(literal(lowerCase), literal(string.toUpperCase()), (value) => { + return create(value.toLowerCase(), literal(lowerCase)); + }) as Struct | Uppercase>; +} diff --git a/packages/snaps-utils/src/index.ts b/packages/snaps-utils/src/index.ts index aa87a494f1..ae1c397abf 100644 --- a/packages/snaps-utils/src/index.ts +++ b/packages/snaps-utils/src/index.ts @@ -6,6 +6,7 @@ export * from './caveats'; export * from './checksum'; export * from './constants'; export * from './cronjob'; +export * from './currency'; export * from './deep-clone'; export * from './default-endowments'; export * from './derivation-paths';