Skip to content

Commit

Permalink
fix: psbt error and controller handling
Browse files Browse the repository at this point in the history
  • Loading branch information
edgarkhanzadian committed Dec 23, 2024
1 parent 25f0bb9 commit 30b9f50
Show file tree
Hide file tree
Showing 10 changed files with 125 additions and 98 deletions.
25 changes: 24 additions & 1 deletion apps/mobile/src/features/send/send-form.utils.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { Account } from '@/store/accounts/accounts';
import { t } from '@lingui/macro';
import {
NavigationProp,
ParamListBase,
Expand All @@ -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;
Expand Down Expand Up @@ -43,3 +45,24 @@ 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
@@ -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';

Expand All @@ -9,7 +8,6 @@ import { Button } from '@leather.io/ui/native';
import { SendFormBaseContext, useSendFormContext } from '../send-form-context';

export function SendFormButton<T extends SendFormBaseContext<T>>() {
const { displayToast } = useToastContext();
const { formData } = useSendFormContext<T>();
const { onInitSendTransfer, schema } = formData;
const {
Expand All @@ -19,11 +17,6 @@ export function SendFormButton<T extends SendFormBaseContext<T>>() {

function onSubmitForm(values: z.infer<typeof schema>) {
onInitSendTransfer(formData, values);
// Temporary toast for testing
displayToast({
title: t`Form submitted`,
type: 'success',
});
}

return (
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useFormContext } from 'react-hook-form';
import { Controller, useFormContext } from 'react-hook-form';

import { z } from 'zod';

Expand All @@ -8,12 +8,18 @@ import { SendFormBaseContext, useSendFormContext } from '../send-form-context';

export function SendFormNumpad<T extends SendFormBaseContext<T>>() {
const { formData } = useSendFormContext<T>();
const { setValue, watch } = useFormContext<z.infer<typeof formData.schema>>();
const amount = watch('amount');
// TODO: fix implicit any-s
const { control } = useFormContext<z.infer<typeof formData.schema>>();

return (
<Box mx="-5">
<Numpad value={amount} onChange={(value: string) => setValue('amount', value)} />
<Controller
control={control}
name="amount"
render={({ field: { value, onChange } }) => (
<Numpad value={value} onChange={value => onChange(value)} />
)}
/>
</Box>
);
}
68 changes: 41 additions & 27 deletions apps/mobile/src/features/send/send-form/hooks/use-send-form-btc.tsx
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -17,6 +20,7 @@ import { createMoneyFromDecimal } from '@leather.io/utils';
import {
CreateCurrentSendRoute,
createCoinSelectionUtxos,
formatBitcoinError,
useSendSheetNavigation,
useSendSheetRoute,
} from '../../send-form.utils';
Expand Down Expand Up @@ -47,6 +51,7 @@ export function useSendFormBtc() {
const route = useSendSheetRoute<CurrentRoute>();
const navigation = useSendSheetNavigation<CurrentRoute>();
const { account } = route.params;
const { displayToast } = useToastContext();

const bitcoinKeychain = useBitcoinAccounts().accountIndexByPaymentType(
account.fingerprint,
Expand All @@ -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' });
}
},
};
}
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
});

Expand All @@ -16,6 +15,5 @@ export const defaultSendFormBtcValues: SendFormBtcSchema = {
amount: '',
senderDerivationPath: '',
recipient: '',
memo: '',
feeRate: '',
};
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,6 @@ export function SendFormBtcSheet() {
<SendForm.Asset icon={<BtcAvatarIcon />} onPress={onGoBack} />
<SendForm.AmountField />
<SendForm.RecipientField />
<SendForm.Memo />
<SendForm.Footer>
<SendForm.Numpad />
<SendForm.Button />
Expand Down
16 changes: 9 additions & 7 deletions packages/bitcoin/src/bitcoin-error.ts
Original file line number Diff line number Diff line change
@@ -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';
12 changes: 5 additions & 7 deletions packages/bitcoin/src/coin-selection/coin-selection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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 });

Expand Down Expand Up @@ -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));

Expand All @@ -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);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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');
});
});
68 changes: 32 additions & 36 deletions packages/bitcoin/src/transactions/generate-unsigned-transaction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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)

Check warning on line 37 in packages/bitcoin/src/transactions/generate-unsigned-transaction.ts

View check run for this annotation

Codecov / codecov/patch

packages/bitcoin/src/transactions/generate-unsigned-transaction.ts#L37

Added line #L37 was not covered by tests
: 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 };
}

0 comments on commit 30b9f50

Please sign in to comment.