Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix: rpc send transfer recipient choose fee #5238

Merged
merged 1 commit into from
Apr 16, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 1 addition & 2 deletions src/app/common/error-formatters.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,7 @@
import { FormErrorMessages } from '@shared/error-messages';
import { Money } from '@shared/models/money.model';
import { isFunction } from '@shared/utils';

import { FormErrorMessages } from '@app/common/error-messages';

export function formatPrecisionError(num?: Money) {
if (!num) return FormErrorMessages.CannotDeterminePrecision;
const error = FormErrorMessages.TooMuchPrecision;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,15 @@
import { validate } from 'bitcoin-address-validation';

import type { RpcSendTransferRecipient } from '@shared/rpc/methods/send-transfer';

import { UtxoResponseItem } from '@app/query/bitcoin/bitcoin-client';

import { filterUneconomicalUtxos, getSizeInfo } from '../utils';
import {
filterUneconomicalUtxos,
filterUneconomicalUtxosMultipleRecipients,
getSizeInfo,
getSizeInfoMultipleRecipients,
} from '../utils';

export interface DetermineUtxosForSpendArgs {
amount: number;
Expand Down Expand Up @@ -96,3 +103,103 @@ export function determineUtxosForSpend({
fee,
};
}

export interface DetermineUtxosForSpendArgsMultipleRecipients {
amount: number;
feeRate: number;
recipients: RpcSendTransferRecipient[];
utxos: UtxoResponseItem[];
}

interface DetermineUtxosForSpendAllArgsMultipleRecipients {
feeRate: number;
recipients: RpcSendTransferRecipient[];
utxos: UtxoResponseItem[];
}

export function determineUtxosForSpendAllMultipleRecipients({
feeRate,
recipients,
utxos,
}: DetermineUtxosForSpendAllArgsMultipleRecipients) {
recipients.forEach(recipient => {
if (!validate(recipient.address))
throw new Error('Cannot calculate spend of invalid address type');
});
const filteredUtxos = filterUneconomicalUtxosMultipleRecipients({ utxos, feeRate, recipients });

const sizeInfo = getSizeInfoMultipleRecipients({
inputLength: filteredUtxos.length,
isSendMax: true,
recipients,
});

// Fee has already been deducted from the amount with send all
const outputs = recipients.map(({ address, amount }) => ({ value: BigInt(amount), address }));

const fee = Math.ceil(sizeInfo.txVBytes * feeRate);
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

use BigNumber for math

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

most of this fn is copy-pasted, I can refactor math there later?


return {
inputs: filteredUtxos,
outputs,
size: sizeInfo.txVBytes,
fee,
};
}

export function determineUtxosForSpendMultipleRecipients({
amount,
feeRate,
recipients,
utxos,
}: DetermineUtxosForSpendArgsMultipleRecipients) {
recipients.forEach(recipient => {
if (!validate(recipient.address))
throw new Error('Cannot calculate spend of invalid address type');
});

const orderedUtxos = utxos.sort((a, b) => b.value - a.value);

const filteredUtxos = filterUneconomicalUtxosMultipleRecipients({
utxos: orderedUtxos,
feeRate,
recipients,
});

const neededUtxos = [];
let sum = 0n;
let sizeInfo = null;

for (const utxo of filteredUtxos) {
sizeInfo = getSizeInfoMultipleRecipients({
inputLength: neededUtxos.length,
recipients,
});
if (sum >= BigInt(amount) + BigInt(Math.ceil(sizeInfo.txVBytes * feeRate))) break;

sum += BigInt(utxo.value);
neededUtxos.push(utxo);
}

if (!sizeInfo) throw new InsufficientFundsError();

const fee = Math.ceil(sizeInfo.txVBytes * feeRate);

const outputs: {
value: bigint;
address?: string;
}[] = [
// outputs[0] = the desired amount going to recipient
...recipients.map(({ address, amount }) => ({ value: BigInt(amount), address })),
// outputs[recipients.length] = the remainder to be returned to a change address
{ value: sum - BigInt(amount) - BigInt(fee) },
];

return {
filteredUtxos,
inputs: neededUtxos,
outputs,
size: sizeInfo.txVBytes,
fee,
};
}
85 changes: 85 additions & 0 deletions src/app/common/transactions/bitcoin/use-generate-bitcoin-tx.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,13 @@ import * as btc from '@scure/btc-signer';

import { logger } from '@shared/logger';
import { Money } from '@shared/models/money.model';
import type { RpcSendTransferRecipient } from '@shared/rpc/methods/send-transfer';

import {
determineUtxosForSpend,
determineUtxosForSpendAll,
determineUtxosForSpendAllMultipleRecipients,
determineUtxosForSpendMultipleRecipients,
} from '@app/common/transactions/bitcoin/coinselect/local-coin-selection';
import { UtxoResponseItem } from '@app/query/bitcoin/bitcoin-client';
import { useBitcoinScureLibNetworkConfig } from '@app/store/accounts/blockchain/bitcoin/bitcoin-keychain';
Expand Down Expand Up @@ -91,3 +94,85 @@ export function useGenerateUnsignedNativeSegwitSingleRecipientTx() {
[networkMode, signer.address, signer.publicKey]
);
}

interface GenerateNativeSegwitMultipleRecipientsTxValues {
amount: Money;
recipients: RpcSendTransferRecipient[];
}

export function useGenerateUnsignedNativeSegwitMultipleRecipientsTx() {
const signer = useCurrentAccountNativeSegwitIndexZeroSigner();

const networkMode = useBitcoinScureLibNetworkConfig();

return useCallback(
async (
values: GenerateNativeSegwitMultipleRecipientsTxValues,
feeRate: number,
utxos: UtxoResponseItem[],
isSendingMax?: boolean
) => {
if (!utxos.length) return;
if (!feeRate) return;

try {
const tx = new btc.Transaction();

const amountAsNumber = values.amount.amount.toNumber();

const determineUtxosArgs = {
amount: amountAsNumber,
feeRate,
recipients: values.recipients,
utxos,
};

const { inputs, outputs, fee } = isSendingMax
? determineUtxosForSpendAllMultipleRecipients(determineUtxosArgs)
: determineUtxosForSpendMultipleRecipients(determineUtxosArgs);

logger.info('Coin selection', { inputs, outputs, fee });

if (!inputs.length) throw new Error('No inputs to sign');
if (!outputs.length) throw new Error('No outputs to sign');

// Is this critical?

// if (outputs.length > 2)
// throw new Error('Address reuse mode: wallet should have max 2 outputs');

const p2wpkh = btc.p2wpkh(signer.publicKey, networkMode);

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 output with no address we assume it is
// a change output
if (!output.address) {
tx.addOutputAddress(signer.address, BigInt(output.value), networkMode);
return;
}
tx.addOutputAddress(output.address, BigInt(output.value), networkMode);
});

return { hex: tx.hex, fee, psbt: tx.toPSBT(), inputs };
} catch (e) {
// eslint-disable-next-line no-console
console.log('Error signing bitcoin transaction', e);
return null;
}
},
[networkMode, signer.address, signer.publicKey]
);
}
110 changes: 109 additions & 1 deletion src/app/common/transactions/bitcoin/utils.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
import BigNumber from 'bignumber.js';
import { getAddressInfo, validate } from 'bitcoin-address-validation';
import { AddressType, getAddressInfo, validate } from 'bitcoin-address-validation';

import { BTC_P2WPKH_DUST_AMOUNT } from '@shared/constants';
import {
BitcoinTransactionVectorOutput,
BitcoinTx,
} from '@shared/models/transactions/bitcoin-transaction.model';
import type { RpcSendTransferRecipient } from '@shared/rpc/methods/send-transfer';

import { sumNumbers } from '@app/common/math/helpers';
import { satToBtc } from '@app/common/money/unit-conversion';
Expand Down Expand Up @@ -144,3 +145,110 @@ export function getBitcoinTxValue(address: string, transaction?: BitcoinTx) {
if (outputs.length) return totalOutputValue.toString();
return '';
}

// multiple recipients
function getSpendableAmountMultipleRecipients({
utxos,
feeRate,
recipients,
}: {
utxos: UtxoResponseItem[];
feeRate: number;
recipients: RpcSendTransferRecipient[];
}) {
const balance = utxos.map(utxo => utxo.value).reduce((prevVal, curVal) => prevVal + curVal, 0);

const size = getSizeInfoMultipleRecipients({
inputLength: utxos.length,
recipients,
});
const fee = Math.ceil(size.txVBytes * feeRate);
const bigNumberBalance = BigNumber(balance);
return {
spendableAmount: BigNumber.max(0, bigNumberBalance.minus(fee)),
fee,
};
}

export function filterUneconomicalUtxosMultipleRecipients({
utxos,
feeRate,
recipients,
}: {
utxos: UtxoResponseItem[];
feeRate: number;
recipients: RpcSendTransferRecipient[];
}) {
const { spendableAmount: fullSpendableAmount } = getSpendableAmountMultipleRecipients({
utxos,
feeRate,
recipients,
});

const filteredUtxos = utxos
.filter(utxo => utxo.value >= BTC_P2WPKH_DUST_AMOUNT)
.filter(utxo => {
// calculate spendableAmount without that utxo.
const { spendableAmount } = getSpendableAmountMultipleRecipients({
utxos: utxos.filter(u => u.txid !== utxo.txid),
feeRate,
recipients,
});
// if spendable amount becomes bigger, do not use that utxo
return spendableAmount.toNumber() < fullSpendableAmount.toNumber();
});
return filteredUtxos;
}

export function getSizeInfoMultipleRecipients(payload: {
inputLength: number;
recipients: RpcSendTransferRecipient[];
isSendMax?: boolean;
}) {
const { inputLength, recipients, isSendMax } = payload;

const addressesInfo = recipients.map(recipient => {
return validate(recipient.address) ? getAddressInfo(recipient.address) : null;
});
const outputAddressesTypesWithFallback = addressesInfo.map(addressInfo =>
addressInfo ? addressInfo.type : AddressType.p2wpkh
);

const outputTypesLengthMap = outputAddressesTypesWithFallback.reduce(
(acc: Record<AddressType, number>, outputType) => {
// we add 1 output for change address if not sending max
if (!acc['p2wpkh'] && !isSendMax) {
acc['p2wpkh'] = 1;
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

what is 1?

Copy link
Contributor Author

@alter-eggo alter-eggo Apr 16, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

default output length value

}

if (acc[outputType]) {
acc[outputType] = acc[outputType] + 1;
} else {
acc[outputType] = 1;
}

return acc;
},
{} as Record<AddressType, number>
);

const outputsData = (Object.keys(outputTypesLengthMap) as AddressType[]).map(
outputAddressType => {
return {
[outputAddressType + '_output_count']: outputTypesLengthMap[outputAddressType],
};
}
);

const txSizer = new BtcSizeFeeEstimator();
const sizeInfo = txSizer.calcTxSize({
// Only p2wpkh is supported by the wallet
input_script: 'p2wpkh',
input_count: inputLength,
// From the address of the recipient, we infer the output type

...outputsData,
});

return sizeInfo;
}
2 changes: 1 addition & 1 deletion src/app/common/validation/forms/address-validators.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,9 @@ import { AddressType, Network, getAddressInfo, validate } from 'bitcoin-address-
import * as yup from 'yup';

import { BitcoinNetworkModes, NetworkConfiguration } from '@shared/constants';
import { FormErrorMessages } from '@shared/error-messages';
import { isString } from '@shared/utils';

import { FormErrorMessages } from '@app/common/error-messages';
import { validateAddressChain, validateStacksAddress } from '@app/common/stacks-utils';

function notCurrentAddressValidatorFactory(currentAddress: string) {
Expand Down
2 changes: 1 addition & 1 deletion src/app/common/validation/forms/amount-validators.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,8 @@ import {
} from '@app/common/money/unit-conversion';
import { UtxoResponseItem } from '@app/query/bitcoin/bitcoin-client';

import { FormErrorMessages } from '../../../../shared/error-messages';
import { formatInsufficientBalanceError, formatPrecisionError } from '../../error-formatters';
import { FormErrorMessages } from '../../error-messages';
import { currencyAmountValidator, stxAmountPrecisionValidator } from './currency-validators';

const minSpendAmountInSats = 6000;
Expand Down
2 changes: 1 addition & 1 deletion src/app/common/validation/forms/currency-validators.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import * as yup from 'yup';

import { BTC_DECIMALS, STX_DECIMALS } from '@shared/constants';
import { FormErrorMessages } from '@shared/error-messages';
import { isNumber } from '@shared/utils';

import { FormErrorMessages } from '@app/common/error-messages';
import { countDecimals } from '@app/common/math/helpers';

export function currencyAmountValidator() {
Expand Down
3 changes: 1 addition & 2 deletions src/app/common/validation/forms/recipient-validators.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import { NetworkConfiguration } from '@shared/constants';
import { stacksChainIdToCoreNetworkMode } from '@shared/crypto/stacks/stacks.utils';

import { FormErrorMessages } from '@app/common/error-messages';
import { FormErrorMessages } from '@shared/error-messages';

import {
notCurrentAddressValidator,
Expand Down
Loading
Loading