From c38725aeec071f7bdf983372b7ceb6ba63dc44cb Mon Sep 17 00:00:00 2001 From: kyranjamie Date: Fri, 15 Nov 2024 13:38:02 -0300 Subject: [PATCH] refactor: use zod for rpc validation --- package.json | 1 + pnpm-lock.yaml | 44 ++++++++-- src/shared/forms/address-validators.ts | 7 +- src/shared/rpc/methods/send-transfer.spec.ts | 42 +++++++++- src/shared/rpc/methods/send-transfer.ts | 83 +++++++++++-------- src/shared/rpc/methods/sign-message.ts | 22 +++-- src/shared/rpc/methods/sign-psbt.ts | 24 +++--- src/shared/rpc/methods/sign-stacks-message.ts | 15 ++-- .../rpc/methods/sign-stacks-transaction.ts | 16 ++-- src/shared/rpc/methods/validation.utils.ts | 30 +++---- 10 files changed, 183 insertions(+), 101 deletions(-) diff --git a/package.json b/package.json index 2b52415a9fc..6596b4a2695 100644 --- a/package.json +++ b/package.json @@ -260,6 +260,7 @@ "webextension-polyfill": "0.12.0", "yup": "1.4.0", "zod": "3.23.8", + "zod-validation-error": "3.4.0", "zxcvbn": "4.4.2" }, "devDependencies": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e7f4de70da6..8a2b54c84c2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -387,6 +387,9 @@ importers: zod: specifier: 3.23.8 version: 3.23.8 + zod-validation-error: + specifier: 3.4.0 + version: 3.4.0(zod@3.23.8) zxcvbn: specifier: 4.4.2 version: 4.4.2 @@ -15711,6 +15714,12 @@ packages: peerDependencies: zod: ^3.18.0 + zod-validation-error@3.4.0: + resolution: {integrity: sha512-ZOPR9SVY6Pb2qqO5XHt+MkkTRxGXb4EVtnjc9JpXUOtUB1T9Ru7mZOT361AN3MsetVe7R0a1KZshJDZdgp9miQ==} + engines: {node: '>=18.0.0'} + peerDependencies: + zod: ^3.18.0 + zod@3.23.8: resolution: {integrity: sha512-XBx9AXhXktjUqnepgTiE5flcKIYWi/rme0Eaj+5Y0lftuGBq+jyRu/md4WnuxqgP1ubdpNCsYEYPxrzVHD8d6g==} @@ -15984,7 +15993,7 @@ snapshots: '@babel/helper-annotate-as-pure': 7.24.7 '@babel/helper-member-expression-to-functions': 7.24.8 '@babel/helper-optimise-call-expression': 7.24.7 - '@babel/helper-replace-supers': 7.25.0(@babel/core@7.26.0) + '@babel/helper-replace-supers': 7.25.0(@babel/core@7.25.2) '@babel/helper-skip-transparent-expression-wrappers': 7.24.7 '@babel/traverse': 7.25.4(supports-color@5.5.0) semver: 6.3.1 @@ -16153,6 +16162,15 @@ snapshots: transitivePeerDependencies: - supports-color + '@babel/helper-replace-supers@7.25.0(@babel/core@7.25.2)': + dependencies: + '@babel/core': 7.25.2 + '@babel/helper-member-expression-to-functions': 7.24.8 + '@babel/helper-optimise-call-expression': 7.24.7 + '@babel/traverse': 7.25.4(supports-color@5.5.0) + transitivePeerDependencies: + - supports-color + '@babel/helper-replace-supers@7.25.0(@babel/core@7.26.0)': dependencies: '@babel/core': 7.26.0 @@ -16473,6 +16491,11 @@ snapshots: '@babel/core': 7.26.0 '@babel/helper-plugin-utils': 7.24.8 + '@babel/plugin-syntax-nullish-coalescing-operator@7.8.3(@babel/core@7.25.2)': + dependencies: + '@babel/core': 7.25.2 + '@babel/helper-plugin-utils': 7.24.8 + '@babel/plugin-syntax-nullish-coalescing-operator@7.8.3(@babel/core@7.26.0)': dependencies: '@babel/core': 7.26.0 @@ -16493,6 +16516,11 @@ snapshots: '@babel/core': 7.26.0 '@babel/helper-plugin-utils': 7.24.8 + '@babel/plugin-syntax-optional-chaining@7.8.3(@babel/core@7.25.2)': + dependencies: + '@babel/core': 7.25.2 + '@babel/helper-plugin-utils': 7.24.8 + '@babel/plugin-syntax-optional-chaining@7.8.3(@babel/core@7.26.0)': dependencies: '@babel/core': 7.26.0 @@ -16580,7 +16608,7 @@ snapshots: '@babel/plugin-transform-class-properties@7.25.4(@babel/core@7.25.2)': dependencies: '@babel/core': 7.25.2 - '@babel/helper-create-class-features-plugin': 7.25.4(@babel/core@7.26.0) + '@babel/helper-create-class-features-plugin': 7.25.4(@babel/core@7.25.2) '@babel/helper-plugin-utils': 7.24.8 transitivePeerDependencies: - supports-color @@ -16766,7 +16794,7 @@ snapshots: '@babel/plugin-transform-modules-commonjs@7.24.8(@babel/core@7.25.2)': dependencies: '@babel/core': 7.25.2 - '@babel/helper-module-transforms': 7.25.2(@babel/core@7.26.0) + '@babel/helper-module-transforms': 7.25.2(@babel/core@7.25.2) '@babel/helper-plugin-utils': 7.24.8 '@babel/helper-simple-access': 7.24.7 transitivePeerDependencies: @@ -16829,7 +16857,7 @@ snapshots: dependencies: '@babel/core': 7.25.2 '@babel/helper-plugin-utils': 7.24.8 - '@babel/plugin-syntax-nullish-coalescing-operator': 7.8.3(@babel/core@7.26.0) + '@babel/plugin-syntax-nullish-coalescing-operator': 7.8.3(@babel/core@7.25.2) '@babel/plugin-transform-nullish-coalescing-operator@7.24.7(@babel/core@7.26.0)': dependencies: @@ -16882,7 +16910,7 @@ snapshots: '@babel/core': 7.25.2 '@babel/helper-plugin-utils': 7.24.8 '@babel/helper-skip-transparent-expression-wrappers': 7.24.7 - '@babel/plugin-syntax-optional-chaining': 7.8.3(@babel/core@7.26.0) + '@babel/plugin-syntax-optional-chaining': 7.8.3(@babel/core@7.25.2) transitivePeerDependencies: - supports-color @@ -16916,7 +16944,7 @@ snapshots: '@babel/plugin-transform-private-methods@7.25.4(@babel/core@7.25.2)': dependencies: '@babel/core': 7.25.2 - '@babel/helper-create-class-features-plugin': 7.25.4(@babel/core@7.26.0) + '@babel/helper-create-class-features-plugin': 7.25.4(@babel/core@7.25.2) '@babel/helper-plugin-utils': 7.24.8 transitivePeerDependencies: - supports-color @@ -35249,6 +35277,10 @@ snapshots: dependencies: zod: 3.23.8 + zod-validation-error@3.4.0(zod@3.23.8): + dependencies: + zod: 3.23.8 + zod@3.23.8: {} zone-file@2.0.0-beta.3: {} diff --git a/src/shared/forms/address-validators.ts b/src/shared/forms/address-validators.ts index 302250aaf19..82b7fec6ee2 100644 --- a/src/shared/forms/address-validators.ts +++ b/src/shared/forms/address-validators.ts @@ -1,4 +1,4 @@ -import { Network, validate } from 'bitcoin-address-validation'; +import { Network, getAddressInfo, validate } from 'bitcoin-address-validation'; import * as yup from 'yup'; import type { BitcoinNetworkModes } from '@leather.io/models'; @@ -23,6 +23,10 @@ export function btcAddressValidator() { }); } +export function getNetworkTypeFromAddress(address: string) { + return getAddressInfo(address).network as BitcoinNetworkModes; +} + function btcAddressNetworkValidatorFactory(network: BitcoinNetworkModes) { function getAddressNetworkType(network: BitcoinNetworkModes): Network { // Signet uses testnet address format, this parsing is to please the @@ -30,7 +34,6 @@ function btcAddressNetworkValidatorFactory(network: BitcoinNetworkModes) { if (network === 'signet') return Network.testnet; return network as Network; } - return (value?: string) => { if (isUndefined(value) || isEmptyString(value)) return true; return validate(value, getAddressNetworkType(network)); diff --git a/src/shared/rpc/methods/send-transfer.spec.ts b/src/shared/rpc/methods/send-transfer.spec.ts index e9601c30336..0104b3e53b7 100644 --- a/src/shared/rpc/methods/send-transfer.spec.ts +++ b/src/shared/rpc/methods/send-transfer.spec.ts @@ -14,7 +14,7 @@ describe('`sendTransfer` method', () => { amount: '10000', }; - expect(rpcSendTransferParamsSchemaLegacy.isValidSync(params)).toBeTruthy(); + expect(rpcSendTransferParamsSchemaLegacy.safeParse(params).success).toEqual(true); }); test('that it validates multiple recipient sends', () => { @@ -33,7 +33,7 @@ describe('`sendTransfer` method', () => { ], }; - expect(rpcSendTransferParamsSchema.isValidSync(params)).toBeTruthy(); + expect(rpcSendTransferParamsSchema.safeParse(params).success).toEqual(true); }); test('that it fails validation for missing required fields', () => { @@ -42,7 +42,7 @@ describe('`sendTransfer` method', () => { account: 0, }; - expect(() => rpcSendTransferParamsSchema.validateSync(params)).toThrow(); + expect(() => rpcSendTransferParamsSchema.parse(params)).toThrow(); }); test('that it converts legacy params to new params', () => { @@ -67,4 +67,40 @@ describe('`sendTransfer` method', () => { expect(convertRpcSendTransferLegacyParamsToNew(legacyParams)).toEqual(newParams); }); }); + + test('that it fails if addresses do not match the defined network', () => { + const result = rpcSendTransferParamsSchema.safeParse({ + network: 'mainnet', + account: 0, + recipients: [ + { + // Expected to fail because we've passed a testnet address + address: 'tb1pjqhc5xw5xuhhg3zvk5p545q055e4farx37pvwnj2plt9t8lezvys0n58yz', + amount: '10000', + }, + ], + }); + expect(result.success).toEqual(false); + }); + + test('that it fails when addresses of different networks are passed', () => { + const result = rpcSendTransferParamsSchema.safeParse({ + network: 'mainnet', + account: 0, + recipients: [ + { + address: 'bc1qar0srrr7xfkvy5l643lydnw9re59gtzzwf5mdq', + amount: '100', + }, + { + address: 'tb1pjqhc5xw5xuhhg3zvk5p545q055e4farx37pvwnj2plt9t8lezvys0n58yz', + amount: '100', + }, + ], + }); + expect(result.error?.issues.map(issue => issue.message)).toContain( + 'Cannot tranfer to addresses of different networks' + ); + expect(result.success).toEqual(false); + }); }); diff --git a/src/shared/rpc/methods/send-transfer.ts b/src/shared/rpc/methods/send-transfer.ts index 37b3879874f..63d2ed93ea6 100644 --- a/src/shared/rpc/methods/send-transfer.ts +++ b/src/shared/rpc/methods/send-transfer.ts @@ -1,10 +1,14 @@ import type { SendTransferRequestParams } from '@btckit/types'; -import * as yup from 'yup'; +import { z } from 'zod'; import { type BitcoinNetworkModes, WalletDefaultNetworkConfigurationIds } from '@leather.io/models'; +import { uniqueArray } from '@leather.io/utils'; import { FormErrorMessages } from '@shared/error-messages'; -import { btcAddressNetworkValidator, btcAddressValidator } from '@shared/forms/address-validators'; +import { + btcAddressNetworkValidator, + getNetworkTypeFromAddress, +} from '@shared/forms/address-validators'; import { checkIfDigitsOnly } from '@shared/forms/amount-validators'; import { @@ -16,42 +20,51 @@ import { export const defaultRpcSendTransferNetwork = 'mainnet'; -export const rpcSendTransferParamsSchemaLegacy = yup.object().shape({ - account: accountSchema, - address: yup.string().required(), - amount: yup.string().required(), - network: yup.string().oneOf(Object.values(WalletDefaultNetworkConfigurationIds)), +export const rpcSendTransferParamsSchemaLegacy = z.object({ + account: accountSchema.optional(), + address: z.string(), + amount: z.string(), + network: z + .enum(Object.values(WalletDefaultNetworkConfigurationIds) as [string, ...string[]]) + .optional(), }); -export const rpcSendTransferParamsSchema = yup.object().shape({ - account: accountSchema, - network: yup.string().oneOf(Object.values(WalletDefaultNetworkConfigurationIds)), - recipients: yup - .array() - .required() - .of( - yup.object().shape({ - // check network is valid for address - address: btcAddressValidator().test( - 'address-network-validation', - FormErrorMessages.IncorrectNetworkAddress, - (value, context) => { - const contextOptions = context.options as any; - const network = - (contextOptions.from[1].value.network as BitcoinNetworkModes) || - defaultRpcSendTransferNetwork; - return btcAddressNetworkValidator(network).isValidSync(value); - } - ), - amount: yup - .string() - .required() - .test('amount-validation', 'Sat denominated amounts only', value => { - return checkIfDigitsOnly(value); +export const rpcSendTransferParamsSchema = z + .object({ + account: accountSchema.optional(), + network: z + .enum(Object.values(WalletDefaultNetworkConfigurationIds) as [string, ...string[]]) + .optional(), + recipients: z + .array( + z.object({ + address: z.string(), + amount: z.string().refine(value => checkIfDigitsOnly(value), { + message: 'Sat denominated amounts only', }), - }) - ), -}); + }) + ) + .nonempty() + .refine( + recipients => { + const inferredNetworksByAddress = recipients.map(({ address }) => + getNetworkTypeFromAddress(address) + ); + return uniqueArray(inferredNetworksByAddress).length === 1; + }, + { message: 'Cannot tranfer to addresses of different networks', path: ['recipients'] } + ), + }) + .refine( + ({ network, recipients }) => { + const addressNetworks = recipients.map(recipient => + btcAddressNetworkValidator(network as BitcoinNetworkModes).isValidSync(recipient.address) + ); + + return !addressNetworks.some(val => val === false); + }, + { message: FormErrorMessages.IncorrectNetworkAddress, path: ['recipients'] } + ); export interface RpcSendTransferParamsLegacy extends SendTransferRequestParams { network: string; diff --git a/src/shared/rpc/methods/sign-message.ts b/src/shared/rpc/methods/sign-message.ts index 728a6736e1a..4328e239acd 100644 --- a/src/shared/rpc/methods/sign-message.ts +++ b/src/shared/rpc/methods/sign-message.ts @@ -1,7 +1,7 @@ -import { PaymentTypes } from '@btckit/types'; -import * as yup from 'yup'; +import { z } from 'zod'; import { WalletDefaultNetworkConfigurationIds } from '@leather.io/models'; +import type { PaymentTypes } from '@leather.io/rpc'; import { accountSchema, @@ -10,18 +10,16 @@ import { validateRpcParams, } from './validation.utils'; -// TODO: Import Bip322MessageTypes from btckit when updated -type SupportedBip322MessageTypes = 'bip322'; - -const rpcSignMessageParamsSchema = yup.object().shape({ - type: yup.string(), - account: accountSchema, - message: yup.string().required(), - network: yup.string().oneOf(Object.values(WalletDefaultNetworkConfigurationIds)), - paymentType: yup.string(), +const rpcSignMessageParamsSchema = z.object({ + type: z.enum(['bip322']).optional(), + account: accountSchema.optional(), + message: z.string(), + network: z + .enum(Object.values(WalletDefaultNetworkConfigurationIds) as [string, ...string[]]) + .optional(), + paymentType: z.enum(['p2tr', 'p2wpkh'] as [PaymentTypes, PaymentTypes]).optional(), }); -// TODO: Import param types from btckit when updated export function validateRpcSignMessageParams(obj: unknown) { return validateRpcParams(obj, rpcSignMessageParamsSchema); } diff --git a/src/shared/rpc/methods/sign-psbt.ts b/src/shared/rpc/methods/sign-psbt.ts index 71085f8cc07..587816fec02 100644 --- a/src/shared/rpc/methods/sign-psbt.ts +++ b/src/shared/rpc/methods/sign-psbt.ts @@ -1,5 +1,5 @@ import { SigHash } from '@scure/btc-signer/transaction'; -import * as yup from 'yup'; +import { z } from 'zod'; import { WalletDefaultNetworkConfigurationIds } from '@leather.io/models'; import { @@ -31,16 +31,20 @@ export const allSighashTypes = [ BtcKitSignatureHash.SINGLE_ANYONECANPAY, ]; -const rpcSignPsbtParamsSchema = yup.object().shape({ - account: accountSchema, - allowedSighash: yup.array(), - broadcast: yup.boolean(), - hex: yup.string().required(), - network: yup.string().oneOf(Object.values(WalletDefaultNetworkConfigurationIds)), - signAtIndex: yup.mixed().test(testIsNumberOrArrayOfNumbers), +const rpcSignPsbtParamsSchema = z.object({ + account: accountSchema.optional(), + allowedSighash: z.array(z.any()).optional(), + broadcast: z.boolean().optional(), + hex: z.string(), + network: z + .enum(Object.values(WalletDefaultNetworkConfigurationIds) as [string, ...string[]]) + .optional(), + signAtIndex: z + .union([z.number(), z.array(z.number())]) + .optional() + .refine(testIsNumberOrArrayOfNumbers), }); -// TODO: Import param types from btckit when updated export function validateRpcSignPsbtParams(obj: unknown) { return validateRpcParams(obj, rpcSignPsbtParamsSchema); } @@ -49,7 +53,7 @@ export function getRpcSignPsbtParamErrors(obj: unknown) { return formatValidationErrors(getRpcParamErrors(obj, rpcSignPsbtParamsSchema)); } -type SignPsbtRequestParams = yup.InferType; +type SignPsbtRequestParams = z.infer; export type SignPsbtRequest = RpcRequest<'signPsbt', SignPsbtRequestParams>; diff --git a/src/shared/rpc/methods/sign-stacks-message.ts b/src/shared/rpc/methods/sign-stacks-message.ts index 7dab65cfb3b..78a73dd3dd1 100644 --- a/src/shared/rpc/methods/sign-stacks-message.ts +++ b/src/shared/rpc/methods/sign-stacks-message.ts @@ -1,16 +1,17 @@ import { DefineRpcMethod, RpcRequest, RpcResponse } from '@btckit/types'; import { StacksNetworks } from '@stacks/network'; -import * as yup from 'yup'; +import { z } from 'zod'; import { formatValidationErrors, getRpcParamErrors, validateRpcParams } from './validation.utils'; const SignedMessageTypeArray = ['utf8', 'structured'] as const; -const rpcSignStacksMessageParamsSchema = yup.object().shape({ - network: yup.string().oneOf(StacksNetworks), - message: yup.string().required(), - domain: yup.string(), - messageType: yup.string().oneOf(SignedMessageTypeArray).required(), +// TODO: refactor to use .discriminatedUnion +const rpcSignStacksMessageParamsSchema = z.object({ + network: z.enum(StacksNetworks).optional(), + message: z.string(), + domain: z.string().optional(), + messageType: z.enum(SignedMessageTypeArray), }); export function validateRpcSignStacksMessageParams(obj: unknown) { @@ -21,7 +22,7 @@ export function getRpcSignStacksMessageParamErrors(obj: unknown) { return formatValidationErrors(getRpcParamErrors(obj, rpcSignStacksMessageParamsSchema)); } -type SignStacksMessageRequestParams = yup.InferType; +type SignStacksMessageRequestParams = z.infer; export type SignStacksMessageRequest = RpcRequest< 'stx_signMessage', diff --git a/src/shared/rpc/methods/sign-stacks-transaction.ts b/src/shared/rpc/methods/sign-stacks-transaction.ts index a950788f64a..8b01e65904a 100644 --- a/src/shared/rpc/methods/sign-stacks-transaction.ts +++ b/src/shared/rpc/methods/sign-stacks-transaction.ts @@ -1,14 +1,14 @@ import { DefineRpcMethod, RpcRequest, RpcResponse } from '@btckit/types'; import { StacksNetworks } from '@stacks/network'; -import * as yup from 'yup'; +import { z } from 'zod'; import { formatValidationErrors, getRpcParamErrors, validateRpcParams } from './validation.utils'; -const rpcSignStacksTransactionParamsSchema = yup.object().shape({ - stxAddress: yup.string(), - txHex: yup.string().required(), - attachment: yup.string(), - network: yup.string().oneOf(StacksNetworks), +const rpcSignStacksTransactionParamsSchema = z.object({ + stxAddress: z.string().optional(), + txHex: z.string(), + attachment: z.string().optional(), + network: z.enum(StacksNetworks).optional(), }); export function validateRpcSignStacksTransactionParams(obj: unknown) { @@ -19,9 +19,7 @@ export function getRpcSignStacksTransactionParamErrors(obj: unknown) { return formatValidationErrors(getRpcParamErrors(obj, rpcSignStacksTransactionParamsSchema)); } -type SignStacksTransactionRequestParams = yup.InferType< - typeof rpcSignStacksTransactionParamsSchema ->; +type SignStacksTransactionRequestParams = z.infer; export type SignStacksTransactionRequest = RpcRequest< 'stx_signTransaction', diff --git a/src/shared/rpc/methods/validation.utils.ts b/src/shared/rpc/methods/validation.utils.ts index 2db10aae1d0..5cf80211328 100644 --- a/src/shared/rpc/methods/validation.utils.ts +++ b/src/shared/rpc/methods/validation.utils.ts @@ -1,38 +1,34 @@ -import * as yup from 'yup'; +import { z } from 'zod'; +import { fromError } from 'zod-validation-error'; import { isNumber, isUndefined } from '@leather.io/utils'; -export const accountSchema = yup.number().integer(); +export const accountSchema = z.number().int(); -export function validateRpcParams( - obj: unknown, - validator: yup.ObjectSchema> -) { +export function validateRpcParams(obj: unknown, validator: z.ZodSchema) { try { - validator.validateSync(obj, { abortEarly: false }); + validator.parse(obj); return true; } catch (e) { return false; } } -export function getRpcParamErrors( - obj: unknown, - validator: yup.ObjectSchema> -) { +export function getRpcParamErrors(obj: unknown, validator: z.ZodTypeAny) { try { - validator.validateSync(obj, { abortEarly: false }); + validator.parse(obj); return []; } catch (e) { - if (e instanceof yup.ValidationError) return e.inner; + if (e instanceof z.ZodError) return [e]; return []; } } -export function formatValidationErrors(errors: yup.ValidationError[]) { - return ( - 'Invalid parameters: ' + errors.map(e => `Error in path ${e.path}, ${e.message}.`).join(' ') - ); +export function formatValidationErrors(errors: z.ZodError[]) { + return errors + .map(error => fromError(error)) + .join('. ') + .trim(); } export function testIsNumberOrArrayOfNumbers(value: unknown) {