@leather.io/bitcoin 0.17.0 → 0.18.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -2,34 +2,33 @@ import BigNumber from 'bignumber.js';
2
2
  import { validate } from 'bitcoin-address-validation';
3
3
 
4
4
  import { BTC_P2WPKH_DUST_AMOUNT } from '@leather.io/constants';
5
- import { sumMoney, sumNumbers } from '@leather.io/utils';
6
-
7
- import {
8
- TransferRecipient,
9
- Utxo,
10
- filterUneconomicalUtxos,
11
- getSizeInfo,
12
- } from './coin-selection.utils';
13
-
14
- export class InsufficientFundsError extends Error {
15
- constructor() {
16
- super('Insufficient funds');
17
- }
18
- }
5
+ import { Money } from '@leather.io/models';
6
+ import { createMoney, sumMoney } from '@leather.io/utils';
7
+
8
+ import { BitcoinError, BitcoinErrorMessage } from '../bitcoin-error';
9
+ import { filterUneconomicalUtxos, getSizeInfo, getUtxoTotal } from './coin-selection.utils';
19
10
 
20
- interface Output {
11
+ export interface CoinSelectionOutput {
21
12
  value: bigint;
22
13
  address?: string;
23
14
  }
24
15
 
25
- export interface DetermineUtxosForSpendArgs {
26
- feeRate: number;
27
- recipients: TransferRecipient[];
28
- utxos: Utxo[];
16
+ export interface CoinSelectionUtxo {
17
+ address: string;
18
+ txid: string;
19
+ value: number;
20
+ vout: number;
29
21
  }
30
22
 
31
- function getUtxoTotal(utxos: Utxo[]) {
32
- return sumNumbers(utxos.map(utxo => utxo.value));
23
+ export interface CoinSelectionRecipient {
24
+ address: string;
25
+ amount: Money;
26
+ }
27
+
28
+ export interface DetermineUtxosForSpendArgs {
29
+ feeRate: number;
30
+ recipients: CoinSelectionRecipient[];
31
+ utxos: CoinSelectionUtxo[];
33
32
  }
34
33
 
35
34
  export function determineUtxosForSpendAll({
@@ -61,7 +60,7 @@ export function determineUtxosForSpendAll({
61
60
  inputs: filteredUtxos,
62
61
  outputs,
63
62
  size: sizeInfo.txVBytes,
64
- fee,
63
+ fee: createMoney(new BigNumber(fee), 'BTC'),
65
64
  };
66
65
  }
67
66
 
@@ -75,12 +74,12 @@ export function determineUtxosForSpend({ feeRate, recipients, utxos }: Determine
75
74
  feeRate,
76
75
  recipients,
77
76
  });
78
- if (!filteredUtxos.length) throw new InsufficientFundsError();
77
+ if (!filteredUtxos.length) throw new BitcoinError(BitcoinErrorMessage.InsufficientFunds);
79
78
 
80
79
  const amount = sumMoney(recipients.map(recipient => recipient.amount));
81
80
 
82
- // Prepopulate with first UTXO, at least one is needed
83
- const neededUtxos: Utxo[] = [filteredUtxos[0]];
81
+ // Prepopulate with first utxo, at least one is needed
82
+ const neededUtxos: CoinSelectionUtxo[] = [filteredUtxos[0]];
84
83
 
85
84
  function estimateTransactionSize() {
86
85
  return getSizeInfo({
@@ -101,7 +100,7 @@ export function determineUtxosForSpend({ feeRate, recipients, utxos }: Determine
101
100
 
102
101
  while (!hasSufficientUtxosForTx()) {
103
102
  const [nextUtxo] = getRemainingUnspentUtxos();
104
- if (!nextUtxo) throw new InsufficientFundsError();
103
+ if (!nextUtxo) throw new BitcoinError(BitcoinErrorMessage.InsufficientFunds);
105
104
  neededUtxos.push(nextUtxo);
106
105
  }
107
106
 
@@ -112,7 +111,7 @@ export function determineUtxosForSpend({ feeRate, recipients, utxos }: Determine
112
111
  const changeAmount =
113
112
  BigInt(getUtxoTotal(neededUtxos).toString()) - BigInt(amount.amount.toNumber()) - BigInt(fee);
114
113
 
115
- const changeUtxos: Output[] =
114
+ const changeUtxos: CoinSelectionOutput[] =
116
115
  changeAmount > BTC_P2WPKH_DUST_AMOUNT
117
116
  ? [
118
117
  {
@@ -121,7 +120,7 @@ export function determineUtxosForSpend({ feeRate, recipients, utxos }: Determine
121
120
  ]
122
121
  : [];
123
122
 
124
- const outputs: Output[] = [
123
+ const outputs: CoinSelectionOutput[] = [
125
124
  ...recipients.map(({ address, amount }) => ({
126
125
  value: BigInt(amount.amount.toNumber()),
127
126
  address,
@@ -134,7 +133,7 @@ export function determineUtxosForSpend({ feeRate, recipients, utxos }: Determine
134
133
  inputs: neededUtxos,
135
134
  outputs,
136
135
  size: estimateTransactionSize().txVBytes,
137
- fee,
136
+ fee: createMoney(new BigNumber(fee), 'BTC'),
138
137
  ...estimateTransactionSize(),
139
138
  };
140
139
  }
@@ -6,7 +6,7 @@ import { filterUneconomicalUtxos } from './coin-selection.utils';
6
6
  describe(filterUneconomicalUtxos.name, () => {
7
7
  const recipients = [
8
8
  {
9
- address: '',
9
+ address: 'tb1qt28eagxcl9gvhq2rpj5slg7dwgxae2dn2hk93m',
10
10
  amount: createMoney(0, 'BTC'),
11
11
  },
12
12
  ];
@@ -2,23 +2,18 @@ import BigNumber from 'bignumber.js';
2
2
  import validate, { AddressInfo, AddressType, getAddressInfo } from 'bitcoin-address-validation';
3
3
 
4
4
  import { BTC_P2WPKH_DUST_AMOUNT } from '@leather.io/constants';
5
- import { Money } from '@leather.io/models';
5
+ import { sumNumbers } from '@leather.io/utils';
6
6
 
7
7
  import { BtcSizeFeeEstimator } from '../btc-size-fee-estimator';
8
+ import { CoinSelectionRecipient, CoinSelectionUtxo } from './coin-selection';
8
9
 
9
- export interface TransferRecipient {
10
- address: string;
11
- amount: Money;
12
- }
13
-
14
- export interface Utxo extends Record<string, any> {
15
- txid: string;
16
- value: number;
10
+ export function getUtxoTotal(utxos: CoinSelectionUtxo[]) {
11
+ return sumNumbers(utxos.map(utxo => utxo.value));
17
12
  }
18
13
 
19
14
  export function getSizeInfo(payload: {
20
15
  inputLength: number;
21
- recipients: TransferRecipient[];
16
+ recipients: CoinSelectionRecipient[];
22
17
  isSendMax?: boolean;
23
18
  }) {
24
19
  const { inputLength, recipients, isSendMax } = payload;
@@ -68,11 +63,13 @@ export function getSpendableAmount({
68
63
  feeRate,
69
64
  recipients,
70
65
  }: {
71
- utxos: Utxo[];
66
+ utxos: CoinSelectionUtxo[];
72
67
  feeRate: number;
73
- recipients: TransferRecipient[];
68
+ recipients: CoinSelectionRecipient[];
74
69
  }) {
75
- const balance = utxos.map(utxo => utxo.value).reduce((prevVal, curVal) => prevVal + curVal, 0);
70
+ const balance = utxos
71
+ .map(utxo => Number(utxo.value))
72
+ .reduce((prevVal, curVal) => prevVal + curVal, 0);
76
73
 
77
74
  const size = getSizeInfo({
78
75
  inputLength: utxos.length,
@@ -92,9 +89,9 @@ export function filterUneconomicalUtxos({
92
89
  feeRate,
93
90
  recipients,
94
91
  }: {
95
- utxos: Utxo[];
92
+ utxos: CoinSelectionUtxo[];
96
93
  feeRate: number;
97
- recipients: TransferRecipient[];
94
+ recipients: CoinSelectionRecipient[];
98
95
  }) {
99
96
  const { spendableAmount: fullSpendableAmount } = getSpendableAmount({
100
97
  utxos,
@@ -111,7 +108,7 @@ export function filterUneconomicalUtxos({
111
108
  feeRate,
112
109
  recipients,
113
110
  });
114
- // If fullSpendableAmount is greater, do not use that utxo
111
+ // If fullSpendableAmount is greater, do not use utxo
115
112
  return spendableAmount.toNumber() < fullSpendableAmount.toNumber();
116
113
  });
117
114
  return filteredUtxos;
@@ -0,0 +1,90 @@
1
+ import BigNumber from 'bignumber.js';
2
+
3
+ import { AverageBitcoinFeeRates } from '@leather.io/models';
4
+ import { createMoney } from '@leather.io/utils';
5
+
6
+ import { CoinSelectionRecipient, CoinSelectionUtxo } from '../coin-selection/coin-selection';
7
+ import { getBitcoinFees, getBitcoinTransactionFee } from './bitcoin-fees';
8
+
9
+ describe('getBitcoinTransactionFee', () => {
10
+ it('should return the fee for a normal transaction', () => {
11
+ const args = {
12
+ recipients: [
13
+ { address: 'tb1qsqncyhhqdtfn07t3dhupx7smv5gk83ds6k0gfa', amount: createMoney(1000, 'BTC') },
14
+ ],
15
+ utxos: [
16
+ {
17
+ address: 'tb1qt28eagxcl9gvhq2rpj5slg7dwgxae2dn2hk93m',
18
+ txid: '8192e8e20088c5f052fc7351b86b8f60a9454937860b281227e53e19f3e9c3f6',
19
+ vout: 0,
20
+ value: 2000,
21
+ },
22
+ ],
23
+ feeRate: 1,
24
+ };
25
+ const fee = getBitcoinTransactionFee(args);
26
+ const expectedFee = createMoney(141, 'BTC');
27
+ expect(fee).toEqual(expectedFee);
28
+ });
29
+
30
+ it('should return the fee for a max send transaction', () => {
31
+ const args = {
32
+ isSendingMax: true,
33
+ recipients: [
34
+ { address: 'tb1qsqncyhhqdtfn07t3dhupx7smv5gk83ds6k0gfa', amount: createMoney(2000, 'BTC') },
35
+ ],
36
+ utxos: [
37
+ {
38
+ address: 'tb1qt28eagxcl9gvhq2rpj5slg7dwgxae2dn2hk93m',
39
+ txid: '8192e8e20088c5f052fc7351b86b8f60a9454937860b281227e53e19f3e9c3f6',
40
+ vout: 0,
41
+ value: 2000,
42
+ },
43
+ ],
44
+ feeRate: 2,
45
+ };
46
+ const fee = getBitcoinTransactionFee(args);
47
+ const expectedFee = createMoney(219, 'BTC');
48
+ expect(fee).toEqual(expectedFee);
49
+ });
50
+
51
+ it('should return null if an error occurs', () => {
52
+ const args = {
53
+ recipients: [],
54
+ utxos: [],
55
+ feeRate: 1,
56
+ };
57
+ const fee = getBitcoinTransactionFee(args);
58
+ expect(fee).toBeNull();
59
+ });
60
+ });
61
+
62
+ describe('getBitcoinFees', () => {
63
+ it('should return the fees for different fee rates', () => {
64
+ const feeRates: AverageBitcoinFeeRates = {
65
+ fastestFee: new BigNumber(3),
66
+ halfHourFee: new BigNumber(2),
67
+ hourFee: new BigNumber(1),
68
+ };
69
+ const recipients: CoinSelectionRecipient[] = [
70
+ { address: 'tb1qsqncyhhqdtfn07t3dhupx7smv5gk83ds6k0gfa', amount: createMoney(1000, 'BTC') },
71
+ ];
72
+ const utxos: CoinSelectionUtxo[] = [
73
+ {
74
+ address: 'tb1qt28eagxcl9gvhq2rpj5slg7dwgxae2dn2hk93m',
75
+ txid: '8192e8e20088c5f052fc7351b86b8f60a9454937860b281227e53e19f3e9c3f6',
76
+ vout: 0,
77
+ value: 2000,
78
+ },
79
+ ];
80
+
81
+ const fees = getBitcoinFees({ feeRates, recipients, utxos });
82
+ const expectedFees = {
83
+ high: { feeRate: 3, fee: createMoney(422, 'BTC') },
84
+ standard: { feeRate: 2, fee: createMoney(281, 'BTC') },
85
+ low: { feeRate: 1, fee: createMoney(141, 'BTC') },
86
+ };
87
+
88
+ expect(fees).toEqual(expectedFees);
89
+ });
90
+ });
@@ -0,0 +1,68 @@
1
+ import { AverageBitcoinFeeRates, Money } from '@leather.io/models';
2
+
3
+ import {
4
+ CoinSelectionRecipient,
5
+ CoinSelectionUtxo,
6
+ DetermineUtxosForSpendArgs,
7
+ determineUtxosForSpend,
8
+ determineUtxosForSpendAll,
9
+ } from '../coin-selection/coin-selection';
10
+
11
+ type GetBitcoinTransactionFeeArgs = DetermineUtxosForSpendArgs & {
12
+ isSendingMax?: boolean;
13
+ };
14
+
15
+ export function getBitcoinTransactionFee({ isSendingMax, ...props }: GetBitcoinTransactionFeeArgs) {
16
+ try {
17
+ const { fee } = isSendingMax
18
+ ? determineUtxosForSpendAll({ ...props })
19
+ : determineUtxosForSpend({ ...props });
20
+ return fee;
21
+ } catch (error) {
22
+ return null;
23
+ }
24
+ }
25
+
26
+ export interface BitcoinFees {
27
+ blockchain: 'bitcoin';
28
+ high: { fee: Money | null; feeRate: number };
29
+ standard: { fee: Money | null; feeRate: number };
30
+ low: { fee: Money | null; feeRate: number };
31
+ }
32
+
33
+ export interface GetBitcoinFeesArgs {
34
+ feeRates: AverageBitcoinFeeRates;
35
+ isSendingMax?: boolean;
36
+ recipients: CoinSelectionRecipient[];
37
+ utxos: CoinSelectionUtxo[];
38
+ }
39
+ export function getBitcoinFees({ feeRates, isSendingMax, recipients, utxos }: GetBitcoinFeesArgs) {
40
+ const defaultArgs = {
41
+ isSendingMax,
42
+ recipients,
43
+ utxos,
44
+ };
45
+
46
+ const highFeeRate = feeRates.fastestFee.toNumber();
47
+ const standardFeeRate = feeRates.halfHourFee.toNumber();
48
+ const lowFeeRate = feeRates.hourFee.toNumber();
49
+
50
+ const highFeeValue = getBitcoinTransactionFee({
51
+ ...defaultArgs,
52
+ feeRate: highFeeRate,
53
+ });
54
+ const standardFeeValue = getBitcoinTransactionFee({
55
+ ...defaultArgs,
56
+ feeRate: standardFeeRate,
57
+ });
58
+ const lowFeeValue = getBitcoinTransactionFee({
59
+ ...defaultArgs,
60
+ feeRate: lowFeeRate,
61
+ });
62
+
63
+ return {
64
+ high: { feeRate: highFeeRate, fee: highFeeValue },
65
+ standard: { feeRate: standardFeeRate, fee: standardFeeValue },
66
+ low: { feeRate: lowFeeRate, fee: lowFeeValue },
67
+ };
68
+ }
package/src/index.ts CHANGED
@@ -1,5 +1,15 @@
1
1
  export * from './bip322/bip322-utils';
2
2
  export * from './bip322/sign-message-bip322-bitcoinjs';
3
+
4
+ export * from './coin-selection/calculate-max-spend';
5
+ export * from './coin-selection/coin-selection';
6
+ export * from './coin-selection/coin-selection.utils';
7
+
8
+ export * from './fees/bitcoin-fees';
9
+
10
+ export * from './transactions/generate-unsigned-transaction';
11
+
12
+ export * from './bitcoin-error';
3
13
  export * from './bitcoin-signer';
4
14
  export * from './bitcoin.network';
5
15
  export * from './bitcoin.utils';
@@ -0,0 +1,83 @@
1
+ import { describe, expect, it } from 'vitest';
2
+
3
+ import { createMoney } from '@leather.io/utils';
4
+
5
+ import { getBtcSignerLibNetworkConfigByMode } from '../bitcoin.network';
6
+ import {
7
+ GenerateBitcoinUnsignedTransactionArgs,
8
+ generateBitcoinUnsignedTransactionNativeSegwit,
9
+ } from './generate-unsigned-transaction';
10
+
11
+ const mockResult = {
12
+ inputs: [
13
+ {
14
+ address: 'tb1qt28eagxcl9gvhq2rpj5slg7dwgxae2dn2hk93m',
15
+ txid: 'c715ea469c8d794f6dd7e0043148631f69d411c428ef0ab2b04e4528ffe8319f',
16
+ vout: 1,
17
+ value: 200000,
18
+ },
19
+ ],
20
+ fee: createMoney(141, 'BTC'),
21
+ };
22
+
23
+ describe('generateBitcoinUnsignedTransactionNativeSegwit', () => {
24
+ const mockArgs: GenerateBitcoinUnsignedTransactionArgs = {
25
+ feeRate: 1,
26
+ isSendingMax: false,
27
+ network: getBtcSignerLibNetworkConfigByMode('testnet'),
28
+ payerAddress: 'tb1qxy5r9rlmpcxgwp92x2594q3gg026y4kdv2rsl8',
29
+ payerPublicKey: '0329b076bc20f7b1592b2a1a5cb91dfefe8c966e50e256458e23dd2c5d63f8f1af',
30
+ recipients: [
31
+ {
32
+ address: 'tb1qsqncyhhqdtfn07t3dhupx7smv5gk83ds6k0gfa',
33
+ amount: createMoney(150000, 'BTC'),
34
+ },
35
+ ],
36
+ utxos: [
37
+ {
38
+ address: 'tb1qt28eagxcl9gvhq2rpj5slg7dwgxae2dn2hk93m',
39
+ txid: '8192e8e20088c5f052fc7351b86b8f60a9454937860b281227e53e19f3e9c3f6',
40
+ vout: 0,
41
+ value: 100000,
42
+ },
43
+ {
44
+ address: 'tb1qt28eagxcl9gvhq2rpj5slg7dwgxae2dn2hk93m',
45
+ txid: 'c715ea469c8d794f6dd7e0043148631f69d411c428ef0ab2b04e4528ffe8319f',
46
+ vout: 1,
47
+ value: 200000,
48
+ },
49
+ ],
50
+ };
51
+
52
+ it('should generate an unsigned transaction with correct inputs and outputs', async () => {
53
+ const result = await generateBitcoinUnsignedTransactionNativeSegwit(mockArgs);
54
+ if (result) {
55
+ expect(result.inputs).toEqual(mockResult.inputs);
56
+ expect(result.fee).toEqual(mockResult.fee);
57
+ expect(result.hex).toBeDefined();
58
+ expect(result.psbt).toBeDefined();
59
+ }
60
+ });
61
+
62
+ it('should add change address to output correctly', async () => {
63
+ const result = await generateBitcoinUnsignedTransactionNativeSegwit(mockArgs);
64
+
65
+ if (result) {
66
+ expect(result.tx.outputsLength).toBe(2);
67
+ expect(result.tx.getOutput(1).script).toBeDefined();
68
+ }
69
+ });
70
+
71
+ it('should throw an error if inputs are empty', async () => {
72
+ const argsWithNoInputs: GenerateBitcoinUnsignedTransactionArgs = {
73
+ ...mockArgs,
74
+ utxos: [],
75
+ };
76
+
77
+ const result = await generateBitcoinUnsignedTransactionNativeSegwit(argsWithNoInputs);
78
+
79
+ if (result) {
80
+ expect(result.inputs.length).toBe(0);
81
+ }
82
+ });
83
+ });
@@ -0,0 +1,71 @@
1
+ import { hexToBytes } from '@noble/hashes/utils';
2
+ import * as btc from '@scure/btc-signer';
3
+
4
+ import { BitcoinError, BitcoinErrorMessage } from '../bitcoin-error';
5
+ import { BtcSignerNetwork } from '../bitcoin.network';
6
+ import {
7
+ CoinSelectionRecipient,
8
+ CoinSelectionUtxo,
9
+ determineUtxosForSpend,
10
+ determineUtxosForSpendAll,
11
+ } from '../coin-selection/coin-selection';
12
+
13
+ export interface GenerateBitcoinUnsignedTransactionArgs {
14
+ feeRate: number;
15
+ isSendingMax?: boolean;
16
+ payerAddress: string;
17
+ payerPublicKey: string;
18
+ network: BtcSignerNetwork;
19
+ recipients: CoinSelectionRecipient[];
20
+ utxos: CoinSelectionUtxo[];
21
+ }
22
+
23
+ export async function generateBitcoinUnsignedTransactionNativeSegwit({
24
+ feeRate,
25
+ isSendingMax,
26
+ payerAddress,
27
+ payerPublicKey,
28
+ network,
29
+ recipients,
30
+ utxos,
31
+ }: GenerateBitcoinUnsignedTransactionArgs) {
32
+ try {
33
+ const determineUtxosArgs = { feeRate, recipients, utxos };
34
+ const { inputs, outputs, fee } = isSendingMax
35
+ ? determineUtxosForSpendAll(determineUtxosArgs)
36
+ : determineUtxosForSpend(determineUtxosArgs);
37
+
38
+ if (!inputs.length) throw new BitcoinError(BitcoinErrorMessage.NoInputsToSign);
39
+ if (!outputs.length) throw new BitcoinError(BitcoinErrorMessage.NoOutputsToSign);
40
+
41
+ const tx = new btc.Transaction();
42
+ const p2wpkh = btc.p2wpkh(hexToBytes(payerPublicKey), network);
43
+
44
+ for (const input of inputs) {
45
+ tx.addInput({
46
+ txid: input.txid,
47
+ index: input.vout,
48
+ sequence: 0,
49
+ witnessUtxo: {
50
+ // script = 0014 + pubKeyHash
51
+ script: p2wpkh.script,
52
+ amount: BigInt(input.value),
53
+ },
54
+ });
55
+ }
56
+
57
+ outputs.forEach(output => {
58
+ // When coin selection returns an output with no address,
59
+ // we assume it is a change output
60
+ if (!output.address) {
61
+ tx.addOutputAddress(payerAddress, BigInt(output.value), network);
62
+ return;
63
+ }
64
+ tx.addOutputAddress(output.address, BigInt(output.value), network);
65
+ });
66
+
67
+ return { tx, hex: tx.hex, psbt: tx.toPSBT(), inputs, fee };
68
+ } catch (e) {
69
+ return null;
70
+ }
71
+ }