diff --git a/apps/mobile/src/common/transactions/bitcoin-transactions.hooks.ts b/apps/mobile/src/common/transactions/bitcoin-transactions.hooks.ts new file mode 100644 index 000000000..9f1a532f6 --- /dev/null +++ b/apps/mobile/src/common/transactions/bitcoin-transactions.hooks.ts @@ -0,0 +1,75 @@ +import { useCallback } from 'react'; + +import { useNetworkPreferenceBitcoinScureLibNetworkConfig } from '@/store/settings/settings.read'; +import { t } from '@lingui/macro'; + +import { + CoinSelectionRecipient, + CoinSelectionUtxo, + determineUtxosForSpend, + determineUtxosForSpendAll, + generateUnsignedTransactionNativeSegwit, +} from '@leather.io/bitcoin'; +import type { Money } from '@leather.io/models'; + +export interface BitcoinTransactionValues { + amount: Money; + recipients: CoinSelectionRecipient[]; +} + +export function useBtcPayerDetails(address: string, publicKey: string) { + const network = useNetworkPreferenceBitcoinScureLibNetworkConfig(); + + return { + network, + payerAddress: address, + payerPublicKey: publicKey, + }; +} + +export interface GenerateBtcUnsignedTransactionCallbackArgs { + feeRate: number; + isSendingMax?: boolean; + utxos: CoinSelectionUtxo[]; + values: BitcoinTransactionValues; +} + +export function useGenerateBtcUnsignedTransactionNativeSegwit( + btcPayerDetails: ReturnType +) { + return useCallback( + async ({ + feeRate, + isSendingMax, + utxos, + values, + }: GenerateBtcUnsignedTransactionCallbackArgs) => { + try { + const determineUtxosArgs = { + feeRate, + recipients: values.recipients, + utxos, + }; + + const { inputs, outputs, fee } = isSendingMax + ? determineUtxosForSpendAll(determineUtxosArgs) + : determineUtxosForSpend(determineUtxosArgs); + + if (!inputs.length) throw new Error('No inputs to sign'); + if (!outputs.length) throw new Error('No outputs to sign'); + + return generateUnsignedTransactionNativeSegwit({ + ...btcPayerDetails, + inputs, + outputs, + fee, + }); + } catch (e) { + // eslint-disable-next-line no-console + console.log(t`Error signing bitcoin transaction`, e); + return null; + } + }, + [btcPayerDetails] + ); +} diff --git a/apps/mobile/src/features/send/send-form.utils.ts b/apps/mobile/src/features/send/send-form.utils.ts index 85f5de8a7..b73b0d224 100644 --- a/apps/mobile/src/features/send/send-form.utils.ts +++ b/apps/mobile/src/features/send/send-form.utils.ts @@ -10,7 +10,7 @@ import { export interface SendSheetNavigatorParamList { 'send-select-account': undefined; 'send-select-asset': { account: Account }; - 'send-form-btc': { account: Account }; + 'send-form-btc': { account: Account; address: string; publicKey: string }; 'send-form-stx': { account: Account; address: string; publicKey: string }; 'sign-psbt': { psbtHex: string }; } diff --git a/apps/mobile/src/features/send/send-form/components/send-form-amount-field.tsx b/apps/mobile/src/features/send/send-form/components/send-form-amount-field.tsx index f6dff7194..58474a541 100644 --- a/apps/mobile/src/features/send/send-form/components/send-form-amount-field.tsx +++ b/apps/mobile/src/features/send/send-form/components/send-form-amount-field.tsx @@ -3,15 +3,15 @@ import { Controller, useFormContext } from 'react-hook-form'; import { TextInput } from '@/components/text-input'; import { z } from 'zod'; -import { useSendFormContext } from '../send-form-context'; +import { SendFormBaseContext, useSendFormContext } from '../send-form-context'; -export function SendFormAmountField() { - const { schema } = useSendFormContext(); +export function SendFormAmountField>() { + const { formData } = useSendFormContext(); const { control, // TODO: Handle errors // formState: { errors }, - } = useFormContext>(); + } = useFormContext>(); return ( >({ + icon, + onPress, +}: SendFormAssetProps) { + const { formData } = useSendFormContext(); + const { availableBalance, fiatBalance, protocol, symbol, name } = formData; return ( 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 eca169d59..cb1fcb509 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 @@ -6,18 +6,19 @@ import { z } from 'zod'; import { Button } from '@leather.io/ui/native'; -import { useSendFormContext } from '../send-form-context'; +import { SendFormBaseContext, useSendFormContext } from '../send-form-context'; -export function SendFormButton() { +export function SendFormButton>() { const { displayToast } = useToastContext(); - const { schema, onInitSendTransfer } = useSendFormContext(); + const { formData } = useSendFormContext(); + const { onInitSendTransfer, schema } = formData; const { formState: { isDirty, isValid }, handleSubmit, } = useFormContext>(); function onSubmitForm(values: z.infer) { - onInitSendTransfer(values); + onInitSendTransfer(formData, values); // Temporary toast for testing displayToast({ title: t`Form submitted`, diff --git a/apps/mobile/src/features/send/send-form/components/send-form-memo.tsx b/apps/mobile/src/features/send/send-form/components/send-form-memo.tsx index 89ea03f2b..fc7e4f279 100644 --- a/apps/mobile/src/features/send/send-form/components/send-form-memo.tsx +++ b/apps/mobile/src/features/send/send-form/components/send-form-memo.tsx @@ -4,9 +4,10 @@ import { t } from '@lingui/macro'; import { NoteEmptyIcon, SheetRef, Text, TouchableOpacity } from '@leather.io/ui/native'; +import { SendFormBaseContext } from '../send-form-context'; import { MemoSheet } from '../sheets/memo-sheet'; -export function SendFormMemo() { +export function SendFormMemo>() { const memoSheetRef = useRef(null); 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 f0e347caa..db0929d2e 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 @@ -4,11 +4,11 @@ import { z } from 'zod'; import { Box, Numpad } from '@leather.io/ui/native'; -import { useSendFormContext } from '../send-form-context'; +import { SendFormBaseContext, useSendFormContext } from '../send-form-context'; -export function SendFormNumpad() { - const { schema } = useSendFormContext(); - const { setValue, watch } = useFormContext>(); +export function SendFormNumpad>() { + const { formData } = useSendFormContext(); + const { setValue, watch } = useFormContext>(); const amount = watch('amount'); return ( diff --git a/apps/mobile/src/features/send/send-form/components/send-form-recipient.tsx b/apps/mobile/src/features/send/send-form/components/send-form-recipient.tsx index cf1ddd056..220997751 100644 --- a/apps/mobile/src/features/send/send-form/components/send-form-recipient.tsx +++ b/apps/mobile/src/features/send/send-form/components/send-form-recipient.tsx @@ -15,12 +15,12 @@ import { UserIcon, } from '@leather.io/ui/native'; -import { useSendFormContext } from '../send-form-context'; +import { SendFormBaseContext, useSendFormContext } from '../send-form-context'; import { RecipientSheet } from '../sheets/recipient-sheet'; -export function SendFormRecipient() { - const { schema } = useSendFormContext(); - const { watch } = useFormContext>(); +export function SendFormRecipient>() { + const { formData } = useSendFormContext(); + const { watch } = useFormContext>(); const recipientSheetRef = useRef(null); const recipient = watch('recipient'); 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 486f043cb..a3568f5d9 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,24 +1,95 @@ +import { useCallback } from 'react'; + +import { + useBtcPayerDetails, + useGenerateBtcUnsignedTransactionNativeSegwit, +} from '@/common/transactions/bitcoin-transactions.hooks'; +import BigNumber from 'bignumber.js'; + +import { CoinSelectionRecipient, CoinSelectionUtxo, getBitcoinFees } from '@leather.io/bitcoin'; +import { AverageBitcoinFeeRates } from '@leather.io/models'; +import { Utxo } from '@leather.io/query'; +import { createMoneyFromDecimal } from '@leather.io/utils'; + import { CreateCurrentSendRoute, useSendSheetNavigation, useSendSheetRoute, } from '../../send-form.utils'; -import { SendFormStxSchema } from '../schemas/send-form-stx.schema'; +import { SendFormBtcContext } from '../providers/send-form-btc-provider'; +import { SendFormBtcSchema } from '../schemas/send-form-btc.schema'; type CurrentRoute = CreateCurrentSendRoute<'send-form-btc'>; +function parseSendFormValues(values: SendFormBtcSchema) { + return { + amount: createMoneyFromDecimal(new BigNumber(values.amount), 'BTC'), + recipients: [ + { + address: values.recipient, + amount: createMoneyFromDecimal(new BigNumber(values.amount), 'BTC'), + }, + ], + }; +} + +function createCoinSelectionUtxos(utxos: Utxo[]): CoinSelectionUtxo[] { + return utxos.map(utxo => ({ + address: utxo.address, + txid: utxo.txid, + value: Number(utxo.value), + vout: utxo.vout, + })); +} + export function useSendFormBtc() { const route = useSendSheetRoute(); const navigation = useSendSheetNavigation(); + const btcPayerDetails = useBtcPayerDetails(route.params.address, route.params.publicKey); + + const getTransactionFees = useCallback( + ( + feeRates: AverageBitcoinFeeRates, + recipients: CoinSelectionRecipient[], + utxos: CoinSelectionUtxo[] + ) => getBitcoinFees({ feeRates, isSendingMax: false, recipients, utxos }), + [] + ); + + const generateTx = useGenerateBtcUnsignedTransactionNativeSegwit(btcPayerDetails); return { onGoBack() { navigation.navigate('send-select-asset', { account: route.params.account }); }, // Temporary logs until we can hook up to approver flow - async onInitSendTransfer(values: SendFormStxSchema) { + async onInitSendTransfer(data: SendFormBtcContext, values: SendFormBtcSchema) { + // eslint-disable-next-line no-console, lingui/no-unlocalized-strings + console.log('Send form data:', parseSendFormValues(values)); + + const parsedSendFormValues = parseSendFormValues(values); + const coinSelectionUtxos = createCoinSelectionUtxos(data.utxos); + + const tx = await generateTx({ + feeRate: Number(values.feeRate), + values: parsedSendFormValues, + utxos: coinSelectionUtxos, + }); + + const fees = getTransactionFees( + data.feeRates, + parsedSendFormValues.recipients, + 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('tx hex:', tx.hex); + // eslint-disable-next-line no-console, lingui/no-unlocalized-strings + console.log('psbt:', tx.psbt); // eslint-disable-next-line no-console, lingui/no-unlocalized-strings - console.log('Send form data:', values); + console.log('fees:', fees); }, }; } diff --git a/apps/mobile/src/features/send/send-form/hooks/use-send-form-stx.tsx b/apps/mobile/src/features/send/send-form/hooks/use-send-form-stx.tsx index 316e0a687..96dc442f2 100644 --- a/apps/mobile/src/features/send/send-form/hooks/use-send-form-stx.tsx +++ b/apps/mobile/src/features/send/send-form/hooks/use-send-form-stx.tsx @@ -12,6 +12,7 @@ import { useSendSheetNavigation, useSendSheetRoute, } from '../../send-form.utils'; +import { SendFormStxContext } from '../providers/send-form-stx-provider'; import { SendFormStxSchema } from '../schemas/send-form-stx.schema'; export type CurrentRoute = CreateCurrentSendRoute<'send-form-stx'>; @@ -41,7 +42,7 @@ export function useSendFormStx() { navigation.navigate('send-select-asset', { account: route.params.account }); }, // Temporary logs until we can hook up to approver flow - async onInitSendTransfer(values: SendFormStxSchema) { + async onInitSendTransfer(data: SendFormStxContext, values: SendFormStxSchema) { // eslint-disable-next-line no-console, lingui/no-unlocalized-strings console.log('Send form data:', values); const tx = await generateTx(parseSendFormValues(values)); @@ -52,6 +53,8 @@ export function useSendFormStx() { const txHex = bytesToHex(tx.serialize()); // eslint-disable-next-line no-console, lingui/no-unlocalized-strings console.log('tx hex:', txHex); + // eslint-disable-next-line no-console, lingui/no-unlocalized-strings + console.log('fees:', data.fees); }, }; } diff --git a/apps/mobile/src/features/send/send-form/loaders/send-form-btc-loader.tsx b/apps/mobile/src/features/send/send-form/loaders/send-form-btc-loader.tsx index 68b6c0c6f..1084c7b17 100644 --- a/apps/mobile/src/features/send/send-form/loaders/send-form-btc-loader.tsx +++ b/apps/mobile/src/features/send/send-form/loaders/send-form-btc-loader.tsx @@ -1,29 +1,31 @@ import { AccountId } from '@/models/domain.model'; -import { useBitcoinAccountTotalBitcoinBalance } from '@/queries/balance/bitcoin-balance.query'; -import BigNumber from 'bignumber.js'; +import { + useBitcoinAccountTotalBitcoinBalance, + useBitcoinAccountUtxos, +} from '@/queries/balance/bitcoin-balance.query'; -import { Money } from '@leather.io/models'; -import { useAverageBitcoinFeeRates } from '@leather.io/query'; +import { AverageBitcoinFeeRates, Money } from '@leather.io/models'; +import { Utxo, useAverageBitcoinFeeRates } from '@leather.io/query'; interface SendFormBtcData { availableBalance: Money; fiatBalance: Money; - feeRates: Record; + feeRates: AverageBitcoinFeeRates; + utxos: Utxo[]; } interface SendFormBtcLoaderProps { account: AccountId; - children({ availableBalance, fiatBalance, feeRates }: SendFormBtcData): React.ReactNode; + children({ availableBalance, fiatBalance, feeRates, utxos }: SendFormBtcData): React.ReactNode; } export function SendFormBtcLoader({ account, children }: SendFormBtcLoaderProps) { - // Not sure if we need to load feeRates here? + const accountId = { fingerprint: account.fingerprint, accountIndex: account.accountIndex }; const { data: feeRates } = useAverageBitcoinFeeRates(); - const { availableBalance, fiatBalance } = useBitcoinAccountTotalBitcoinBalance({ - accountIndex: account.accountIndex, - fingerprint: account.fingerprint, - }); + const { data: utxos = [] } = useBitcoinAccountUtxos(accountId); + const { availableBalance, fiatBalance } = useBitcoinAccountTotalBitcoinBalance(accountId); - if (!feeRates) return null; + // Handle loading and error states + if (!utxos.length || !feeRates) return null; - return children({ availableBalance, fiatBalance, feeRates }); + return children({ availableBalance, fiatBalance, feeRates, utxos }); } diff --git a/apps/mobile/src/features/send/send-form/providers/send-form-btc-provider.tsx b/apps/mobile/src/features/send/send-form/providers/send-form-btc-provider.tsx index 0f68520e4..c537033c2 100644 --- a/apps/mobile/src/features/send/send-form/providers/send-form-btc-provider.tsx +++ b/apps/mobile/src/features/send/send-form/providers/send-form-btc-provider.tsx @@ -1,14 +1,23 @@ import { HasChildren } from '@/utils/types'; 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 { SendFormProvider } from '../send-form-context'; +import { SendFormBaseContext } from '../send-form-context'; +import { SendFormProvider } from '../send-form-provider'; type CurrentRoute = CreateCurrentSendRoute<'send-form-btc'>; +export interface SendFormBtcContext extends SendFormBaseContext { + feeRates: AverageBitcoinFeeRates; + utxos: Utxo[]; +} + export function SendFormBtcProvider({ children }: HasChildren) { const route = useSendSheetRoute(); @@ -16,26 +25,32 @@ export function SendFormBtcProvider({ children }: HasChildren) { return ( - {({ availableBalance, fiatBalance, feeRates }) => ( - - {children} - - )} + {({ availableBalance, fiatBalance, feeRates, utxos }) => { + return ( + + initialData={{ + name: t({ + id: 'asset_name.bitcoin', + message: 'Bitcoin', + }), + protocol: 'nativeBtc', + symbol: 'BTC', + availableBalance, + fiatBalance, + defaultValues: { + ...defaultSendFormBtcValues, + feeRate: feeRates.halfHourFee.toString(), + }, + schema: sendFormBtcSchema, + feeRates, + utxos, + onInitSendTransfer, + }} + > + {children} + + ); + }} ); } diff --git a/apps/mobile/src/features/send/send-form/providers/send-form-stx-provider.tsx b/apps/mobile/src/features/send/send-form/providers/send-form-stx-provider.tsx index 768cab0fb..8d3de699b 100644 --- a/apps/mobile/src/features/send/send-form/providers/send-form-stx-provider.tsx +++ b/apps/mobile/src/features/send/send-form/providers/send-form-stx-provider.tsx @@ -1,6 +1,7 @@ import { HasChildren } from '@/utils/types'; import { t } from '@lingui/macro'; +import { Fees } from '@leather.io/models'; import { defaultStacksFees } from '@leather.io/query'; import { convertAmountToBaseUnit, createMoney } from '@leather.io/utils'; @@ -8,12 +9,17 @@ 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 { SendFormProvider } from '../send-form-context'; +import { SendFormBaseContext } from '../send-form-context'; +import { SendFormProvider } from '../send-form-provider'; const defaultFeeFallback = 2500; type CurrentRoute = CreateCurrentSendRoute<'send-form-stx'>; +export interface SendFormStxContext extends SendFormBaseContext { + fees: Fees; +} + export function SendFormStxProvider({ children }: HasChildren) { const route = useSendSheetRoute(); @@ -27,8 +33,8 @@ export function SendFormStxProvider({ children }: HasChildren) { return ( {({ availableBalance, fiatBalance, nonce }) => ( - + initialData={{ name: t({ id: 'asset_name.stacks', message: 'Stacks', @@ -36,7 +42,6 @@ export function SendFormStxProvider({ children }: HasChildren) { protocol: 'nativeStx', symbol: 'STX', availableBalance, - fees: {}, fiatBalance, defaultValues: { ...defaultSendFormStxValues, @@ -44,6 +49,7 @@ export function SendFormStxProvider({ children }: HasChildren) { nonce, }, schema: sendFormStxSchema, + fees: defaultStacksFees, onInitSendTransfer, }} > 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 067bb8333..1c4a20a15 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 @@ -6,7 +6,7 @@ export const sendFormBtcSchema = z.object({ }), recipient: z.string(), memo: z.string().optional(), - fee: z.string(), + feeRate: z.string(), }); export type SendFormBtcSchema = z.infer; @@ -15,5 +15,5 @@ export const defaultSendFormBtcValues: SendFormBtcSchema = { amount: '', recipient: '', memo: '', - fee: '', + feeRate: '', }; diff --git a/apps/mobile/src/features/send/send-form/send-form-context.tsx b/apps/mobile/src/features/send/send-form/send-form-context.tsx index 70f45956c..5150ce385 100644 --- a/apps/mobile/src/features/send/send-form/send-form-context.tsx +++ b/apps/mobile/src/features/send/send-form/send-form-context.tsx @@ -5,24 +5,26 @@ import { ZodTypeAny } from 'zod'; import { CryptoAssetProtocol, CryptoCurrency, Money } from '@leather.io/models'; -export interface SendFormContext { +export interface SendFormBaseContext { name: string; protocol: CryptoAssetProtocol; symbol: CryptoCurrency; availableBalance: Money; - fees: Record; fiatBalance: Money; defaultValues: FieldValues; schema: ZodTypeAny; - onInitSendTransfer(data: any): void; + onInitSendTransfer(data: T, values: any): void; } -const sendFormContext = createContext(null); +export interface SendFormContext { + formData: T; + onSetFormData(key: keyof T, value: T[keyof T]): void; +} -export const SendFormProvider = sendFormContext.Provider; +export const sendFormContext = createContext | null>(null); -export function useSendFormContext() { - const context = useContext(sendFormContext); +export function useSendFormContext() { + const context = useContext(sendFormContext) as SendFormContext; if (!context) throw new Error('`useSendFormContext` must be used within a `SendFormProvider`'); return context; } diff --git a/apps/mobile/src/features/send/send-form/send-form-provider.tsx b/apps/mobile/src/features/send/send-form/send-form-provider.tsx new file mode 100644 index 000000000..f4f8b4fb9 --- /dev/null +++ b/apps/mobile/src/features/send/send-form/send-form-provider.tsx @@ -0,0 +1,30 @@ +import { useState } from 'react'; + +import { HasChildren } from '@/utils/types'; + +import { SendFormBaseContext, sendFormContext as SendFormContext } from './send-form-context'; + +interface SendFormProviderProps extends HasChildren { + initialData: T; +} +export function SendFormProvider>({ + children, + initialData, +}: SendFormProviderProps) { + const [formData, setFormData] = useState(initialData); + + function onSetFormData(key: keyof T, value: T[keyof T]) { + setFormData(prev => ({ ...prev, [key]: value })); + } + + return ( + + {children} + + ); +} diff --git a/apps/mobile/src/features/send/send-form/send-form.tsx b/apps/mobile/src/features/send/send-form/send-form.tsx index ef419b1b0..1533ad30d 100644 --- a/apps/mobile/src/features/send/send-form/send-form.tsx +++ b/apps/mobile/src/features/send/send-form/send-form.tsx @@ -12,10 +12,11 @@ import { SendFormFooterLayout } from './components/send-form-footer.layout'; import { SendFormMemo } from './components/send-form-memo'; import { SendFormNumpad } from './components/send-form-numpad'; import { SendFormRecipient } from './components/send-form-recipient'; -import { useSendFormContext } from './send-form-context'; +import { SendFormBaseContext, useSendFormContext } from './send-form-context'; -function SendForm({ ...props }: HasChildren) { - const { defaultValues, schema } = useSendFormContext(); +function SendForm>({ ...props }: HasChildren) { + const { formData } = useSendFormContext(); + const { defaultValues, schema } = formData; const formMethods = useForm>({ mode: 'onChange', defaultValues, diff --git a/apps/mobile/src/features/send/send-form/sheets/memo-sheet.tsx b/apps/mobile/src/features/send/send-form/sheets/memo-sheet.tsx index e1ab8996d..dedbe41eb 100644 --- a/apps/mobile/src/features/send/send-form/sheets/memo-sheet.tsx +++ b/apps/mobile/src/features/send/send-form/sheets/memo-sheet.tsx @@ -9,14 +9,14 @@ import { z } from 'zod'; import { Button, NoteTextIcon, SheetRef, UIBottomSheetTextInput } from '@leather.io/ui/native'; -import { useSendFormContext } from '../send-form-context'; +import { SendFormBaseContext, useSendFormContext } from '../send-form-context'; interface MemoSheetProps { sheetRef: RefObject; } -export function MemoSheet({ sheetRef }: MemoSheetProps) { - const { schema } = useSendFormContext(); - const { control } = useFormContext>(); +export function MemoSheet>({ sheetRef }: MemoSheetProps) { + const { formData } = useSendFormContext(); + const { control } = useFormContext>(); const { displayToast } = useToastContext(); function onAddMemo() { diff --git a/apps/mobile/src/features/send/send-form/sheets/recipient-sheet.tsx b/apps/mobile/src/features/send/send-form/sheets/recipient-sheet.tsx index aed47f5fe..4c365caad 100644 --- a/apps/mobile/src/features/send/send-form/sheets/recipient-sheet.tsx +++ b/apps/mobile/src/features/send/send-form/sheets/recipient-sheet.tsx @@ -8,14 +8,16 @@ import { z } from 'zod'; import { Box, Sheet, SheetRef, UIBottomSheetTextInput } from '@leather.io/ui/native'; -import { useSendFormContext } from '../send-form-context'; +import { SendFormBaseContext, useSendFormContext } from '../send-form-context'; interface RecipientSheetProps { sheetRef: RefObject; } -export function RecipientSheet({ sheetRef }: RecipientSheetProps) { - const { schema } = useSendFormContext(); - const { control } = useFormContext>(); +export function RecipientSheet>({ + sheetRef, +}: RecipientSheetProps) { + const { formData } = useSendFormContext(); + const { control } = useFormContext>(); const { themeDerivedFromThemePreference } = useSettings(); return ( diff --git a/apps/mobile/src/features/send/send-sheets/select-asset-sheet.tsx b/apps/mobile/src/features/send/send-sheets/select-asset-sheet.tsx index e974f85ec..ce0e09a50 100644 --- a/apps/mobile/src/features/send/send-sheets/select-asset-sheet.tsx +++ b/apps/mobile/src/features/send/send-sheets/select-asset-sheet.tsx @@ -5,7 +5,7 @@ import { FullHeightSheetLayout } from '@/components/full-height-sheet/full-heigh import { BitcoinBalanceByAccount } from '@/features/balances/bitcoin/bitcoin-balance'; import { StacksBalanceByAccount } from '@/features/balances/stacks/stacks-balance'; import { NetworkBadge } from '@/features/settings/network-badge'; -import { StacksSignerLoader } from '@/store/keychains/keychains'; +import { BitcoinPayerLoader, StacksSignerLoader } from '@/store/keychains/keychains'; import { t } from '@lingui/macro'; import { useLingui } from '@lingui/react'; import { bytesToHex } from '@noble/hashes/utils'; @@ -43,11 +43,21 @@ export function SelectAssetSheet() { } > - navigation.navigate('send-form-btc', { account })} - /> + + {({ nativeSegwitPayer }) => ( + + navigation.navigate('send-form-btc', { + account, + address: nativeSegwitPayer.address, + publicKey: bytesToHex(nativeSegwitPayer.publicKey), + }) + } + /> + )} + {({ stxSigner }) => ( []; +export function useBitcoinAccountUtxos({ fingerprint, accountIndex }: AccountId) { + const descriptors = useBitcoinDescriptorsByAcccount(fingerprint, accountIndex); + const queries = useCreateBitcoinAccountUtxoQueryOptions(descriptors); + return useQueries({ + queries, + combine: results => { + return { + data: results.flatMap(result => result.data).filter(isDefined) as Utxo[], + isLoading: results.some(result => result.isLoading), + isPending: results.some(result => result.isPending), + isError: results.some(result => result.isError), + }; + }, + }); +} + +type TotalBalanceCombineFn = UseQueryResult[]; export function useTotalBitcoinBalanceOfDescriptors(descriptors: string[]) { const queries = useCreateBitcoinAccountUtxoQueryOptions(descriptors); diff --git a/apps/mobile/src/store/keychains/bitcoin/bitcoin-keychains.read.ts b/apps/mobile/src/store/keychains/bitcoin/bitcoin-keychains.read.ts index e45dec1e1..8d3085871 100644 --- a/apps/mobile/src/store/keychains/bitcoin/bitcoin-keychains.read.ts +++ b/apps/mobile/src/store/keychains/bitcoin/bitcoin-keychains.read.ts @@ -94,6 +94,18 @@ export function useBitcoinAccounts() { }, [list]); } +export function useBitcoinPayerFromAccountIndex(fingerprint: string, accountIndex: number) { + const { nativeSegwit, taproot } = useBitcoinAccounts().accountIndexByPaymentType( + fingerprint, + accountIndex + ); + + const taprootPayer = taproot.derivePayer({ addressIndex: 0 }); + const nativeSegwitPayer = nativeSegwit.derivePayer({ addressIndex: 0 }); + + return { taprootPayer, nativeSegwitPayer }; +} + export function useBitcoinPayerAddressFromAccountIndex(fingerprint: string, accountIndex: number) { const { nativeSegwit, taproot } = useBitcoinAccounts().accountIndexByPaymentType( fingerprint, diff --git a/apps/mobile/src/store/keychains/keychains.ts b/apps/mobile/src/store/keychains/keychains.ts index 8ffb9be02..a26664a5f 100644 --- a/apps/mobile/src/store/keychains/keychains.ts +++ b/apps/mobile/src/store/keychains/keychains.ts @@ -1,6 +1,10 @@ import { EntityState, EntityStateAdapter } from '@reduxjs/toolkit'; -import { BitcoinAccountKeychain } from '@leather.io/bitcoin'; +import { + BitcoinAccountKeychain, + BitcoinNativeSegwitPayer, + BitcoinTaprootPayer, +} from '@leather.io/bitcoin'; import { extractAccountIndexFromDescriptor, extractAccountIndexFromPath, @@ -116,6 +120,36 @@ export function BitcoinAccountLoader({ return children({ nativeSegwit, taproot }); } +interface BitcoinPayerLoaderProps { + fingerprint: string; + accountIndex: number; + fallback?: React.ReactNode; + children({ + nativeSegwitPayer, + taprootPayer, + }: { + nativeSegwitPayer: BitcoinNativeSegwitPayer; + taprootPayer: BitcoinTaprootPayer; + }): React.ReactNode; +} +export function BitcoinPayerLoader({ + fingerprint, + accountIndex, + fallback, + children, +}: BitcoinPayerLoaderProps) { + const { nativeSegwit, taproot } = useBitcoinAccounts().accountIndexByPaymentType( + fingerprint, + accountIndex + ); + if (!nativeSegwit || !taproot) return fallback ?? null; + + const taprootPayer = taproot.derivePayer({ addressIndex: 0 }); + const nativeSegwitPayer = nativeSegwit.derivePayer({ addressIndex: 0 }); + + return children({ nativeSegwitPayer, taprootPayer }); +} + interface StacksSignerLoaderProps { fingerprint: string; accountIndex: number; diff --git a/apps/mobile/src/store/settings/settings.read.ts b/apps/mobile/src/store/settings/settings.read.ts index 8f65c8b25..506d2c4af 100644 --- a/apps/mobile/src/store/settings/settings.read.ts +++ b/apps/mobile/src/store/settings/settings.read.ts @@ -8,6 +8,7 @@ import { createSelector } from '@reduxjs/toolkit'; import { StacksNetwork } from '@stacks/network'; import { ChainID, TransactionVersion } from '@stacks/transactions'; +import { getBtcSignerLibNetworkConfigByMode } from '@leather.io/bitcoin'; import { accountDisplayPreferencesKeyedByType, bitcoinUnitsKeyedByName, @@ -117,3 +118,8 @@ export function useNetworkPreferenceStacksNetwork(): StacksNetwork { return stacksNetwork; }, [networkPreference]); } + +export function useNetworkPreferenceBitcoinScureLibNetworkConfig() { + const { networkPreference } = useSettings(); + return getBtcSignerLibNetworkConfigByMode(networkPreference.chain.bitcoin.mode); +} diff --git a/packages/bitcoin/src/bitcoin-error.ts b/packages/bitcoin/src/bitcoin-error.ts new file mode 100644 index 000000000..d2b25bc8b --- /dev/null +++ b/packages/bitcoin/src/bitcoin-error.ts @@ -0,0 +1,13 @@ +export enum BitcoinErrorMessage { + InsufficientFunds = 'InsufficientFunds', +} + +export class BitcoinError extends Error { + constructor(message: string) { + super(message); + this.name = 'BitcoinError'; + + // Fix the prototype chain + Object.setPrototypeOf(this, new.target.prototype); + } +} diff --git a/packages/bitcoin/src/btc-size-fee-estimator.spec.ts b/packages/bitcoin/src/btc-size-fee-estimator.spec.ts index 63813b481..7e65c0928 100644 --- a/packages/bitcoin/src/btc-size-fee-estimator.spec.ts +++ b/packages/bitcoin/src/btc-size-fee-estimator.spec.ts @@ -3,7 +3,11 @@ import { describe, expect, it } from 'vitest'; import { BtcSizeFeeEstimator } from './btc-size-fee-estimator'; describe('BtcSizeFeeEstimator', () => { - const estimator = new BtcSizeFeeEstimator(); + let estimator: BtcSizeFeeEstimator; + + beforeEach(() => { + estimator = new BtcSizeFeeEstimator(); + }); describe('getSizeOfScriptLengthElement', () => { it('should return the correct size for small lengths', () => { diff --git a/packages/bitcoin/src/coin-selection/calculate-max-spend.ts b/packages/bitcoin/src/coin-selection/calculate-max-spend.ts index 9e0ff01bf..2ce5a8175 100644 --- a/packages/bitcoin/src/coin-selection/calculate-max-spend.ts +++ b/packages/bitcoin/src/coin-selection/calculate-max-spend.ts @@ -3,11 +3,12 @@ import BigNumber from 'bignumber.js'; import type { AverageBitcoinFeeRates } from '@leather.io/models'; import { createMoney, satToBtc } from '@leather.io/utils'; -import { Utxo, filterUneconomicalUtxos, getSpendableAmount } from './coin-selection.utils'; +import { CoinSelectionUtxo } from './coin-selection'; +import { filterUneconomicalUtxos, getSpendableAmount } from './coin-selection.utils'; interface CalculateMaxBitcoinSpend { address: string; - utxos: Utxo[]; + utxos: CoinSelectionUtxo[]; fetchedFeeRates?: AverageBitcoinFeeRates; feeRate?: number; } diff --git a/packages/bitcoin/src/coin-selection/coin-selection.mocks.ts b/packages/bitcoin/src/coin-selection/coin-selection.mocks.ts index 74f17a90e..34fab1b94 100644 --- a/packages/bitcoin/src/coin-selection/coin-selection.mocks.ts +++ b/packages/bitcoin/src/coin-selection/coin-selection.mocks.ts @@ -2,7 +2,7 @@ import { sha256 } from '@noble/hashes/sha256'; import { hexToBytes } from '@noble/hashes/utils'; import BigNumber from 'bignumber.js'; -import { Utxo } from './coin-selection.utils'; +import { CoinSelectionUtxo } from './coin-selection'; function generateMockHex() { return Math.floor(Math.random() * 0xffffff) @@ -10,22 +10,17 @@ function generateMockHex() { .padEnd(6, '0'); } -function generateMockTxId(value: number): Utxo { +function generateMockUtxo(value: number): CoinSelectionUtxo { return { + address: 'tb1qxy5r9rlmpcxgwp92x2594q3gg026y4kdv2rsl8', txid: sha256(sha256(hexToBytes(generateMockHex()))).toString(), - vout: 0, - status: { - confirmed: true, - block_height: 2568495, - block_hash: '000000000000008622fafce4a5388861b252d534f819d0f7cb5d4f2c5f9c1638', - block_time: 1703787327, - }, value, + vout: 0, }; } export function generateMockTransactions(values: number[]) { - return values.map(val => generateMockTxId(val)); + return values.map(val => generateMockUtxo(val)); } export function generateMockAverageFee(value: number) { diff --git a/packages/bitcoin/src/coin-selection/coin-selection.spec.ts b/packages/bitcoin/src/coin-selection/coin-selection.spec.ts index 0c59953c2..5e6358b69 100644 --- a/packages/bitcoin/src/coin-selection/coin-selection.spec.ts +++ b/packages/bitcoin/src/coin-selection/coin-selection.spec.ts @@ -123,13 +123,13 @@ describe(determineUtxosForSpend.name, () => { test('that given a set of utxos, legacy is more expensive', () => { const legacy = generate10kSpendWithDummyUtxoSet('15PyZveQd28E2SHZu2ugkWZBp6iER41vXj'); const segwit = generate10kSpendWithDummyUtxoSet('33SVjoCHJovrXxjDKLFSXo1h3t5KgkPzfH'); - expect(legacy.fee).toBeGreaterThan(segwit.fee); + expect(legacy.fee.amount.isGreaterThan(segwit.fee.amount)).toBeTruthy(); }); test('that given a set of utxos, wrapped segwit is more expensive than native', () => { const segwit = generate10kSpendWithDummyUtxoSet('33SVjoCHJovrXxjDKLFSXo1h3t5KgkPzfH'); const native = generate10kSpendWithDummyUtxoSet('tb1qt28eagxcl9gvhq2rpj5slg7dwgxae2dn2hk93m'); - expect(segwit.fee).toBeGreaterThan(native.fee); + expect(segwit.fee.amount.isGreaterThan(native.fee.amount)).toBeTruthy(); }); test('that given a set of utxos, taproot is more expensive than native segwit', () => { @@ -140,7 +140,7 @@ describe(determineUtxosForSpend.name, () => { const taproot = generate10kSpendWithDummyUtxoSet( 'tb1parwmj7533de3k2fw2kntyqacspvhm67qnjcmpqnnpfvzu05l69nsczdywd' ); - expect(taproot.fee).toBeGreaterThan(native.fee); + expect(taproot.fee.amount.isGreaterThan(native.fee.amount)).toBeTruthy(); }); test('against a random set of generated utxos', () => { @@ -162,7 +162,7 @@ describe(determineUtxosForSpend.name, () => { expect(result.outputs[1].value.toString()).toEqual( sumNumbers(result.inputs.map(i => i.value)) - .minus(result.fee) + .minus(result.fee.amount) .minus(amount.toString()) .toString() ); @@ -173,7 +173,7 @@ describe(determineUtxosForSpend.name, () => { const recipients = [ { address: 'tb1qt28eagxcl9gvhq2rpj5slg7dwgxae2dn2hk93m', - amount: createMoney(Number(1), 'BTC'), + amount: createMoney(1, 'BTC'), }, ]; const filteredUtxos = filterUneconomicalUtxos({ @@ -182,21 +182,21 @@ describe(determineUtxosForSpend.name, () => { recipients, }); const amount = filteredUtxos.reduce((total, utxo) => total + utxo.value, 0) - 2251; - recipients[0].amount = createMoney(Number(amount), 'BTC'); + recipients[0].amount = createMoney(amount, 'BTC'); const result = determineUtxosForSpend({ utxos: filteredUtxos as any, recipients: [ { address: 'tb1qt28eagxcl9gvhq2rpj5slg7dwgxae2dn2hk93m', - amount: createMoney(Number(amount), 'BTC'), + amount: createMoney(amount, 'BTC'), }, ], feeRate, }); expect(result.inputs.length).toEqual(10); expect(result.outputs.length).toEqual(1); - expect(result.fee).toEqual(2251); + expect(result.fee.amount.isEqualTo(2251)).toBeTruthy(); }); test('that spending all utxos with sendMax does not result in dust utxos', () => { @@ -215,7 +215,7 @@ describe(determineUtxosForSpend.name, () => { const feeRate = 3; const fee = Math.floor(sizeInfo.txVBytes * feeRate); const amount = utxos.reduce((total, utxo) => total + utxo.value, 0) - fee; - recipients[0].amount = createMoney(Number(amount), 'BTC'); + recipients[0].amount = createMoney(amount, 'BTC'); const result = determineUtxosForSpendAll({ utxos: utxos as any, @@ -224,7 +224,7 @@ describe(determineUtxosForSpend.name, () => { }); expect(result.inputs.length).toEqual(utxos.length); expect(result.outputs.length).toEqual(1); - expect(result.fee).toEqual(735); + expect(result.fee.amount.isEqualTo(735)).toBeTruthy(); expect(fee).toEqual(735); }); }); diff --git a/packages/bitcoin/src/coin-selection/coin-selection.ts b/packages/bitcoin/src/coin-selection/coin-selection.ts index 93b1e6674..e1c567234 100644 --- a/packages/bitcoin/src/coin-selection/coin-selection.ts +++ b/packages/bitcoin/src/coin-selection/coin-selection.ts @@ -2,34 +2,33 @@ import BigNumber from 'bignumber.js'; import { validate } from 'bitcoin-address-validation'; import { BTC_P2WPKH_DUST_AMOUNT } from '@leather.io/constants'; -import { sumMoney, sumNumbers } from '@leather.io/utils'; - -import { - TransferRecipient, - Utxo, - filterUneconomicalUtxos, - getSizeInfo, -} from './coin-selection.utils'; - -export class InsufficientFundsError extends Error { - constructor() { - super('Insufficient funds'); - } -} +import { Money } from '@leather.io/models'; +import { createMoney, sumMoney } from '@leather.io/utils'; + +import { BitcoinError, BitcoinErrorMessage } from '../bitcoin-error'; +import { filterUneconomicalUtxos, getSizeInfo, getUtxoTotal } from './coin-selection.utils'; -interface Output { +export interface CoinSelectionOutput { value: bigint; address?: string; } -export interface DetermineUtxosForSpendArgs { - feeRate: number; - recipients: TransferRecipient[]; - utxos: Utxo[]; +export interface CoinSelectionUtxo { + address: string; + txid: string; + value: number; + vout: number; } -function getUtxoTotal(utxos: Utxo[]) { - return sumNumbers(utxos.map(utxo => utxo.value)); +export interface CoinSelectionRecipient { + address: string; + amount: Money; +} + +export interface DetermineUtxosForSpendArgs { + feeRate: number; + recipients: CoinSelectionRecipient[]; + utxos: CoinSelectionUtxo[]; } export function determineUtxosForSpendAll({ @@ -61,7 +60,7 @@ export function determineUtxosForSpendAll({ inputs: filteredUtxos, outputs, size: sizeInfo.txVBytes, - fee, + fee: createMoney(new BigNumber(fee), 'BTC'), }; } @@ -75,12 +74,12 @@ export function determineUtxosForSpend({ feeRate, recipients, utxos }: Determine feeRate, recipients, }); - if (!filteredUtxos.length) throw new InsufficientFundsError(); + if (!filteredUtxos.length) throw new BitcoinError(BitcoinErrorMessage.InsufficientFunds); const amount = sumMoney(recipients.map(recipient => recipient.amount)); - // Prepopulate with first UTXO, at least one is needed - const neededUtxos: Utxo[] = [filteredUtxos[0]]; + // Prepopulate with first utxo, at least one is needed + const neededUtxos: CoinSelectionUtxo[] = [filteredUtxos[0]]; function estimateTransactionSize() { return getSizeInfo({ @@ -101,7 +100,7 @@ export function determineUtxosForSpend({ feeRate, recipients, utxos }: Determine while (!hasSufficientUtxosForTx()) { const [nextUtxo] = getRemainingUnspentUtxos(); - if (!nextUtxo) throw new InsufficientFundsError(); + if (!nextUtxo) throw new BitcoinError(BitcoinErrorMessage.InsufficientFunds); neededUtxos.push(nextUtxo); } @@ -112,7 +111,7 @@ export function determineUtxosForSpend({ feeRate, recipients, utxos }: Determine const changeAmount = BigInt(getUtxoTotal(neededUtxos).toString()) - BigInt(amount.amount.toNumber()) - BigInt(fee); - const changeUtxos: Output[] = + const changeUtxos: CoinSelectionOutput[] = changeAmount > BTC_P2WPKH_DUST_AMOUNT ? [ { @@ -121,7 +120,7 @@ export function determineUtxosForSpend({ feeRate, recipients, utxos }: Determine ] : []; - const outputs: Output[] = [ + const outputs: CoinSelectionOutput[] = [ ...recipients.map(({ address, amount }) => ({ value: BigInt(amount.amount.toNumber()), address, @@ -134,7 +133,7 @@ export function determineUtxosForSpend({ feeRate, recipients, utxos }: Determine inputs: neededUtxos, outputs, size: estimateTransactionSize().txVBytes, - fee, + fee: createMoney(new BigNumber(fee), 'BTC'), ...estimateTransactionSize(), }; } diff --git a/packages/bitcoin/src/coin-selection/coin-selection.utils.ts b/packages/bitcoin/src/coin-selection/coin-selection.utils.ts index 0a7e37d8e..ff2a88674 100644 --- a/packages/bitcoin/src/coin-selection/coin-selection.utils.ts +++ b/packages/bitcoin/src/coin-selection/coin-selection.utils.ts @@ -2,23 +2,18 @@ import BigNumber from 'bignumber.js'; import validate, { AddressInfo, AddressType, getAddressInfo } from 'bitcoin-address-validation'; import { BTC_P2WPKH_DUST_AMOUNT } from '@leather.io/constants'; -import { Money } from '@leather.io/models'; +import { sumNumbers } from '@leather.io/utils'; import { BtcSizeFeeEstimator } from '../btc-size-fee-estimator'; +import { CoinSelectionRecipient, CoinSelectionUtxo } from './coin-selection'; -export interface TransferRecipient { - address: string; - amount: Money; -} - -export interface Utxo extends Record { - txid: string; - value: number; +export function getUtxoTotal(utxos: CoinSelectionUtxo[]) { + return sumNumbers(utxos.map(utxo => utxo.value)); } export function getSizeInfo(payload: { inputLength: number; - recipients: TransferRecipient[]; + recipients: CoinSelectionRecipient[]; isSendMax?: boolean; }) { const { inputLength, recipients, isSendMax } = payload; @@ -68,11 +63,13 @@ export function getSpendableAmount({ feeRate, recipients, }: { - utxos: Utxo[]; + utxos: CoinSelectionUtxo[]; feeRate: number; - recipients: TransferRecipient[]; + recipients: CoinSelectionRecipient[]; }) { - const balance = utxos.map(utxo => utxo.value).reduce((prevVal, curVal) => prevVal + curVal, 0); + const balance = utxos + .map(utxo => Number(utxo.value)) + .reduce((prevVal, curVal) => prevVal + curVal, 0); const size = getSizeInfo({ inputLength: utxos.length, @@ -92,9 +89,9 @@ export function filterUneconomicalUtxos({ feeRate, recipients, }: { - utxos: Utxo[]; + utxos: CoinSelectionUtxo[]; feeRate: number; - recipients: TransferRecipient[]; + recipients: CoinSelectionRecipient[]; }) { const { spendableAmount: fullSpendableAmount } = getSpendableAmount({ utxos, @@ -111,7 +108,7 @@ export function filterUneconomicalUtxos({ feeRate, recipients, }); - // If fullSpendableAmount is greater, do not use that utxo + // If fullSpendableAmount is greater, do not use utxo return spendableAmount.toNumber() < fullSpendableAmount.toNumber(); }); return filteredUtxos; diff --git a/packages/bitcoin/src/fees/bitcoin-fees.spec.ts b/packages/bitcoin/src/fees/bitcoin-fees.spec.ts new file mode 100644 index 000000000..ebdbb3493 --- /dev/null +++ b/packages/bitcoin/src/fees/bitcoin-fees.spec.ts @@ -0,0 +1,90 @@ +import BigNumber from 'bignumber.js'; + +import { AverageBitcoinFeeRates } from '@leather.io/models'; +import { createMoney } from '@leather.io/utils'; + +import { CoinSelectionRecipient, CoinSelectionUtxo } from '../coin-selection/coin-selection'; +import { getBitcoinFees, getBitcoinTransactionFee } from './bitcoin-fees'; + +describe('getBitcoinTransactionFee', () => { + it('should return the fee for a normal transaction', () => { + const args = { + recipients: [ + { address: 'tb1qsqncyhhqdtfn07t3dhupx7smv5gk83ds6k0gfa', amount: createMoney(1000, 'BTC') }, + ], + utxos: [ + { + address: 'tb1qt28eagxcl9gvhq2rpj5slg7dwgxae2dn2hk93m', + txid: '8192e8e20088c5f052fc7351b86b8f60a9454937860b281227e53e19f3e9c3f6', + vout: 0, + value: 2000, + }, + ], + feeRate: 1, + }; + const fee = getBitcoinTransactionFee(args); + const expectedFee = createMoney(141, 'BTC'); + expect(fee).toEqual(expectedFee); + }); + + it('should return the fee for a max send transaction', () => { + const args = { + isSendingMax: true, + recipients: [ + { address: 'tb1qsqncyhhqdtfn07t3dhupx7smv5gk83ds6k0gfa', amount: createMoney(2000, 'BTC') }, + ], + utxos: [ + { + address: 'tb1qt28eagxcl9gvhq2rpj5slg7dwgxae2dn2hk93m', + txid: '8192e8e20088c5f052fc7351b86b8f60a9454937860b281227e53e19f3e9c3f6', + vout: 0, + value: 2000, + }, + ], + feeRate: 2, + }; + const fee = getBitcoinTransactionFee(args); + const expectedFee = createMoney(219, 'BTC'); + expect(fee).toEqual(expectedFee); + }); + + it('should return null if an error occurs', () => { + const args = { + recipients: [], + utxos: [], + feeRate: 1, + }; + const fee = getBitcoinTransactionFee(args); + expect(fee).toBeNull(); + }); +}); + +describe('getBitcoinFees', () => { + it('should return the fees for different fee rates', () => { + const feeRates: AverageBitcoinFeeRates = { + fastestFee: new BigNumber(3), + halfHourFee: new BigNumber(2), + hourFee: new BigNumber(1), + }; + const recipients: CoinSelectionRecipient[] = [ + { address: 'tb1qsqncyhhqdtfn07t3dhupx7smv5gk83ds6k0gfa', amount: createMoney(1000, 'BTC') }, + ]; + const utxos: CoinSelectionUtxo[] = [ + { + address: 'tb1qt28eagxcl9gvhq2rpj5slg7dwgxae2dn2hk93m', + txid: '8192e8e20088c5f052fc7351b86b8f60a9454937860b281227e53e19f3e9c3f6', + vout: 0, + value: 2000, + }, + ]; + + const fees = getBitcoinFees({ feeRates, recipients, utxos }); + const expectedFees = { + high: { feeRate: 3, fee: createMoney(422, 'BTC') }, + standard: { feeRate: 2, fee: createMoney(281, 'BTC') }, + low: { feeRate: 1, fee: createMoney(141, 'BTC') }, + }; + + expect(fees).toEqual(expectedFees); + }); +}); diff --git a/packages/bitcoin/src/fees/bitcoin-fees.ts b/packages/bitcoin/src/fees/bitcoin-fees.ts new file mode 100644 index 000000000..b02ed9a9f --- /dev/null +++ b/packages/bitcoin/src/fees/bitcoin-fees.ts @@ -0,0 +1,68 @@ +import { AverageBitcoinFeeRates, Money } from '@leather.io/models'; + +import { + CoinSelectionRecipient, + CoinSelectionUtxo, + DetermineUtxosForSpendArgs, + determineUtxosForSpend, + determineUtxosForSpendAll, +} from '../coin-selection/coin-selection'; + +type GetBitcoinTransactionFeeArgs = DetermineUtxosForSpendArgs & { + isSendingMax?: boolean; +}; + +export function getBitcoinTransactionFee({ isSendingMax, ...props }: GetBitcoinTransactionFeeArgs) { + try { + const { fee } = isSendingMax + ? determineUtxosForSpendAll({ ...props }) + : determineUtxosForSpend({ ...props }); + return fee; + } catch (error) { + return null; + } +} + +export interface BitcoinFees { + blockchain: 'bitcoin'; + high: { fee: Money | null; feeRate: number }; + standard: { fee: Money | null; feeRate: number }; + low: { fee: Money | null; feeRate: number }; +} + +export interface GetBitcoinFeesArgs { + feeRates: AverageBitcoinFeeRates; + isSendingMax?: boolean; + recipients: CoinSelectionRecipient[]; + utxos: CoinSelectionUtxo[]; +} +export function getBitcoinFees({ feeRates, isSendingMax, recipients, utxos }: GetBitcoinFeesArgs) { + const defaultArgs = { + isSendingMax, + recipients, + utxos, + }; + + const highFeeRate = feeRates.fastestFee.toNumber(); + const standardFeeRate = feeRates.halfHourFee.toNumber(); + const lowFeeRate = feeRates.hourFee.toNumber(); + + const highFeeValue = getBitcoinTransactionFee({ + ...defaultArgs, + feeRate: highFeeRate, + }); + const standardFeeValue = getBitcoinTransactionFee({ + ...defaultArgs, + feeRate: standardFeeRate, + }); + const lowFeeValue = getBitcoinTransactionFee({ + ...defaultArgs, + feeRate: lowFeeRate, + }); + + return { + high: { feeRate: highFeeRate, fee: highFeeValue }, + standard: { feeRate: standardFeeRate, fee: standardFeeValue }, + low: { feeRate: lowFeeRate, fee: lowFeeValue }, + }; +} diff --git a/packages/bitcoin/src/index.ts b/packages/bitcoin/src/index.ts index bf1db8bd5..ae88e7207 100644 --- a/packages/bitcoin/src/index.ts +++ b/packages/bitcoin/src/index.ts @@ -1,5 +1,15 @@ export * from './bip322/bip322-utils'; export * from './bip322/sign-message-bip322-bitcoinjs'; + +export * from './coin-selection/calculate-max-spend'; +export * from './coin-selection/coin-selection'; +export * from './coin-selection/coin-selection.utils'; + +export * from './fees/bitcoin-fees'; + +export * from './transactions/generate-unsigned-transaction'; + +export * from './bitcoin-error'; export * from './bitcoin-signer'; export * from './bitcoin.network'; export * from './bitcoin.utils'; diff --git a/packages/bitcoin/src/transactions/generate-unsigned-transaction.spec.ts b/packages/bitcoin/src/transactions/generate-unsigned-transaction.spec.ts new file mode 100644 index 000000000..4d3fba537 --- /dev/null +++ b/packages/bitcoin/src/transactions/generate-unsigned-transaction.spec.ts @@ -0,0 +1,82 @@ +import { describe, expect, it } from 'vitest'; + +import { createMoney } from '@leather.io/utils'; + +import { getBtcSignerLibNetworkConfigByMode } from '../bitcoin.network'; +import { + GenerateUnsignedTransactionArgs, + generateUnsignedTransactionNativeSegwit, +} from './generate-unsigned-transaction'; + +describe('generateUnsignedTransactionNativeSegwit', () => { + const mockArgs: GenerateUnsignedTransactionArgs = { + payerAddress: 'tb1qxy5r9rlmpcxgwp92x2594q3gg026y4kdv2rsl8', + payerPublicKey: '0329b076bc20f7b1592b2a1a5cb91dfefe8c966e50e256458e23dd2c5d63f8f1af', + network: getBtcSignerLibNetworkConfigByMode('testnet'), + inputs: [ + { + address: 'tb1qt28eagxcl9gvhq2rpj5slg7dwgxae2dn2hk93m', + txid: '8192e8e20088c5f052fc7351b86b8f60a9454937860b281227e53e19f3e9c3f6', + vout: 0, + value: 100000, + }, + { + address: 'tb1qt28eagxcl9gvhq2rpj5slg7dwgxae2dn2hk93m', + txid: 'c715ea469c8d794f6dd7e0043148631f69d411c428ef0ab2b04e4528ffe8319f', + vout: 1, + value: 200000, + }, + ], + outputs: [ + { + address: 'tb1qsqncyhhqdtfn07t3dhupx7smv5gk83ds6k0gfa', + value: BigInt(150000), + }, + { + address: 'tb1qt28eagxcl9gvhq2rpj5slg7dwgxae2dn2hk93m', + value: BigInt(100000), + }, + ], + fee: createMoney(50000, 'BTC'), + }; + + it('should generate an unsigned transaction with correct inputs and outputs', async () => { + const result = await generateUnsignedTransactionNativeSegwit(mockArgs); + + expect(result.inputs).toEqual(mockArgs.inputs); + expect(result.fee).toEqual(mockArgs.fee); + expect(result.hex).toBeDefined(); + expect(result.psbt).toBeDefined(); + }); + + it('should handle change output correctly', async () => { + const argsWithChange: GenerateUnsignedTransactionArgs = { + ...mockArgs, + outputs: [ + ...mockArgs.outputs, + { + address: 'tb1qt28eagxcl9gvhq2rpj5slg7dwgxae2dn2hk93m', + value: BigInt(50000), + }, + ], + }; + + const result = await generateUnsignedTransactionNativeSegwit(argsWithChange); + + expect(result.inputs).toEqual(argsWithChange.inputs); + expect(result.fee).toEqual(argsWithChange.fee); + expect(result.hex).toBeDefined(); + expect(result.psbt).toBeDefined(); + }); + + it('should throw an error if inputs are empty', async () => { + const argsWithNoInputs: GenerateUnsignedTransactionArgs = { + ...mockArgs, + inputs: [], + }; + + const tx = await generateUnsignedTransactionNativeSegwit(argsWithNoInputs); + + await expect(tx.inputs.length).toBe(0); + }); +}); diff --git a/packages/bitcoin/src/transactions/generate-unsigned-transaction.ts b/packages/bitcoin/src/transactions/generate-unsigned-transaction.ts new file mode 100644 index 000000000..66834bb05 --- /dev/null +++ b/packages/bitcoin/src/transactions/generate-unsigned-transaction.ts @@ -0,0 +1,52 @@ +import { hexToBytes } from '@noble/hashes/utils'; +import * as btc from '@scure/btc-signer'; +import { BtcSignerNetwork } from 'bitcoin.network'; +import { CoinSelectionOutput, CoinSelectionUtxo } from 'coin-selection/coin-selection'; + +import { Money } from '@leather.io/models'; + +export interface GenerateUnsignedTransactionArgs { + payerAddress: string; + payerPublicKey: string; + network: BtcSignerNetwork; + inputs: CoinSelectionUtxo[]; + outputs: CoinSelectionOutput[]; + fee: Money; +} + +export async function generateUnsignedTransactionNativeSegwit({ + payerAddress, + payerPublicKey, + network, + inputs, + outputs, + fee, +}: GenerateUnsignedTransactionArgs) { + 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, + 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); + }); + + return { hex: tx.hex, psbt: tx.toPSBT(), inputs, fee }; +} diff --git a/packages/models/src/index.ts b/packages/models/src/index.ts index 01a67183b..1f12f5f99 100644 --- a/packages/models/src/index.ts +++ b/packages/models/src/index.ts @@ -15,4 +15,3 @@ export * from './network/network.schema'; export * from './settings.model'; export * from './transactions/bitcoin-transaction.model'; export * from './transactions/stacks-transaction.model'; -export * from './utxo.model'; diff --git a/packages/models/src/utxo.model.ts b/packages/models/src/utxo.model.ts deleted file mode 100644 index b7df3dae2..000000000 --- a/packages/models/src/utxo.model.ts +++ /dev/null @@ -1,11 +0,0 @@ -export interface UtxoItem { - txid: string; - vout: number; - status: { - confirmed: boolean; - block_height: number; - block_hash: string; - block_time: number; - }; - value: number; -} diff --git a/packages/query/src/bitcoin/fees/fee-estimates.hooks.ts b/packages/query/src/bitcoin/fees/fee-estimates.hooks.ts index 78c150ae5..43f6f1f52 100644 --- a/packages/query/src/bitcoin/fees/fee-estimates.hooks.ts +++ b/packages/query/src/bitcoin/fees/fee-estimates.hooks.ts @@ -1,12 +1,13 @@ -import { useQuery } from '@tanstack/react-query'; +import { UseQueryResult, useQuery } from '@tanstack/react-query'; +import { AverageBitcoinFeeRates } from '@leather.io/models'; import { calculateMeanAverage, initBigNumber, isFulfilled, isRejected } from '@leather.io/utils'; import { useLeatherNetwork } from '../../leather-query-provider'; import { useBitcoinClient } from '../clients/bitcoin-client'; import { createGetBitcoinFeeEstimatesQueryOptions } from './fee-estimates.query'; -export function useAverageBitcoinFeeRates() { +export function useAverageBitcoinFeeRates(): UseQueryResult { const client = useBitcoinClient(); const network = useLeatherNetwork(); diff --git a/packages/stacks/src/index.ts b/packages/stacks/src/index.ts index 295931e42..c29d9d7ff 100644 --- a/packages/stacks/src/index.ts +++ b/packages/stacks/src/index.ts @@ -1,4 +1,8 @@ +export * from './signer/signer'; + +export * from './transactions/generate-unsigned-transaction'; +export * from './transactions/post-condition.utils'; +export * from './transactions/transaction.types'; + export * from './stacks.utils'; export * from './message-signing'; -export * from './signer/signer'; -export * from './transactions'; diff --git a/packages/stacks/src/transactions/index.ts b/packages/stacks/src/transactions/index.ts deleted file mode 100644 index 18286b59a..000000000 --- a/packages/stacks/src/transactions/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -export * from './generate-unsigned-transaction'; -export * from './post-condition.utils'; -export * from './transaction.types';