@leather.io/bitcoin 0.36.6 → 0.37.1
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.
- package/.turbo/turbo-build.log +7 -7
- package/CHANGELOG.md +37 -0
- package/dist/index.d.ts +233 -170
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +47 -10
- package/dist/index.js.map +1 -1
- package/package.json +7 -7
- package/src/coin-selection/calculate-max-spend.spec.ts +125 -6
- package/src/coin-selection/calculate-max-spend.ts +6 -2
- package/src/coin-selection/coin-selection.mocks.ts +19 -0
- package/src/coin-selection/coin-selection.spec.ts +161 -34
- package/src/coin-selection/coin-selection.ts +10 -5
- package/src/coin-selection/coin-selection.utils.spec.ts +113 -2
- package/src/coin-selection/coin-selection.utils.ts +42 -12
- package/src/fees/bitcoin-fees.spec.ts +71 -3
- package/src/fees/bitcoin-fees.ts +4 -2
- package/src/fees/btc-size-fee-estimator.spec.ts +74 -0
- package/src/fees/btc-size-fee-estimator.ts +64 -0
- package/src/index.ts +1 -0
- package/src/transactions/generate-unsigned-transaction.spec.ts +351 -13
- package/src/transactions/generate-unsigned-transaction.ts +2 -1
- package/src/utils/bitcoin.utils.ts +8 -0
package/package.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"name": "@leather.io/bitcoin",
|
|
3
3
|
"author": "Leather.io contact@leather.io",
|
|
4
4
|
"description": "Shared bitcoin utilities",
|
|
5
|
-
"version": "0.
|
|
5
|
+
"version": "0.37.1",
|
|
6
6
|
"license": "MIT",
|
|
7
7
|
"homepage": "https://github.com/leather.io/mono/tree/dev/packages/bitcoin",
|
|
8
8
|
"repository": {
|
|
@@ -32,10 +32,10 @@
|
|
|
32
32
|
"just-memoize": "2.2.0",
|
|
33
33
|
"varuint-bitcoin": "1.1.2",
|
|
34
34
|
"zod": "4.0.17",
|
|
35
|
-
"@leather.io/
|
|
36
|
-
"@leather.io/
|
|
37
|
-
"@leather.io/
|
|
38
|
-
"@leather.io/
|
|
35
|
+
"@leather.io/crypto": "1.12.18",
|
|
36
|
+
"@leather.io/constants": "0.33.0",
|
|
37
|
+
"@leather.io/models": "0.54.0",
|
|
38
|
+
"@leather.io/utils": "0.50.1"
|
|
39
39
|
},
|
|
40
40
|
"devDependencies": {
|
|
41
41
|
"prettier": "3.5.1",
|
|
@@ -44,9 +44,9 @@
|
|
|
44
44
|
"typescript": "5.9.3",
|
|
45
45
|
"vitest": "2.1.9",
|
|
46
46
|
"@leather.io/prettier-config": "0.9.0",
|
|
47
|
-
"@leather.io/
|
|
47
|
+
"@leather.io/rpc": "2.21.12",
|
|
48
48
|
"@leather.io/tsconfig-config": "0.11.1",
|
|
49
|
-
"@leather.io/
|
|
49
|
+
"@leather.io/test-config": "0.1.3"
|
|
50
50
|
},
|
|
51
51
|
"keywords": [
|
|
52
52
|
"bitcoin",
|
|
@@ -1,16 +1,28 @@
|
|
|
1
|
+
import { createMoney } from '@leather.io/utils';
|
|
2
|
+
|
|
3
|
+
import { recipientAddress, taprootAddress } from '../mocks/mocks';
|
|
1
4
|
import { createBitcoinAddress } from '../validation/bitcoin-address';
|
|
2
5
|
import { calculateMaxSpend } from './calculate-max-spend';
|
|
3
|
-
import { mockUtxos } from './coin-selection.mocks';
|
|
6
|
+
import { mockTaprootUtxos, mockUtxos } from './coin-selection.mocks';
|
|
7
|
+
import { getSizeInfo } from './coin-selection.utils';
|
|
4
8
|
|
|
5
9
|
const recipient = createBitcoinAddress('');
|
|
6
10
|
describe(calculateMaxSpend.name, () => {
|
|
11
|
+
test('that empty utxos returns zero', () => {
|
|
12
|
+
const result = calculateMaxSpend({ recipient: recipientAddress, utxos: [], feeRate: 1 });
|
|
13
|
+
expect(result.spendAllFee).toEqual(0);
|
|
14
|
+
expect(result.amount.amount.toNumber()).toEqual(0);
|
|
15
|
+
expect(result.spendableBitcoin.toNumber()).toEqual(0);
|
|
16
|
+
});
|
|
17
|
+
|
|
7
18
|
test('with 1 sat/vb fee', () => {
|
|
8
19
|
const maxBitcoinSpend = calculateMaxSpend({
|
|
9
20
|
recipient,
|
|
10
21
|
utxos: mockUtxos,
|
|
11
22
|
feeRate: 1,
|
|
12
23
|
});
|
|
13
|
-
expect(maxBitcoinSpend.amount.amount.toNumber()).toEqual(
|
|
24
|
+
expect(maxBitcoinSpend.amount.amount.toNumber()).toEqual(50087979);
|
|
25
|
+
expect(maxBitcoinSpend.spendAllFee).toEqual(621);
|
|
14
26
|
});
|
|
15
27
|
|
|
16
28
|
test('with 5 sat/vb fee', () => {
|
|
@@ -19,7 +31,8 @@ describe(calculateMaxSpend.name, () => {
|
|
|
19
31
|
utxos: mockUtxos,
|
|
20
32
|
feeRate: 5,
|
|
21
33
|
});
|
|
22
|
-
expect(maxBitcoinSpend.amount.amount.toNumber()).toEqual(
|
|
34
|
+
expect(maxBitcoinSpend.amount.amount.toNumber()).toEqual(50085497);
|
|
35
|
+
expect(maxBitcoinSpend.spendAllFee).toEqual(3103);
|
|
23
36
|
});
|
|
24
37
|
|
|
25
38
|
test('with 30 sat/vb fee', () => {
|
|
@@ -28,7 +41,8 @@ describe(calculateMaxSpend.name, () => {
|
|
|
28
41
|
utxos: mockUtxos,
|
|
29
42
|
feeRate: 30,
|
|
30
43
|
});
|
|
31
|
-
expect(maxBitcoinSpend.amount.amount.toNumber()).toEqual(
|
|
44
|
+
expect(maxBitcoinSpend.amount.amount.toNumber()).toEqual(50074515);
|
|
45
|
+
expect(maxBitcoinSpend.spendAllFee).toEqual(10485);
|
|
32
46
|
});
|
|
33
47
|
|
|
34
48
|
test('with 100 sat/vb fee', () => {
|
|
@@ -37,7 +51,8 @@ describe(calculateMaxSpend.name, () => {
|
|
|
37
51
|
utxos: mockUtxos,
|
|
38
52
|
feeRate: 100,
|
|
39
53
|
});
|
|
40
|
-
expect(maxBitcoinSpend.amount.amount.toNumber()).toEqual(
|
|
54
|
+
expect(maxBitcoinSpend.amount.amount.toNumber()).toEqual(50050050);
|
|
55
|
+
expect(maxBitcoinSpend.spendAllFee).toEqual(34950);
|
|
41
56
|
});
|
|
42
57
|
|
|
43
58
|
test('with 400 sat/vb fee', () => {
|
|
@@ -46,6 +61,110 @@ describe(calculateMaxSpend.name, () => {
|
|
|
46
61
|
utxos: mockUtxos,
|
|
47
62
|
feeRate: 400,
|
|
48
63
|
});
|
|
49
|
-
expect(maxBitcoinSpend.amount.amount.toNumber()).toEqual(
|
|
64
|
+
expect(maxBitcoinSpend.amount.amount.toNumber()).toEqual(49981500);
|
|
65
|
+
expect(maxBitcoinSpend.spendAllFee).toEqual(58500);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
describe('with taproot UTXOs', () => {
|
|
69
|
+
const taprootRecipient = recipientAddress;
|
|
70
|
+
|
|
71
|
+
test('with 1 sat/vb fee', () => {
|
|
72
|
+
const result = calculateMaxSpend({
|
|
73
|
+
recipient: taprootRecipient,
|
|
74
|
+
utxos: mockTaprootUtxos,
|
|
75
|
+
feeRate: 1,
|
|
76
|
+
});
|
|
77
|
+
expect(result.amount.amount.toNumber()).toEqual(50088043);
|
|
78
|
+
expect(result.spendAllFee).toEqual(557);
|
|
79
|
+
expect(result.spendableBitcoin.toNumber()).toEqual(0.50088043);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test('with 5 sat/vb fee', () => {
|
|
83
|
+
const result = calculateMaxSpend({
|
|
84
|
+
recipient: taprootRecipient,
|
|
85
|
+
utxos: mockTaprootUtxos,
|
|
86
|
+
feeRate: 5,
|
|
87
|
+
});
|
|
88
|
+
expect(result.amount.amount.toNumber()).toEqual(50085815);
|
|
89
|
+
expect(result.spendAllFee).toEqual(2785);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test('with 30 sat/vb fee', () => {
|
|
93
|
+
const result = calculateMaxSpend({
|
|
94
|
+
recipient: taprootRecipient,
|
|
95
|
+
utxos: mockTaprootUtxos,
|
|
96
|
+
feeRate: 30,
|
|
97
|
+
});
|
|
98
|
+
expect(result.amount.amount.toNumber()).toEqual(50075160);
|
|
99
|
+
expect(result.spendAllFee).toEqual(9840);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
test('with 100 sat/vb fee', () => {
|
|
103
|
+
const result = calculateMaxSpend({
|
|
104
|
+
recipient: taprootRecipient,
|
|
105
|
+
utxos: mockTaprootUtxos,
|
|
106
|
+
feeRate: 100,
|
|
107
|
+
});
|
|
108
|
+
expect(result.amount.amount.toNumber()).toEqual(50052200);
|
|
109
|
+
expect(result.spendAllFee).toEqual(32800);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
test('with 400 sat/vb fee', () => {
|
|
113
|
+
const result = calculateMaxSpend({
|
|
114
|
+
recipient: taprootRecipient,
|
|
115
|
+
utxos: mockTaprootUtxos,
|
|
116
|
+
feeRate: 400,
|
|
117
|
+
});
|
|
118
|
+
expect(result.amount.amount.toNumber()).toEqual(49979600);
|
|
119
|
+
expect(result.spendAllFee).toEqual(85400);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
test('taproot inputs have lower fees than native segwit at same fee rate', () => {
|
|
123
|
+
const segwitResult = calculateMaxSpend({
|
|
124
|
+
recipient: taprootRecipient,
|
|
125
|
+
utxos: mockUtxos,
|
|
126
|
+
feeRate: 30,
|
|
127
|
+
});
|
|
128
|
+
const taprootResult = calculateMaxSpend({
|
|
129
|
+
recipient: taprootRecipient,
|
|
130
|
+
utxos: mockTaprootUtxos,
|
|
131
|
+
feeRate: 30,
|
|
132
|
+
});
|
|
133
|
+
expect(taprootResult.spendAllFee).toBeLessThan(segwitResult.spendAllFee);
|
|
134
|
+
});
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
test('send-max fee should not include a change output', () => {
|
|
138
|
+
const feeRate = 10;
|
|
139
|
+
const utxos = mockTaprootUtxos;
|
|
140
|
+
|
|
141
|
+
const result = calculateMaxSpend({ recipient: recipientAddress, utxos, feeRate });
|
|
142
|
+
|
|
143
|
+
const correctSize = getSizeInfo({
|
|
144
|
+
utxos,
|
|
145
|
+
recipients: [{ address: recipientAddress, amount: createMoney(0, 'BTC') }],
|
|
146
|
+
isSendMax: true,
|
|
147
|
+
});
|
|
148
|
+
const correctFee = Math.ceil(correctSize.txVBytes * feeRate);
|
|
149
|
+
|
|
150
|
+
expect(result.spendAllFee).toEqual(correctFee);
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
describe('with taproot recipient', () => {
|
|
154
|
+
test('P2TR output costs more than P2WPKH output', () => {
|
|
155
|
+
const segwitRecipientResult = calculateMaxSpend({
|
|
156
|
+
recipient: recipientAddress,
|
|
157
|
+
utxos: mockUtxos,
|
|
158
|
+
feeRate: 30,
|
|
159
|
+
});
|
|
160
|
+
const taprootRecipientResult = calculateMaxSpend({
|
|
161
|
+
recipient: taprootAddress,
|
|
162
|
+
utxos: mockUtxos,
|
|
163
|
+
feeRate: 30,
|
|
164
|
+
});
|
|
165
|
+
expect(segwitRecipientResult.amount.amount.toNumber()).toEqual(50073585);
|
|
166
|
+
expect(taprootRecipientResult.amount.amount.toNumber()).toEqual(50073225);
|
|
167
|
+
expect(taprootRecipientResult.spendAllFee).toBeGreaterThan(segwitRecipientResult.spendAllFee);
|
|
168
|
+
});
|
|
50
169
|
});
|
|
51
170
|
});
|
|
@@ -3,11 +3,15 @@ import BigNumber from 'bignumber.js';
|
|
|
3
3
|
import type { Money } from '@leather.io/models';
|
|
4
4
|
import { createMoney, satToBtc } from '@leather.io/utils';
|
|
5
5
|
|
|
6
|
-
import {
|
|
6
|
+
import {
|
|
7
|
+
type InputData,
|
|
8
|
+
filterUneconomicalUtxos,
|
|
9
|
+
getSpendableAmount,
|
|
10
|
+
} from './coin-selection.utils';
|
|
7
11
|
|
|
8
12
|
interface CalculateMaxSpendParams {
|
|
9
13
|
recipient: string;
|
|
10
|
-
utxos:
|
|
14
|
+
utxos: InputData[];
|
|
11
15
|
feeRate: number;
|
|
12
16
|
}
|
|
13
17
|
|
|
@@ -37,4 +37,23 @@ export const mockUtxos = generateMockTransactions([
|
|
|
37
37
|
600, 600, 1200, 1200, 10000, 10000, 25000, 40000, 50000000,
|
|
38
38
|
]);
|
|
39
39
|
|
|
40
|
+
function generateMockTaprootUtxo(value: number): OwnedUtxo {
|
|
41
|
+
return {
|
|
42
|
+
address: 'tb1parwmj7533de3k2fw2kntyqacspvhm67qnjcmpqnnpfvzu05l69nsczdywd',
|
|
43
|
+
path: `m/86'/1'/0'/0/0`,
|
|
44
|
+
keyOrigin: `deadbeef/86'/1'/0'/0/0`,
|
|
45
|
+
txid: sha256(sha256(hexToBytes(generateMockHex()))).toString(),
|
|
46
|
+
value,
|
|
47
|
+
vout: 0,
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function generateMockTaprootTransactions(values: number[]) {
|
|
52
|
+
return values.map(val => generateMockTaprootUtxo(val));
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export const mockTaprootUtxos = generateMockTaprootTransactions([
|
|
56
|
+
600, 600, 1200, 1200, 10000, 10000, 25000, 40000, 50000000,
|
|
57
|
+
]);
|
|
58
|
+
|
|
40
59
|
export const mockAverageFee = generateMockAverageFee(10);
|
|
@@ -10,22 +10,23 @@ import {
|
|
|
10
10
|
} from '../mocks/mocks';
|
|
11
11
|
import { isBitcoinAddress } from '../validation/bitcoin-address';
|
|
12
12
|
import { determineUtxosForSpend, determineUtxosForSpendAll } from './coin-selection';
|
|
13
|
+
import { generateMockTaprootTransactions, generateMockTransactions } from './coin-selection.mocks';
|
|
13
14
|
import { filterUneconomicalUtxos, getSizeInfo } from './coin-selection.utils';
|
|
14
15
|
|
|
15
16
|
const demoUtxos = [
|
|
16
|
-
{ value: 8200 },
|
|
17
|
-
{ value: 8490 },
|
|
18
|
-
{ value: 8790 },
|
|
19
|
-
{ value: 19 },
|
|
20
|
-
{ value: 2000 },
|
|
21
|
-
{ value: 2340 },
|
|
22
|
-
{ value: 1230 },
|
|
23
|
-
{ value: 120 },
|
|
24
|
-
{ value: 8 },
|
|
25
|
-
{ value: 1002 },
|
|
26
|
-
{ value: 1382 },
|
|
27
|
-
{ value: 1400 },
|
|
28
|
-
{ value: 909 },
|
|
17
|
+
{ txid: 'a1', value: 8200, address: recipientAddress },
|
|
18
|
+
{ txid: 'a2', value: 8490, address: recipientAddress },
|
|
19
|
+
{ txid: 'a3', value: 8790, address: recipientAddress },
|
|
20
|
+
{ txid: 'a4', value: 19, address: recipientAddress },
|
|
21
|
+
{ txid: 'a5', value: 2000, address: recipientAddress },
|
|
22
|
+
{ txid: 'a6', value: 2340, address: recipientAddress },
|
|
23
|
+
{ txid: 'a7', value: 1230, address: recipientAddress },
|
|
24
|
+
{ txid: 'a8', value: 120, address: recipientAddress },
|
|
25
|
+
{ txid: 'a9', value: 8, address: recipientAddress },
|
|
26
|
+
{ txid: 'a10', value: 1002, address: recipientAddress },
|
|
27
|
+
{ txid: 'a11', value: 1382, address: recipientAddress },
|
|
28
|
+
{ txid: 'a12', value: 1400, address: recipientAddress },
|
|
29
|
+
{ txid: 'a13', value: 909, address: recipientAddress },
|
|
29
30
|
];
|
|
30
31
|
|
|
31
32
|
function generate10kSpendWithDummyUtxoSet(recipient: string) {
|
|
@@ -33,7 +34,7 @@ function generate10kSpendWithDummyUtxoSet(recipient: string) {
|
|
|
33
34
|
throw new Error('Invalid Bitcoin address');
|
|
34
35
|
}
|
|
35
36
|
return determineUtxosForSpend({
|
|
36
|
-
utxos: demoUtxos
|
|
37
|
+
utxos: demoUtxos,
|
|
37
38
|
feeRate: 20,
|
|
38
39
|
recipients: [{ address: recipient, amount: createMoney(10_000, 'BTC') }],
|
|
39
40
|
});
|
|
@@ -43,7 +44,7 @@ describe(determineUtxosForSpend.name, () => {
|
|
|
43
44
|
describe('Estimated size', () => {
|
|
44
45
|
test('that Native Segwit, 1 input 2 outputs weighs 140 vBytes', () => {
|
|
45
46
|
const estimation = determineUtxosForSpend({
|
|
46
|
-
utxos: [{ value: 50_000
|
|
47
|
+
utxos: [{ txid: 'b1', value: 50_000, address: recipientAddress }],
|
|
47
48
|
recipients: [
|
|
48
49
|
{
|
|
49
50
|
address: recipientAddress,
|
|
@@ -58,7 +59,10 @@ describe(determineUtxosForSpend.name, () => {
|
|
|
58
59
|
|
|
59
60
|
test('that Native Segwit, 2 input 2 outputs weighs 200vBytes', () => {
|
|
60
61
|
const estimation = determineUtxosForSpend({
|
|
61
|
-
utxos: [
|
|
62
|
+
utxos: [
|
|
63
|
+
{ txid: 'c1', value: 50_000, address: recipientAddress },
|
|
64
|
+
{ txid: 'c2', value: 50_000, address: recipientAddress },
|
|
65
|
+
],
|
|
62
66
|
recipients: [
|
|
63
67
|
{
|
|
64
68
|
address: recipientAddress,
|
|
@@ -74,17 +78,17 @@ describe(determineUtxosForSpend.name, () => {
|
|
|
74
78
|
test('that Native Segwit, 10 input 2 outputs weighs 200vBytes', () => {
|
|
75
79
|
const estimation = determineUtxosForSpend({
|
|
76
80
|
utxos: [
|
|
77
|
-
{ value: 20_000 },
|
|
78
|
-
{ value: 20_000 },
|
|
79
|
-
{ value: 10_000 },
|
|
80
|
-
{ value: 10_000 },
|
|
81
|
-
{ value: 10_000 },
|
|
82
|
-
{ value: 10_000 },
|
|
83
|
-
{ value: 10_000 },
|
|
84
|
-
{ value: 10_000 },
|
|
85
|
-
{ value: 10_000 },
|
|
86
|
-
{ value: 10_000 },
|
|
87
|
-
]
|
|
81
|
+
{ txid: 'd1', value: 20_000, address: recipientAddress },
|
|
82
|
+
{ txid: 'd2', value: 20_000, address: recipientAddress },
|
|
83
|
+
{ txid: 'd3', value: 10_000, address: recipientAddress },
|
|
84
|
+
{ txid: 'd4', value: 10_000, address: recipientAddress },
|
|
85
|
+
{ txid: 'd5', value: 10_000, address: recipientAddress },
|
|
86
|
+
{ txid: 'd6', value: 10_000, address: recipientAddress },
|
|
87
|
+
{ txid: 'd7', value: 10_000, address: recipientAddress },
|
|
88
|
+
{ txid: 'd8', value: 10_000, address: recipientAddress },
|
|
89
|
+
{ txid: 'd9', value: 10_000, address: recipientAddress },
|
|
90
|
+
{ txid: 'd10', value: 10_000, address: recipientAddress },
|
|
91
|
+
],
|
|
88
92
|
recipients: [
|
|
89
93
|
{
|
|
90
94
|
address: recipientAddress,
|
|
@@ -147,12 +151,14 @@ describe(determineUtxosForSpend.name, () => {
|
|
|
147
151
|
});
|
|
148
152
|
|
|
149
153
|
test('against a random set of generated utxos', () => {
|
|
150
|
-
const testData = createNullArrayOfLength(50).map(() => ({
|
|
154
|
+
const testData = createNullArrayOfLength(50).map((_, i) => ({
|
|
155
|
+
txid: `rnd${i}`,
|
|
151
156
|
value: Math.ceil(Math.random() * 10000),
|
|
157
|
+
address: recipientAddress,
|
|
152
158
|
}));
|
|
153
159
|
const amount = 29123n;
|
|
154
160
|
const result = determineUtxosForSpend({
|
|
155
|
-
utxos: testData
|
|
161
|
+
utxos: testData,
|
|
156
162
|
recipients: [
|
|
157
163
|
{
|
|
158
164
|
address: recipientAddress,
|
|
@@ -180,7 +186,7 @@ describe(determineUtxosForSpend.name, () => {
|
|
|
180
186
|
},
|
|
181
187
|
];
|
|
182
188
|
const filteredUtxos = filterUneconomicalUtxos({
|
|
183
|
-
utxos: demoUtxos.sort((a, b) => b.value - a.value)
|
|
189
|
+
utxos: demoUtxos.sort((a, b) => b.value - a.value),
|
|
184
190
|
feeRate,
|
|
185
191
|
recipients,
|
|
186
192
|
});
|
|
@@ -188,7 +194,7 @@ describe(determineUtxosForSpend.name, () => {
|
|
|
188
194
|
recipients[0].amount = createMoney(amount, 'BTC');
|
|
189
195
|
|
|
190
196
|
const result = determineUtxosForSpend({
|
|
191
|
-
utxos: filteredUtxos
|
|
197
|
+
utxos: filteredUtxos,
|
|
192
198
|
recipients: [
|
|
193
199
|
{
|
|
194
200
|
address: recipientAddress,
|
|
@@ -203,7 +209,11 @@ describe(determineUtxosForSpend.name, () => {
|
|
|
203
209
|
});
|
|
204
210
|
|
|
205
211
|
test('that spending all utxos with sendMax does not result in dust utxos', () => {
|
|
206
|
-
const utxos = [
|
|
212
|
+
const utxos = [
|
|
213
|
+
{ txid: '123', value: 1000, address: recipientAddress },
|
|
214
|
+
{ txid: '1323', value: 2000, address: recipientAddress },
|
|
215
|
+
{ txid: '132355', value: 3000, address: recipientAddress },
|
|
216
|
+
];
|
|
207
217
|
const recipients = [
|
|
208
218
|
{
|
|
209
219
|
address: recipientAddress,
|
|
@@ -211,7 +221,7 @@ describe(determineUtxosForSpend.name, () => {
|
|
|
211
221
|
},
|
|
212
222
|
];
|
|
213
223
|
const sizeInfo = getSizeInfo({
|
|
214
|
-
|
|
224
|
+
utxos,
|
|
215
225
|
isSendMax: true,
|
|
216
226
|
recipients,
|
|
217
227
|
});
|
|
@@ -221,7 +231,7 @@ describe(determineUtxosForSpend.name, () => {
|
|
|
221
231
|
recipients[0].amount = createMoney(amount, 'BTC');
|
|
222
232
|
|
|
223
233
|
const result = determineUtxosForSpendAll({
|
|
224
|
-
utxos: utxos
|
|
234
|
+
utxos: utxos,
|
|
225
235
|
recipients,
|
|
226
236
|
feeRate,
|
|
227
237
|
});
|
|
@@ -230,4 +240,121 @@ describe(determineUtxosForSpend.name, () => {
|
|
|
230
240
|
expect(result.fee.amount.isEqualTo(735)).toBeTruthy();
|
|
231
241
|
expect(fee).toEqual(735);
|
|
232
242
|
});
|
|
243
|
+
|
|
244
|
+
test('that spending all taproot utxos with sendMax produces lower fee than native segwit', () => {
|
|
245
|
+
const taprootUtxos = generateMockTaprootTransactions([1000, 2000, 3000]);
|
|
246
|
+
const segwitUtxos = generateMockTransactions([1000, 2000, 3000]);
|
|
247
|
+
const feeRate = 2;
|
|
248
|
+
|
|
249
|
+
const taprootResult = determineUtxosForSpendAll({
|
|
250
|
+
utxos: taprootUtxos,
|
|
251
|
+
recipients: [{ address: recipientAddress, amount: createMoney(5000, 'BTC') }],
|
|
252
|
+
feeRate,
|
|
253
|
+
});
|
|
254
|
+
const segwitResult = determineUtxosForSpendAll({
|
|
255
|
+
utxos: segwitUtxos,
|
|
256
|
+
recipients: [{ address: recipientAddress, amount: createMoney(5000, 'BTC') }],
|
|
257
|
+
feeRate,
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
expect(taprootResult.inputs.length).toEqual(3);
|
|
261
|
+
expect(taprootResult.outputs.length).toEqual(1);
|
|
262
|
+
expect(taprootResult.fee.amount.isLessThan(segwitResult.fee.amount)).toBeTruthy();
|
|
263
|
+
expect(taprootResult.size).toBeGreaterThan(200);
|
|
264
|
+
expect(taprootResult.size).toBeLessThan(220);
|
|
265
|
+
});
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
describe('mixed input types', () => {
|
|
269
|
+
test('that spending from P2TR-only inputs produces correct fee estimation', () => {
|
|
270
|
+
const taprootUtxos = generateMockTaprootTransactions([50_000]);
|
|
271
|
+
const estimation = determineUtxosForSpend({
|
|
272
|
+
utxos: taprootUtxos,
|
|
273
|
+
recipients: [
|
|
274
|
+
{
|
|
275
|
+
address: recipientAddress,
|
|
276
|
+
amount: createMoney(40_000, 'BTC'),
|
|
277
|
+
},
|
|
278
|
+
],
|
|
279
|
+
feeRate: 20,
|
|
280
|
+
});
|
|
281
|
+
expect(estimation.fee.amount.toNumber()).toBeGreaterThan(0);
|
|
282
|
+
expect(estimation.inputs).toHaveLength(1);
|
|
283
|
+
expect(estimation.txVBytes).toBeGreaterThanOrEqual(130);
|
|
284
|
+
expect(estimation.txVBytes).toBeLessThan(155);
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
test('that spending from mixed P2WPKH + P2TR inputs uses correct vBytes', () => {
|
|
288
|
+
const nativeSegwitUtxos = generateMockTransactions([30_000]);
|
|
289
|
+
const taprootUtxos = generateMockTaprootTransactions([30_000]);
|
|
290
|
+
const mixedUtxos = [...nativeSegwitUtxos, ...taprootUtxos];
|
|
291
|
+
|
|
292
|
+
const estimation = determineUtxosForSpend({
|
|
293
|
+
utxos: mixedUtxos,
|
|
294
|
+
recipients: [
|
|
295
|
+
{
|
|
296
|
+
address: recipientAddress,
|
|
297
|
+
amount: createMoney(50_000, 'BTC'),
|
|
298
|
+
},
|
|
299
|
+
],
|
|
300
|
+
feeRate: 20,
|
|
301
|
+
});
|
|
302
|
+
expect(estimation.inputs).toHaveLength(2);
|
|
303
|
+
expect(estimation.fee.amount.toNumber()).toBeGreaterThan(0);
|
|
304
|
+
expect(estimation.txVBytes).toBeGreaterThan(170);
|
|
305
|
+
expect(estimation.txVBytes).toBeLessThan(210);
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
test('that spending all mixed P2WPKH + P2TR utxos includes inputs from both address types', () => {
|
|
309
|
+
const nativeSegwitUtxos = generateMockTransactions([2000, 3000]);
|
|
310
|
+
const taprootUtxos = generateMockTaprootTransactions([2000, 3000]);
|
|
311
|
+
const allTaprootUtxos = generateMockTaprootTransactions([2000, 3000, 2000, 3000]);
|
|
312
|
+
const mixedUtxos = [...nativeSegwitUtxos, ...taprootUtxos];
|
|
313
|
+
const feeRate = 4;
|
|
314
|
+
|
|
315
|
+
const mixedResult = determineUtxosForSpendAll({
|
|
316
|
+
utxos: mixedUtxos,
|
|
317
|
+
recipients: [{ address: recipientAddress, amount: createMoney(8000, 'BTC') }],
|
|
318
|
+
feeRate,
|
|
319
|
+
});
|
|
320
|
+
const allTaprootResult = determineUtxosForSpendAll({
|
|
321
|
+
utxos: allTaprootUtxos,
|
|
322
|
+
recipients: [{ address: recipientAddress, amount: createMoney(8000, 'BTC') }],
|
|
323
|
+
feeRate,
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
expect(mixedResult.inputs.length).toEqual(4);
|
|
327
|
+
expect(mixedResult.outputs.length).toEqual(1);
|
|
328
|
+
|
|
329
|
+
const hasSegwit = mixedResult.inputs.some(u => u.address.startsWith('tb1q'));
|
|
330
|
+
const hasTaproot = mixedResult.inputs.some(u => u.address.startsWith('tb1p'));
|
|
331
|
+
expect(hasSegwit).toBeTruthy();
|
|
332
|
+
expect(hasTaproot).toBeTruthy();
|
|
333
|
+
|
|
334
|
+
expect(mixedResult.size).toBeGreaterThan(280);
|
|
335
|
+
expect(mixedResult.size).toBeLessThan(300);
|
|
336
|
+
expect(mixedResult.fee.amount.isGreaterThan(allTaprootResult.fee.amount)).toBeTruthy();
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
test('that mixed P2WPKH + P2TR inputs are sorted by value descending', () => {
|
|
340
|
+
const nativeSegwitUtxos = generateMockTransactions([15_000, 40_000]);
|
|
341
|
+
const taprootUtxos = generateMockTaprootTransactions([25_000, 30_000]);
|
|
342
|
+
const utxos = [...nativeSegwitUtxos, ...taprootUtxos];
|
|
343
|
+
|
|
344
|
+
const result = determineUtxosForSpend({
|
|
345
|
+
utxos,
|
|
346
|
+
recipients: [
|
|
347
|
+
{
|
|
348
|
+
address: recipientAddress,
|
|
349
|
+
amount: createMoney(50_000, 'BTC'),
|
|
350
|
+
},
|
|
351
|
+
],
|
|
352
|
+
feeRate: 20,
|
|
353
|
+
});
|
|
354
|
+
result.inputs.forEach((input, i) => {
|
|
355
|
+
const nextInput = result.inputs[i + 1];
|
|
356
|
+
if (!nextInput) return;
|
|
357
|
+
expect(input.value >= nextInput.value).toEqual(true);
|
|
358
|
+
});
|
|
359
|
+
});
|
|
233
360
|
});
|
|
@@ -6,7 +6,12 @@ import { Money } from '@leather.io/models';
|
|
|
6
6
|
import { createMoney, sumMoney } from '@leather.io/utils';
|
|
7
7
|
|
|
8
8
|
import { BitcoinError } from '../validation/bitcoin-error';
|
|
9
|
-
import {
|
|
9
|
+
import {
|
|
10
|
+
type InputData,
|
|
11
|
+
filterUneconomicalUtxos,
|
|
12
|
+
getSizeInfo,
|
|
13
|
+
getUtxoTotal,
|
|
14
|
+
} from './coin-selection.utils';
|
|
10
15
|
|
|
11
16
|
export interface CoinSelectionOutput {
|
|
12
17
|
value: bigint;
|
|
@@ -23,7 +28,7 @@ export interface DetermineUtxosForSpendArgs<T> {
|
|
|
23
28
|
recipients: CoinSelectionRecipient[];
|
|
24
29
|
utxos: T[];
|
|
25
30
|
}
|
|
26
|
-
export function determineUtxosForSpendAll<T extends
|
|
31
|
+
export function determineUtxosForSpendAll<T extends InputData>({
|
|
27
32
|
feeRate,
|
|
28
33
|
recipients,
|
|
29
34
|
utxos,
|
|
@@ -36,7 +41,7 @@ export function determineUtxosForSpendAll<T extends { value: number; txid: strin
|
|
|
36
41
|
if (!filteredUtxos.length) throw new BitcoinError('InsufficientFunds');
|
|
37
42
|
|
|
38
43
|
const sizeInfo = getSizeInfo({
|
|
39
|
-
|
|
44
|
+
utxos: filteredUtxos,
|
|
40
45
|
isSendMax: true,
|
|
41
46
|
recipients,
|
|
42
47
|
});
|
|
@@ -57,7 +62,7 @@ export function determineUtxosForSpendAll<T extends { value: number; txid: strin
|
|
|
57
62
|
};
|
|
58
63
|
}
|
|
59
64
|
|
|
60
|
-
export function determineUtxosForSpend<T extends
|
|
65
|
+
export function determineUtxosForSpend<T extends InputData>({
|
|
61
66
|
feeRate,
|
|
62
67
|
recipients,
|
|
63
68
|
utxos,
|
|
@@ -79,7 +84,7 @@ export function determineUtxosForSpend<T extends { value: number; txid: string }
|
|
|
79
84
|
|
|
80
85
|
function estimateTransactionSize() {
|
|
81
86
|
return getSizeInfo({
|
|
82
|
-
|
|
87
|
+
utxos: neededUtxos,
|
|
83
88
|
recipients,
|
|
84
89
|
});
|
|
85
90
|
}
|