Skip to content

Commit

Permalink
fix: rpc send transfer recipient choose fee
Browse files Browse the repository at this point in the history
  • Loading branch information
alter-eggo committed Apr 16, 2024
1 parent f665707 commit a739014
Show file tree
Hide file tree
Showing 31 changed files with 977 additions and 86 deletions.
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);

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;
}

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

0 comments on commit a739014

Please sign in to comment.