Skip to content

Commit

Permalink
chore: send form validation
Browse files Browse the repository at this point in the history
  • Loading branch information
pete-watters committed Jan 24, 2025
1 parent 70cf8d4 commit c3073d9
Show file tree
Hide file tree
Showing 20 changed files with 441 additions and 40 deletions.
2 changes: 1 addition & 1 deletion apps/mobile/src/features/psbt-signer/psbt-signer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ import {
import { ApproverButtons } from '../approver/components/approver-buttons';
import { FeesSheet } from '../approver/components/fees/bitcoin-fee-sheet';
import { ApproverState } from '../approver/utils';
import { createCoinSelectionUtxos } from '../send/send-form.utils';
import { createCoinSelectionUtxos } from '../send/send-form/send-form.utils';
import { usePsbtAccounts } from './use-psbt-accounts';
import { usePsbtPayers } from './use-psbt-payers';
import { usePsbtSigner } from './use-psbt-signer';
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,15 +17,15 @@ import {
import { AverageBitcoinFeeRates } from '@leather.io/models';
import { createMoneyFromDecimal } from '@leather.io/utils';

import { SendFormBtcContext } from '../providers/send-form-btc-provider';
import { SendFormBtcSchema } from '../schemas/send-form-btc.schema';
import {
CreateCurrentSendRoute,
createCoinSelectionUtxos,
formatBitcoinError,
useSendSheetNavigation,
useSendSheetRoute,
} from '../../send-form.utils';
import { SendFormBtcContext } from '../providers/send-form-btc-provider';
import { SendFormBtcSchema } from '../schemas/send-form-btc.schema';
} from '../send-form.utils';

type CurrentRoute = CreateCurrentSendRoute<'send-form-btc'>;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,13 @@ import BigNumber from 'bignumber.js';

import { createMoneyFromDecimal } from '@leather.io/utils';

import { SendFormStxContext } from '../providers/send-form-stx-provider';
import { SendFormStxSchema } from '../schemas/send-form-stx.schema';
import {
CreateCurrentSendRoute,
useSendSheetNavigation,
useSendSheetRoute,
} from '../../send-form.utils';
import { SendFormStxContext } from '../providers/send-form-stx-provider';
import { SendFormStxSchema } from '../schemas/send-form-stx.schema';
} from '../send-form.utils';

export type CurrentRoute = CreateCurrentSendRoute<'send-form-stx'>;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,12 @@ import { t } from '@lingui/macro';
import { AverageBitcoinFeeRates } from '@leather.io/models';
import { Utxo } from '@leather.io/query';

import { CreateCurrentSendRoute, useSendSheetRoute } from '../../send-form.utils';
import { useSendFormBtc } from '../hooks/use-send-form-btc';
import { SendFormBtcLoader } from '../loaders/send-form-btc-loader';
import { defaultSendFormBtcValues, sendFormBtcSchema } from '../schemas/send-form-btc.schema';
import { SendFormBaseContext } from '../send-form-context';
import { SendFormProvider } from '../send-form-provider';
import { CreateCurrentSendRoute, useSendSheetRoute } from '../send-form.utils';

type CurrentRoute = CreateCurrentSendRoute<'send-form-btc'>;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,12 @@ import { Fees } from '@leather.io/models';
import { defaultStacksFees } from '@leather.io/query';
import { convertAmountToBaseUnit, createMoney } from '@leather.io/utils';

import { CreateCurrentSendRoute, useSendSheetRoute } from '../../send-form.utils';
import { useSendFormStx } from '../hooks/use-send-form-stx';
import { SendFormStxLoader } from '../loaders/send-form-stx-loader';
import { defaultSendFormStxValues, sendFormStxSchema } from '../schemas/send-form-stx.schema';
import { SendFormBaseContext } from '../send-form-context';
import { SendFormProvider } from '../send-form-provider';
import { CreateCurrentSendRoute, useSendSheetRoute } from '../send-form.utils';

const defaultFeeFallback = 2500;

Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import { Account } from '@/store/accounts/accounts';
import { t } from '@lingui/macro';
import {
NavigationProp,
ParamListBase,
Expand All @@ -8,9 +7,8 @@ import {
useRoute,
} from '@react-navigation/native';

import { BitcoinErrorKey, CoinSelectionUtxo } from '@leather.io/bitcoin';
import { CoinSelectionUtxo } from '@leather.io/bitcoin';
import { Utxo } from '@leather.io/query';
import { match } from '@leather.io/utils';

export interface SendSheetNavigatorParamList {
'send-select-account': undefined;
Expand Down Expand Up @@ -45,24 +43,3 @@ export function createCoinSelectionUtxos(utxos: Utxo[]): CoinSelectionUtxo[] {
vout: utxo.vout,
}));
}

export function formatBitcoinError(errorMessage: BitcoinErrorKey) {
return match<BitcoinErrorKey>()(errorMessage, {
InvalidAddress: t({
id: 'bitcoin-error.invalid-address',
message: 'Invalid address',
}),
NoInputsToSign: t({
id: 'bitcoin-error.no-inputs-to-sign',
message: 'No inputs to sign',
}),
NoOutputsToSign: t({
id: 'bitcoin-error.no-outputs-to-sign',
message: 'No outputs to sign',
}),
InsufficientFunds: t({
id: 'bitcoin-error.insufficient-funds',
message: 'Insufficient funds',
}),
});
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import BigNumber from 'bignumber.js';
import { z } from 'zod';

import { BTC_DECIMALS } from '@leather.io/constants';
import { UtxoResponseItem } from '@leather.io/query';
import { btcToSat, satToBtc } from '@leather.io/utils';

import { FormErrorMessages, currencyPrecisionValidatorFactory } from './common.validation';

const minSpendAmountInSats = 546;

interface BtcInsufficientBalanceValidatorArgs {
calcMaxSpend(
recipient: string,
utxos: UtxoResponseItem[]
): {
spendableBitcoin: BigNumber;
};
recipient: string;
utxos: UtxoResponseItem[];
}
export function btcInsufficientBalanceValidator({
calcMaxSpend,
recipient,
utxos,
}: BtcInsufficientBalanceValidatorArgs) {
return z
.number({
invalid_type_error: FormErrorMessages.MustBeNumber,
})
.refine(
value => {
if (!value) return false;
const maxSpend = calcMaxSpend(recipient, utxos);
if (!maxSpend) return false;
const desiredSpend = new BigNumber(value);
if (desiredSpend.isGreaterThan(maxSpend.spendableBitcoin)) return false;
return true;
},
{
message: FormErrorMessages.InsufficientFunds,
}
);
}

export function btcMinimumSpendValidator() {
return z
.number({
invalid_type_error: FormErrorMessages.MustBeNumber,
})
.refine(
value => {
if (!value) return false;
const desiredSpend = btcToSat(value);
if (desiredSpend.isLessThan(minSpendAmountInSats)) return false;
return true;
},
{
// FIXME: LEA-1647 - move to packages
/* eslint-disable-next-line lingui/no-unlocalized-strings */
message: `Minimum is ${satToBtc(minSpendAmountInSats)}`,
}
);
}
// btc and stx doing the same thing basically so just use currencyPrecisionValidatorFactory
export function btcAmountPrecisionValidator(errorMsg: string) {
return currencyPrecisionValidatorFactory(BTC_DECIMALS, errorMsg);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
// TODO: LEA-1617 - this was unused in extension
import { Money } from '@leather.io/models';
import { btcToSat } from '@leather.io/utils';

import { btcAmountPrecisionValidator } from './btc.amount-validators';
import { feeValidatorFactory } from './common.validation';

// Maybe better to just get rid of this and simplify?
// ts-unused-exports:disable-next-line
export function btcFeeValidator(availableBalance?: Money) {
return feeValidatorFactory({
availableBalance,
unitConverter: btcToSat,
validator: btcAmountPrecisionValidator,
});
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { t } from '@lingui/macro';

import { BitcoinErrorKey } from '@leather.io/bitcoin';
import { match } from '@leather.io/utils';

export function formatBitcoinError(errorMessage: BitcoinErrorKey) {
return match<BitcoinErrorKey>()(errorMessage, {
InvalidAddress: t({
id: 'bitcoin-error.invalid-address',
message: 'Invalid address',
}),
NoInputsToSign: t({
id: 'bitcoin-error.no-inputs-to-sign',
message: 'No inputs to sign',
}),
NoOutputsToSign: t({
id: 'bitcoin-error.no-outputs-to-sign',
message: 'No outputs to sign',
}),
InsufficientFunds: t({
id: 'bitcoin-error.insufficient-funds',
message: 'Insufficient funds',
}),
});
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
import BigNumber from 'bignumber.js';
import { z } from 'zod';

import type { Money } from '@leather.io/models';
import { countDecimals, isFunction, isNumber, moneyToBaseUnit } from '@leather.io/utils';

// from extension/src/shared/error-messages
export enum FormErrorMessages {
AdjustedFeeExceedsBalance = 'Fee added exceeds current balance',
AddressRequired = 'Enter an address',
AmountRequired = 'Enter an amount',
BnsAddressNotFound = 'Address not found',
CannotDetermineBalance = 'Cannot determine balance',
CannotDeterminePrecision = 'Cannot determine decimal precision',
CastToNumber = 'Amount must be a `number` type, but the final value was: `NaN`',
DoesNotSupportDecimals = 'Token does not support decimal places',
IncorrectNetworkAddress = 'Address is for incorrect network',
InvalidAddress = 'Address is not valid',
InsufficientBalance = 'Insufficient balance. Available:',
InsufficientFunds = 'Insufficient funds',
MemoExceedsLimit = 'Memo must be less than 34-bytes',
MustBeNumber = 'Amount must be a number',
MustBePositive = 'Amount must be greater than zero',
MustSelectAsset = 'Select a valid token to transfer',
SameAddress = 'Cannot send to yourself',
TooMuchPrecision = 'Token can only have {decimals} decimals',
NonZeroOffsetInscription = 'Sending inscriptions at non-zero offsets is unsupported',
UtxoWithMultipleInscriptions = 'Sending inscription from utxo with multiple inscriptions is unsupported',
InsufficientFundsToCoverFee = 'Insufficient funds to cover fee. Deposit some BTC to your Native Segwit address.',
}

export function currencyAmountValidator() {
return z
.number({
// FIXME: LEA-1647 - move to packages
/* eslint-disable-next-line lingui/no-unlocalized-strings */
invalid_type_error: 'Currency must be a number',
})
.positive(FormErrorMessages.MustBePositive);
}

export function currencyPrecisionValidatorFactory(precision: number, errorMessage: string) {
return z
.number({
required_error: FormErrorMessages.AmountRequired,
invalid_type_error: FormErrorMessages.MustBeNumber,
})
.refine(
value => {
if (!isNumber(value)) return false;
return countDecimals(value) <= precision;
},
{
message: errorMessage,
}
);
}

export function formatPrecisionError(num?: Money) {
if (!num) return FormErrorMessages.CannotDeterminePrecision;
const error = FormErrorMessages.TooMuchPrecision;
return error.replace('{decimals}', String(num.decimals));
}

export function formatInsufficientBalanceError(
sum?: Money,
formatterFn?: (amount: Money) => string
) {
if (!sum) return FormErrorMessages.CannotDetermineBalance;
const isAmountLessThanZero = sum.amount.lt(0);

const formattedAmount = isFunction(formatterFn) ? formatterFn(sum) : sum.amount.toString(10);

return `${FormErrorMessages.InsufficientBalance} ${
isAmountLessThanZero ? '0' : formattedAmount
} ${sum.symbol}`;
}

interface FeeValidatorFactoryArgs {
availableBalance?: Money;
unitConverter(unit: string | number | BigNumber): BigNumber;
validator(errorMsg: string): z.ZodType<number | undefined>;
}
export function feeValidatorFactory({
availableBalance,
unitConverter,
validator,
}: FeeValidatorFactoryArgs) {
return validator(formatPrecisionError(availableBalance)).refine(
(fee: unknown) => {
if (!availableBalance || !isNumber(fee)) return false;
return availableBalance.amount.isGreaterThanOrEqualTo(unitConverter(fee));
},
{
message: formatInsufficientBalanceError(availableBalance, sum =>
moneyToBaseUnit(sum).toString()
),
}
);
}

// <<<< PETE continue here migrating the fee stuff
// - then move them to packages to avoid the stupid lingui errors
Loading

0 comments on commit c3073d9

Please sign in to comment.