From 30b9f5020cd9b241661751c536a8928007bf5dab Mon Sep 17 00:00:00 2001 From: Edgar Khanzadian Date: Mon, 23 Dec 2024 14:57:32 +0400 Subject: [PATCH] fix: psbt error and controller handling --- .../src/features/send/send-form.utils.ts | 25 ++++++- .../send-form/components/send-form-button.tsx | 7 -- .../send-form/components/send-form-numpad.tsx | 14 ++-- .../send-form/hooks/use-send-form-btc.tsx | 68 +++++++++++-------- .../send-form/schemas/send-form-btc.schema.ts | 4 +- .../send/send-sheets/send-form-btc-sheet.tsx | 1 - packages/bitcoin/src/bitcoin-error.ts | 16 +++-- .../src/coin-selection/coin-selection.ts | 12 ++-- .../generate-unsigned-transaction.spec.ts | 8 +-- .../generate-unsigned-transaction.ts | 68 +++++++++---------- 10 files changed, 125 insertions(+), 98 deletions(-) diff --git a/apps/mobile/src/features/send/send-form.utils.ts b/apps/mobile/src/features/send/send-form.utils.ts index 9ca698331..4f6074431 100644 --- a/apps/mobile/src/features/send/send-form.utils.ts +++ b/apps/mobile/src/features/send/send-form.utils.ts @@ -1,4 +1,5 @@ import { Account } from '@/store/accounts/accounts'; +import { t } from '@lingui/macro'; import { NavigationProp, ParamListBase, @@ -7,8 +8,9 @@ import { useRoute, } from '@react-navigation/native'; -import { CoinSelectionUtxo } from '@leather.io/bitcoin'; +import { BitcoinErrorKey, CoinSelectionUtxo } from '@leather.io/bitcoin'; import { Utxo } from '@leather.io/query'; +import { match } from '@leather.io/utils'; export interface SendSheetNavigatorParamList { 'send-select-account': undefined; @@ -43,3 +45,24 @@ export function createCoinSelectionUtxos(utxos: Utxo[]): CoinSelectionUtxo[] { vout: utxo.vout, })); } + +export function formatBitcoinError(errorMessage: BitcoinErrorKey) { + return match()(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', + }), + }); +} diff --git a/apps/mobile/src/features/send/send-form/components/send-form-button.tsx b/apps/mobile/src/features/send/send-form/components/send-form-button.tsx index cb1fcb509..00fdb8882 100644 --- a/apps/mobile/src/features/send/send-form/components/send-form-button.tsx +++ b/apps/mobile/src/features/send/send-form/components/send-form-button.tsx @@ -1,6 +1,5 @@ import { useFormContext } from 'react-hook-form'; -import { useToastContext } from '@/components/toast/toast-context'; import { t } from '@lingui/macro'; import { z } from 'zod'; @@ -9,7 +8,6 @@ import { Button } from '@leather.io/ui/native'; import { SendFormBaseContext, useSendFormContext } from '../send-form-context'; export function SendFormButton>() { - const { displayToast } = useToastContext(); const { formData } = useSendFormContext(); const { onInitSendTransfer, schema } = formData; const { @@ -19,11 +17,6 @@ export function SendFormButton>() { function onSubmitForm(values: z.infer) { onInitSendTransfer(formData, values); - // Temporary toast for testing - displayToast({ - title: t`Form submitted`, - type: 'success', - }); } return ( diff --git a/apps/mobile/src/features/send/send-form/components/send-form-numpad.tsx b/apps/mobile/src/features/send/send-form/components/send-form-numpad.tsx index db0929d2e..d4b6f33f9 100644 --- a/apps/mobile/src/features/send/send-form/components/send-form-numpad.tsx +++ b/apps/mobile/src/features/send/send-form/components/send-form-numpad.tsx @@ -1,4 +1,4 @@ -import { useFormContext } from 'react-hook-form'; +import { Controller, useFormContext } from 'react-hook-form'; import { z } from 'zod'; @@ -8,12 +8,18 @@ import { SendFormBaseContext, useSendFormContext } from '../send-form-context'; export function SendFormNumpad>() { const { formData } = useSendFormContext(); - const { setValue, watch } = useFormContext>(); - const amount = watch('amount'); + // TODO: fix implicit any-s + const { control } = useFormContext>(); return ( - setValue('amount', value)} /> + ( + onChange(value)} /> + )} + /> ); } diff --git a/apps/mobile/src/features/send/send-form/hooks/use-send-form-btc.tsx b/apps/mobile/src/features/send/send-form/hooks/use-send-form-btc.tsx index 1fc1f0c87..d6872eb13 100644 --- a/apps/mobile/src/features/send/send-form/hooks/use-send-form-btc.tsx +++ b/apps/mobile/src/features/send/send-form/hooks/use-send-form-btc.tsx @@ -1,11 +1,14 @@ import { useCallback } from 'react'; import { useGenerateBtcUnsignedTransactionNativeSegwit } from '@/common/transactions/bitcoin-transactions.hooks'; +import { useToastContext } from '@/components/toast/toast-context'; import { useBitcoinAccounts } from '@/store/keychains/bitcoin/bitcoin-keychains.read'; +import { t } from '@lingui/macro'; import { bytesToHex } from '@noble/hashes/utils'; import BigNumber from 'bignumber.js'; import { + BitcoinError, CoinSelectionRecipient, CoinSelectionUtxo, getBitcoinFees, @@ -17,6 +20,7 @@ import { createMoneyFromDecimal } from '@leather.io/utils'; import { CreateCurrentSendRoute, createCoinSelectionUtxos, + formatBitcoinError, useSendSheetNavigation, useSendSheetRoute, } from '../../send-form.utils'; @@ -47,6 +51,7 @@ export function useSendFormBtc() { const route = useSendSheetRoute(); const navigation = useSendSheetNavigation(); const { account } = route.params; + const { displayToast } = useToastContext(); const bitcoinKeychain = useBitcoinAccounts().accountIndexByPaymentType( account.fingerprint, @@ -71,33 +76,42 @@ export function useSendFormBtc() { // Temporary logs until we can hook up to approver flow async onInitSendTransfer(data: SendFormBtcContext, values: SendFormBtcSchema) { - const parsedSendFormValues = parseSendFormValues(values); - const coinSelectionUtxos = createCoinSelectionUtxos(data.utxos); - - const nativeSegwitPayer = bitcoinKeychain.nativeSegwit.derivePayer({ addressIndex: 0 }); - - const tx = await generateTx({ - feeRate: Number(values.feeRate), - isSendingMax: false, - values: parsedSendFormValues, - utxos: coinSelectionUtxos, - bip32Derivation: [payerToBip32Derivation(nativeSegwitPayer)], - }); - - const fees = getTxFees({ - feeRates: data.feeRates, - recipients: parsedSendFormValues.recipients, - utxos: coinSelectionUtxos, - }); - - // Show an error toast here? - if (!tx) throw new Error('Attempted to generate raw tx, but no tx exists'); - // eslint-disable-next-line no-console, lingui/no-unlocalized-strings - console.log('fees:', fees); - - const psbtHex = bytesToHex(tx.psbt); - - navigation.navigate('sign-psbt', { psbtHex }); + try { + const parsedSendFormValues = parseSendFormValues(values); + const coinSelectionUtxos = createCoinSelectionUtxos(data.utxos); + + const nativeSegwitPayer = bitcoinKeychain.nativeSegwit.derivePayer({ addressIndex: 0 }); + + const tx = await generateTx({ + feeRate: Number(values.feeRate), + isSendingMax: false, + values: parsedSendFormValues, + utxos: coinSelectionUtxos, + bip32Derivation: [payerToBip32Derivation(nativeSegwitPayer)], + }); + + const fees = getTxFees({ + feeRates: data.feeRates, + recipients: parsedSendFormValues.recipients, + utxos: coinSelectionUtxos, + }); + + // Show an error toast here? + if (!tx) throw new Error('Attempted to generate raw tx, but no tx exists'); + // eslint-disable-next-line no-console, lingui/no-unlocalized-strings + console.log('fees:', fees); + + const psbtHex = bytesToHex(tx.psbt); + + navigation.navigate('sign-psbt', { psbtHex }); + } catch (e) { + const message = + e instanceof BitcoinError + ? formatBitcoinError(e.message) + : t({ id: 'something-went-wrong', message: 'Something went wrong' }); + + displayToast({ title: message, type: 'error' }); + } }, }; } diff --git a/apps/mobile/src/features/send/send-form/schemas/send-form-btc.schema.ts b/apps/mobile/src/features/send/send-form/schemas/send-form-btc.schema.ts index 9232e5fe8..da6b1a807 100644 --- a/apps/mobile/src/features/send/send-form/schemas/send-form-btc.schema.ts +++ b/apps/mobile/src/features/send/send-form/schemas/send-form-btc.schema.ts @@ -5,8 +5,7 @@ export const sendFormBtcSchema = z.object({ message: '', }), senderDerivationPath: z.string(), - recipient: z.string(), - memo: z.string().optional(), + recipient: z.string().min(1), feeRate: z.string(), }); @@ -16,6 +15,5 @@ export const defaultSendFormBtcValues: SendFormBtcSchema = { amount: '', senderDerivationPath: '', recipient: '', - memo: '', feeRate: '', }; diff --git a/apps/mobile/src/features/send/send-sheets/send-form-btc-sheet.tsx b/apps/mobile/src/features/send/send-sheets/send-form-btc-sheet.tsx index efff1c2ee..cf841d5f6 100644 --- a/apps/mobile/src/features/send/send-sheets/send-form-btc-sheet.tsx +++ b/apps/mobile/src/features/send/send-sheets/send-form-btc-sheet.tsx @@ -40,7 +40,6 @@ export function SendFormBtcSheet() { } onPress={onGoBack} /> - diff --git a/packages/bitcoin/src/bitcoin-error.ts b/packages/bitcoin/src/bitcoin-error.ts index 9634794e2..fcfcd5b09 100644 --- a/packages/bitcoin/src/bitcoin-error.ts +++ b/packages/bitcoin/src/bitcoin-error.ts @@ -1,15 +1,17 @@ -export enum BitcoinErrorMessage { - InsufficientFunds = 'Insufficient funds', - NoInputsToSign = 'No inputs to sign', - NoOutputsToSign = 'No outputs to sign', -} - export class BitcoinError extends Error { - constructor(message: string) { + public message: BitcoinErrorKey; + constructor(message: BitcoinErrorKey) { super(message); this.name = 'BitcoinError'; + this.message = message; // Fix the prototype chain Object.setPrototypeOf(this, new.target.prototype); } } + +export type BitcoinErrorKey = + | 'InvalidAddress' + | 'InsufficientFunds' + | 'NoInputsToSign' + | 'NoOutputsToSign'; diff --git a/packages/bitcoin/src/coin-selection/coin-selection.ts b/packages/bitcoin/src/coin-selection/coin-selection.ts index e1c567234..805816566 100644 --- a/packages/bitcoin/src/coin-selection/coin-selection.ts +++ b/packages/bitcoin/src/coin-selection/coin-selection.ts @@ -5,7 +5,7 @@ import { BTC_P2WPKH_DUST_AMOUNT } from '@leather.io/constants'; import { Money } from '@leather.io/models'; import { createMoney, sumMoney } from '@leather.io/utils'; -import { BitcoinError, BitcoinErrorMessage } from '../bitcoin-error'; +import { BitcoinError } from '../bitcoin-error'; import { filterUneconomicalUtxos, getSizeInfo, getUtxoTotal } from './coin-selection.utils'; export interface CoinSelectionOutput { @@ -37,8 +37,7 @@ export function determineUtxosForSpendAll({ utxos, }: DetermineUtxosForSpendArgs) { recipients.forEach(recipient => { - if (!validate(recipient.address)) - throw new Error('Cannot calculate spend of invalid address type'); + if (!validate(recipient.address)) throw new BitcoinError('InvalidAddress'); }); const filteredUtxos = filterUneconomicalUtxos({ utxos, feeRate, recipients }); @@ -66,15 +65,14 @@ export function determineUtxosForSpendAll({ export function determineUtxosForSpend({ feeRate, recipients, utxos }: DetermineUtxosForSpendArgs) { recipients.forEach(recipient => { - if (!validate(recipient.address)) - throw new Error('Cannot calculate spend of invalid address type'); + if (!validate(recipient.address)) throw new BitcoinError('InvalidAddress'); }); const filteredUtxos = filterUneconomicalUtxos({ utxos: utxos.sort((a, b) => b.value - a.value), feeRate, recipients, }); - if (!filteredUtxos.length) throw new BitcoinError(BitcoinErrorMessage.InsufficientFunds); + if (!filteredUtxos.length) throw new BitcoinError('InsufficientFunds'); const amount = sumMoney(recipients.map(recipient => recipient.amount)); @@ -100,7 +98,7 @@ export function determineUtxosForSpend({ feeRate, recipients, utxos }: Determine while (!hasSufficientUtxosForTx()) { const [nextUtxo] = getRemainingUnspentUtxos(); - if (!nextUtxo) throw new BitcoinError(BitcoinErrorMessage.InsufficientFunds); + if (!nextUtxo) throw new BitcoinError('InsufficientFunds'); neededUtxos.push(nextUtxo); } diff --git a/packages/bitcoin/src/transactions/generate-unsigned-transaction.spec.ts b/packages/bitcoin/src/transactions/generate-unsigned-transaction.spec.ts index 5958998b7..a21d35ac5 100644 --- a/packages/bitcoin/src/transactions/generate-unsigned-transaction.spec.ts +++ b/packages/bitcoin/src/transactions/generate-unsigned-transaction.spec.ts @@ -81,10 +81,8 @@ describe('generateBitcoinUnsignedTransactionNativeSegwit', () => { utxos: [], }; - const result = await generateBitcoinUnsignedTransactionNativeSegwit(argsWithNoInputs); - - if (result) { - expect(result.inputs.length).toBe(0); - } + await expect(() => + generateBitcoinUnsignedTransactionNativeSegwit(argsWithNoInputs) + ).rejects.toThrowError('InsufficientFunds'); }); }); diff --git a/packages/bitcoin/src/transactions/generate-unsigned-transaction.ts b/packages/bitcoin/src/transactions/generate-unsigned-transaction.ts index b7379ad88..d871ee57d 100644 --- a/packages/bitcoin/src/transactions/generate-unsigned-transaction.ts +++ b/packages/bitcoin/src/transactions/generate-unsigned-transaction.ts @@ -2,7 +2,7 @@ import { hexToBytes } from '@noble/hashes/utils'; import * as btc from '@scure/btc-signer'; import { BtcSignerDefaultBip32Derivation } from 'bitcoin-signer'; -import { BitcoinError, BitcoinErrorMessage } from '../bitcoin-error'; +import { BitcoinError } from '../bitcoin-error'; import { BtcSignerNetwork } from '../bitcoin.network'; import { CoinSelectionRecipient, @@ -32,44 +32,40 @@ export async function generateBitcoinUnsignedTransactionNativeSegwit({ recipients, utxos, }: GenerateBitcoinUnsignedTransactionArgs) { - try { - const determineUtxosArgs = { feeRate, recipients, utxos }; - const { inputs, outputs, fee } = isSendingMax - ? determineUtxosForSpendAll(determineUtxosArgs) - : determineUtxosForSpend(determineUtxosArgs); + const determineUtxosArgs = { feeRate, recipients, utxos }; + const { inputs, outputs, fee } = isSendingMax + ? determineUtxosForSpendAll(determineUtxosArgs) + : determineUtxosForSpend(determineUtxosArgs); - if (!inputs.length) throw new BitcoinError(BitcoinErrorMessage.NoInputsToSign); - if (!outputs.length) throw new BitcoinError(BitcoinErrorMessage.NoOutputsToSign); + if (!inputs.length) throw new BitcoinError('NoInputsToSign'); + if (!outputs.length) throw new BitcoinError('NoOutputsToSign'); - const tx = new btc.Transaction(); - const p2wpkh = btc.p2wpkh(hexToBytes(payerPublicKey), network); + const tx = new btc.Transaction(); + const p2wpkh = btc.p2wpkh(hexToBytes(payerPublicKey), network); - for (const input of inputs) { - tx.addInput({ - txid: input.txid, - index: input.vout, - sequence: 0, - bip32Derivation, - witnessUtxo: { - // script = 0014 + pubKeyHash - script: p2wpkh.script, - amount: BigInt(input.value), - }, - }); - } - - outputs.forEach(output => { - // When coin selection returns an output with no address, - // we assume it is a change output - if (!output.address) { - tx.addOutputAddress(payerAddress, BigInt(output.value), network); - return; - } - tx.addOutputAddress(output.address, BigInt(output.value), network); + for (const input of inputs) { + tx.addInput({ + txid: input.txid, + index: input.vout, + sequence: 0, + bip32Derivation, + witnessUtxo: { + // script = 0014 + pubKeyHash + script: p2wpkh.script, + amount: BigInt(input.value), + }, }); - - return { tx, hex: tx.hex, psbt: tx.toPSBT(), inputs, fee }; - } catch (e) { - return null; } + + outputs.forEach(output => { + // When coin selection returns an output with no address, + // we assume it is a change output + if (!output.address) { + tx.addOutputAddress(payerAddress, BigInt(output.value), network); + return; + } + tx.addOutputAddress(output.address, BigInt(output.value), network); + }); + + return { tx, hex: tx.hex, psbt: tx.toPSBT(), inputs, fee }; }