@leather.io/bitcoin 0.16.7 → 0.17.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.
- package/.turbo/turbo-build.log +3 -3
- package/CHANGELOG.md +7 -0
- package/package.json +8 -6
- package/src/btc-size-fee-estimator.spec.ts +109 -0
- package/src/btc-size-fee-estimator.ts +327 -0
- package/src/coin-selection/calculate-max-spend.spec.ts +54 -0
- package/src/coin-selection/calculate-max-spend.ts +47 -0
- package/src/coin-selection/coin-selection.mocks.ts +43 -0
- package/src/coin-selection/coin-selection.spec.ts +230 -0
- package/src/coin-selection/coin-selection.ts +140 -0
- package/src/coin-selection/coin-selection.utils.spec.ts +64 -0
- package/src/coin-selection/coin-selection.utils.ts +118 -0
package/.turbo/turbo-build.log
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
|
|
2
|
-
> @leather.io/bitcoin@0.
|
|
2
|
+
> @leather.io/bitcoin@0.17.0 build /home/runner/work/mono/mono/packages/bitcoin
|
|
3
3
|
> tsup
|
|
4
4
|
|
|
5
5
|
CLI Building entry: src/index.ts
|
|
@@ -10,7 +10,7 @@ CLI Target: es2022
|
|
|
10
10
|
ESM Build start
|
|
11
11
|
ESM dist/index.js 25.37 KB
|
|
12
12
|
ESM dist/index.js.map 49.31 KB
|
|
13
|
-
ESM ⚡️ Build success in
|
|
13
|
+
ESM ⚡️ Build success in 39ms
|
|
14
14
|
DTS Build start
|
|
15
|
-
DTS ⚡️ Build success in
|
|
15
|
+
DTS ⚡️ Build success in 4101ms
|
|
16
16
|
DTS dist/index.d.ts 16.04 KB
|
package/CHANGELOG.md
CHANGED
|
@@ -310,6 +310,13 @@
|
|
|
310
310
|
* devDependencies
|
|
311
311
|
* @leather.io/rpc bumped to 2.1.20
|
|
312
312
|
|
|
313
|
+
## [0.17.0](https://github.com/leather-io/mono/compare/@leather.io/bitcoin-v0.16.7...@leather.io/bitcoin-v0.17.0) (2024-11-26)
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
### Features
|
|
317
|
+
|
|
318
|
+
* migrate btc coin selection, closes LEA-1734 ([0cad7aa](https://github.com/leather-io/mono/commit/0cad7aaa35fbc8704d959a16d12965502757fe89))
|
|
319
|
+
|
|
313
320
|
## [0.16.4](https://github.com/leather-io/mono/compare/@leather.io/bitcoin-v0.16.3...@leather.io/bitcoin-v0.16.4) (2024-11-19)
|
|
314
321
|
|
|
315
322
|
|
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.17.0",
|
|
6
6
|
"license": "MIT",
|
|
7
7
|
"homepage": "https://github.com/leather.io/mono/tree/dev/packages/bitcoin",
|
|
8
8
|
"repository": {
|
|
@@ -25,15 +25,17 @@
|
|
|
25
25
|
"@scure/btc-signer": "1.4.0",
|
|
26
26
|
"@stacks/common": "6.13.0",
|
|
27
27
|
"@stacks/transactions": "6.17.0",
|
|
28
|
+
"bignumber.js": "9.1.2",
|
|
28
29
|
"bip32": "4.0.0",
|
|
30
|
+
"bitcoin-address-validation": "2.2.3",
|
|
29
31
|
"bitcoinjs-lib": "6.1.5",
|
|
30
32
|
"ecpair": "2.1.0",
|
|
31
33
|
"just-memoize": "2.2.0",
|
|
32
34
|
"varuint-bitcoin": "1.1.2",
|
|
33
|
-
"@leather.io/constants": "0.13.5",
|
|
34
35
|
"@leather.io/crypto": "1.6.14",
|
|
35
|
-
"@leather.io/
|
|
36
|
-
"@leather.io/models": "0.22.0"
|
|
36
|
+
"@leather.io/constants": "0.13.5",
|
|
37
|
+
"@leather.io/models": "0.22.0",
|
|
38
|
+
"@leather.io/utils": "0.20.0"
|
|
37
39
|
},
|
|
38
40
|
"devDependencies": {
|
|
39
41
|
"eslint": "8.53.0",
|
|
@@ -43,9 +45,9 @@
|
|
|
43
45
|
"typescript": "5.5.4",
|
|
44
46
|
"vitest": "2.1.5",
|
|
45
47
|
"@leather.io/eslint-config": "0.7.0",
|
|
46
|
-
"@leather.io/
|
|
48
|
+
"@leather.io/prettier-config": "0.6.0",
|
|
47
49
|
"@leather.io/tsconfig-config": "0.6.0",
|
|
48
|
-
"@leather.io/
|
|
50
|
+
"@leather.io/rpc": "2.1.20"
|
|
49
51
|
},
|
|
50
52
|
"keywords": [
|
|
51
53
|
"bitcoin",
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
|
|
3
|
+
import { BtcSizeFeeEstimator } from './btc-size-fee-estimator';
|
|
4
|
+
|
|
5
|
+
describe('BtcSizeFeeEstimator', () => {
|
|
6
|
+
const estimator = new BtcSizeFeeEstimator();
|
|
7
|
+
|
|
8
|
+
describe('getSizeOfScriptLengthElement', () => {
|
|
9
|
+
it('should return the correct size for small lengths', () => {
|
|
10
|
+
expect(estimator.getSizeOfScriptLengthElement(75)).toBe(2);
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
it('should return the correct size for large lengths', () => {
|
|
14
|
+
expect(estimator.getSizeOfScriptLengthElement(512)).toBe(3);
|
|
15
|
+
});
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
describe('getSizeOfletInt', () => {
|
|
19
|
+
it('should return 1 for values <= 0xFC', () => {
|
|
20
|
+
expect(estimator.getSizeOfletInt(250)).toBe(1);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it('should return 3 for values <= 0xFFFF', () => {
|
|
24
|
+
expect(estimator.getSizeOfletInt(0xabcd)).toBe(3);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it('should return 5 for values <= 0xFFFFFFFF', () => {
|
|
28
|
+
expect(estimator.getSizeOfletInt(0xabcdef)).toBe(5);
|
|
29
|
+
});
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
describe('getTxOverheadVBytes', () => {
|
|
33
|
+
it('should calculate the transaction overhead correctly', () => {
|
|
34
|
+
const result = estimator.getTxOverheadVBytes('p2wpkh', 1, 1);
|
|
35
|
+
expect(result).toEqual(10.75);
|
|
36
|
+
});
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
describe('prepareParams', () => {
|
|
40
|
+
it('should validate and return formatted parameters', () => {
|
|
41
|
+
const params = estimator.prepareParams({
|
|
42
|
+
input_count: 2,
|
|
43
|
+
input_script: 'p2wpkh',
|
|
44
|
+
input_m: 1,
|
|
45
|
+
input_n: 1,
|
|
46
|
+
p2pkh_output_count: 1,
|
|
47
|
+
p2sh_output_count: 1,
|
|
48
|
+
p2sh_p2wpkh_output_count: 1,
|
|
49
|
+
p2sh_p2wsh_output_count: 1,
|
|
50
|
+
p2wpkh_output_count: 1,
|
|
51
|
+
p2wsh_output_count: 1,
|
|
52
|
+
p2tr_output_count: 1,
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
expect(params.input_count).toBe(2);
|
|
56
|
+
expect(params.input_script).toBe('p2wpkh');
|
|
57
|
+
expect(params.input_m).toBe(1);
|
|
58
|
+
expect(params.input_n).toBe(1);
|
|
59
|
+
expect(params.p2pkh_output_count).toBe(1);
|
|
60
|
+
expect(params.p2sh_output_count).toBe(1);
|
|
61
|
+
expect(params.p2sh_p2wpkh_output_count).toBe(1);
|
|
62
|
+
expect(params.p2sh_p2wsh_output_count).toBe(1);
|
|
63
|
+
expect(params.p2wpkh_output_count).toBe(1);
|
|
64
|
+
expect(params.p2wsh_output_count).toBe(1);
|
|
65
|
+
expect(params.p2tr_output_count).toBe(1);
|
|
66
|
+
});
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
describe('calcTxSize', () => {
|
|
70
|
+
it('should calculate transaction size correctly', () => {
|
|
71
|
+
const { txVBytes, txBytes, txWeight } = estimator.calcTxSize({
|
|
72
|
+
input_count: 2,
|
|
73
|
+
input_script: 'p2wpkh',
|
|
74
|
+
input_m: 1,
|
|
75
|
+
input_n: 1,
|
|
76
|
+
p2pkh_output_count: 1,
|
|
77
|
+
p2sh_output_count: 1,
|
|
78
|
+
p2sh_p2wpkh_output_count: 1,
|
|
79
|
+
p2sh_p2wsh_output_count: 1,
|
|
80
|
+
p2wpkh_output_count: 1,
|
|
81
|
+
p2wsh_output_count: 1,
|
|
82
|
+
p2tr_output_count: 1,
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
expect(txVBytes).toEqual(393.25);
|
|
86
|
+
expect(txBytes).toEqual(609.5);
|
|
87
|
+
expect(txWeight).toEqual(1573);
|
|
88
|
+
});
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
describe('estimateFee', () => {
|
|
92
|
+
it('should calculate the correct fee based on transaction size and fee rate', () => {
|
|
93
|
+
const fee = estimator.estimateFee(250, 20);
|
|
94
|
+
expect(fee).toBe(5000);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it('should return 0 fee if fee rate is 0', () => {
|
|
98
|
+
const fee = estimator.estimateFee(250, 0);
|
|
99
|
+
expect(fee).toBe(0);
|
|
100
|
+
});
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
describe('formatFeeRange', () => {
|
|
104
|
+
it('should format fee range correctly', () => {
|
|
105
|
+
const feeRange = estimator.formatFeeRange(1000, 0.1);
|
|
106
|
+
expect(feeRange).toBe('900 - 1100');
|
|
107
|
+
});
|
|
108
|
+
});
|
|
109
|
+
});
|
|
@@ -0,0 +1,327 @@
|
|
|
1
|
+
// https://github.com/argvil19/bitcoin-transaction-size-calculator/blob/master/index.js
|
|
2
|
+
import BigNumber from 'bignumber.js';
|
|
3
|
+
|
|
4
|
+
export type InputScriptType =
|
|
5
|
+
| 'p2pkh'
|
|
6
|
+
| 'p2sh'
|
|
7
|
+
| 'p2sh-p2wpkh'
|
|
8
|
+
| 'p2sh-p2wsh'
|
|
9
|
+
| 'p2wpkh'
|
|
10
|
+
| 'p2wsh'
|
|
11
|
+
| 'p2tr';
|
|
12
|
+
|
|
13
|
+
export interface TxSizerParams {
|
|
14
|
+
input_count: number;
|
|
15
|
+
input_script: InputScriptType;
|
|
16
|
+
input_m: number;
|
|
17
|
+
input_n: number;
|
|
18
|
+
p2pkh_output_count: number;
|
|
19
|
+
p2sh_output_count: number;
|
|
20
|
+
p2sh_p2wpkh_output_count: number;
|
|
21
|
+
p2sh_p2wsh_output_count: number;
|
|
22
|
+
p2wpkh_output_count: number;
|
|
23
|
+
p2wsh_output_count: number;
|
|
24
|
+
p2tr_output_count: number;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export class BtcSizeFeeEstimator {
|
|
28
|
+
P2PKH_IN_SIZE = 148;
|
|
29
|
+
P2PKH_OUT_SIZE = 34;
|
|
30
|
+
P2SH_OUT_SIZE = 32;
|
|
31
|
+
P2SH_P2WPKH_OUT_SIZE = 32;
|
|
32
|
+
P2SH_P2WSH_OUT_SIZE = 32;
|
|
33
|
+
P2SH_P2WPKH_IN_SIZE = 91;
|
|
34
|
+
P2WPKH_IN_SIZE = 67.75;
|
|
35
|
+
P2WPKH_OUT_SIZE = 31;
|
|
36
|
+
P2WSH_OUT_SIZE = 43;
|
|
37
|
+
P2TR_OUT_SIZE = 43;
|
|
38
|
+
P2TR_IN_SIZE = 57.25;
|
|
39
|
+
PUBKEY_SIZE = 33;
|
|
40
|
+
SIGNATURE_SIZE = 72;
|
|
41
|
+
SUPPORTED_INPUT_SCRIPT_TYPES: InputScriptType[] = [
|
|
42
|
+
'p2pkh',
|
|
43
|
+
'p2sh',
|
|
44
|
+
'p2sh-p2wpkh',
|
|
45
|
+
'p2sh-p2wsh',
|
|
46
|
+
'p2wpkh',
|
|
47
|
+
'p2wsh',
|
|
48
|
+
'p2tr',
|
|
49
|
+
];
|
|
50
|
+
|
|
51
|
+
defaultParams: TxSizerParams = {
|
|
52
|
+
input_count: 0,
|
|
53
|
+
input_script: 'p2wpkh',
|
|
54
|
+
input_m: 0,
|
|
55
|
+
input_n: 0,
|
|
56
|
+
p2pkh_output_count: 0,
|
|
57
|
+
p2sh_output_count: 0,
|
|
58
|
+
p2sh_p2wpkh_output_count: 0,
|
|
59
|
+
p2sh_p2wsh_output_count: 0,
|
|
60
|
+
p2wpkh_output_count: 0,
|
|
61
|
+
p2wsh_output_count: 0,
|
|
62
|
+
p2tr_output_count: 0,
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
params: TxSizerParams = { ...this.defaultParams };
|
|
66
|
+
|
|
67
|
+
getSizeOfScriptLengthElement(length: number) {
|
|
68
|
+
if (length < 75) {
|
|
69
|
+
return 1;
|
|
70
|
+
} else if (length <= 255) {
|
|
71
|
+
return 2;
|
|
72
|
+
} else if (length <= 65535) {
|
|
73
|
+
return 3;
|
|
74
|
+
} else if (length <= 4294967295) {
|
|
75
|
+
return 5;
|
|
76
|
+
} else {
|
|
77
|
+
throw new Error('Size of redeem script is too large');
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
getSizeOfletInt(length: number) {
|
|
82
|
+
if (length < 253) {
|
|
83
|
+
return 1;
|
|
84
|
+
} else if (length < 65535) {
|
|
85
|
+
return 3;
|
|
86
|
+
} else if (length < 4294967295) {
|
|
87
|
+
return 5;
|
|
88
|
+
} else if (new BigNumber(length).isLessThan('18446744073709551615')) {
|
|
89
|
+
return 9;
|
|
90
|
+
} else {
|
|
91
|
+
throw new Error('Invalid let int');
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
getTxOverheadVBytes(input_script: InputScriptType, input_count: number, output_count: number) {
|
|
96
|
+
let witness_vbytes;
|
|
97
|
+
if (input_script === 'p2pkh' || input_script === 'p2sh') {
|
|
98
|
+
witness_vbytes = 0;
|
|
99
|
+
} else {
|
|
100
|
+
// Transactions with segwit inputs have extra overhead
|
|
101
|
+
witness_vbytes =
|
|
102
|
+
0.25 + // segwit marker
|
|
103
|
+
0.25 + // segwit flag
|
|
104
|
+
this.getSizeOfletInt(input_count) / 4; // witness element count
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
return (
|
|
108
|
+
4 + // nVersion
|
|
109
|
+
this.getSizeOfletInt(input_count) + // number of inputs
|
|
110
|
+
this.getSizeOfletInt(output_count) + // number of outputs
|
|
111
|
+
4 + // nLockTime
|
|
112
|
+
witness_vbytes
|
|
113
|
+
);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
getTxOverheadExtraRawBytes(input_script: InputScriptType, input_count: number) {
|
|
117
|
+
let witness_vbytes;
|
|
118
|
+
if (input_script === 'p2pkh' || input_script === 'p2sh') {
|
|
119
|
+
witness_vbytes = 0;
|
|
120
|
+
} else {
|
|
121
|
+
// Transactions with segwit inputs have extra overhead
|
|
122
|
+
witness_vbytes =
|
|
123
|
+
0.25 + // segwit marker
|
|
124
|
+
0.25 + // segwit flag
|
|
125
|
+
this.getSizeOfletInt(input_count) / 4; // witness element count
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
return witness_vbytes * 3;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
prepareParams(opts: Partial<TxSizerParams>) {
|
|
132
|
+
// Verify opts and set them to this.params
|
|
133
|
+
opts = opts || Object.assign(this.defaultParams);
|
|
134
|
+
|
|
135
|
+
const input_count = opts.input_count || this.defaultParams.input_count;
|
|
136
|
+
if (!Number.isInteger(input_count) || input_count < 0) {
|
|
137
|
+
throw new Error('expecting positive input count, got: ' + input_count);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const input_script = opts.input_script || this.defaultParams.input_script;
|
|
141
|
+
if (this.SUPPORTED_INPUT_SCRIPT_TYPES.indexOf(input_script) === -1) {
|
|
142
|
+
throw new Error('Not supported input script type');
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const input_m = opts.input_m || this.defaultParams.input_m;
|
|
146
|
+
if (!Number.isInteger(input_m) || input_m < 0) {
|
|
147
|
+
throw new Error('expecting positive signature count');
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const input_n = opts.input_n || this.defaultParams.input_n;
|
|
151
|
+
if (!Number.isInteger(input_n) || input_n < 0) {
|
|
152
|
+
throw new Error('expecting positive pubkey count');
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const p2pkh_output_count = opts.p2pkh_output_count || this.defaultParams.p2pkh_output_count;
|
|
156
|
+
if (!Number.isInteger(p2pkh_output_count) || p2pkh_output_count < 0) {
|
|
157
|
+
throw new Error('expecting positive p2pkh output count');
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const p2sh_output_count = opts.p2sh_output_count || this.defaultParams.p2sh_output_count;
|
|
161
|
+
if (!Number.isInteger(p2sh_output_count) || p2sh_output_count < 0) {
|
|
162
|
+
throw new Error('expecting positive p2sh output count');
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const p2sh_p2wpkh_output_count =
|
|
166
|
+
opts.p2sh_p2wpkh_output_count || this.defaultParams.p2sh_p2wpkh_output_count;
|
|
167
|
+
if (!Number.isInteger(p2sh_p2wpkh_output_count) || p2sh_p2wpkh_output_count < 0) {
|
|
168
|
+
throw new Error('expecting positive p2sh-p2wpkh output count');
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const p2sh_p2wsh_output_count =
|
|
172
|
+
opts.p2sh_p2wsh_output_count || this.defaultParams.p2sh_p2wsh_output_count;
|
|
173
|
+
if (!Number.isInteger(p2sh_p2wsh_output_count) || p2sh_p2wsh_output_count < 0) {
|
|
174
|
+
throw new Error('expecting positive p2sh-p2wsh output count');
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const p2wpkh_output_count = opts.p2wpkh_output_count || this.defaultParams.p2wpkh_output_count;
|
|
178
|
+
if (!Number.isInteger(p2wpkh_output_count) || p2wpkh_output_count < 0) {
|
|
179
|
+
throw new Error('expecting positive p2wpkh output count');
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const p2wsh_output_count = opts.p2wsh_output_count || this.defaultParams.p2wsh_output_count;
|
|
183
|
+
if (!Number.isInteger(p2wsh_output_count) || p2wsh_output_count < 0) {
|
|
184
|
+
throw new Error('expecting positive p2wsh output count');
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const p2tr_output_count = opts.p2tr_output_count || this.defaultParams.p2tr_output_count;
|
|
188
|
+
if (!Number.isInteger(p2tr_output_count) || p2tr_output_count < 0) {
|
|
189
|
+
throw new Error('expecting positive p2tr output count');
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
this.params = {
|
|
193
|
+
input_count,
|
|
194
|
+
input_script,
|
|
195
|
+
input_m,
|
|
196
|
+
input_n,
|
|
197
|
+
p2pkh_output_count,
|
|
198
|
+
p2sh_output_count,
|
|
199
|
+
p2sh_p2wpkh_output_count,
|
|
200
|
+
p2sh_p2wsh_output_count,
|
|
201
|
+
p2wpkh_output_count,
|
|
202
|
+
p2wsh_output_count,
|
|
203
|
+
p2tr_output_count,
|
|
204
|
+
};
|
|
205
|
+
|
|
206
|
+
return this.params;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
getOutputCount() {
|
|
210
|
+
return (
|
|
211
|
+
this.params.p2pkh_output_count +
|
|
212
|
+
this.params.p2sh_output_count +
|
|
213
|
+
this.params.p2sh_p2wpkh_output_count +
|
|
214
|
+
this.params.p2sh_p2wsh_output_count +
|
|
215
|
+
this.params.p2wpkh_output_count +
|
|
216
|
+
this.params.p2wsh_output_count +
|
|
217
|
+
this.params.p2tr_output_count
|
|
218
|
+
);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
getSizeBasedOnInputType() {
|
|
222
|
+
// In most cases the input size is predictable. For multisig inputs we need to perform a detailed calculation
|
|
223
|
+
let inputSize = 0; // in virtual bytes
|
|
224
|
+
let inputWitnessSize = 0;
|
|
225
|
+
let redeemScriptSize;
|
|
226
|
+
switch (this.params.input_script) {
|
|
227
|
+
case 'p2pkh':
|
|
228
|
+
inputSize = this.P2PKH_IN_SIZE;
|
|
229
|
+
break;
|
|
230
|
+
case 'p2sh-p2wpkh':
|
|
231
|
+
inputSize = this.P2SH_P2WPKH_IN_SIZE;
|
|
232
|
+
inputWitnessSize = 107; // size(signature) + signature + size(pubkey) + pubkey
|
|
233
|
+
break;
|
|
234
|
+
case 'p2wpkh':
|
|
235
|
+
inputSize = this.P2WPKH_IN_SIZE;
|
|
236
|
+
inputWitnessSize = 107; // size(signature) + signature + size(pubkey) + pubkey
|
|
237
|
+
break;
|
|
238
|
+
case 'p2tr': // Only consider the cooperative taproot signing path assume multisig is done via aggregate signatures
|
|
239
|
+
inputSize = this.P2TR_IN_SIZE;
|
|
240
|
+
inputWitnessSize = 65; // getSizeOfletInt(schnorrSignature) + schnorrSignature
|
|
241
|
+
break;
|
|
242
|
+
case 'p2sh':
|
|
243
|
+
redeemScriptSize =
|
|
244
|
+
1 + // OP_M
|
|
245
|
+
this.params.input_n * (1 + this.PUBKEY_SIZE) + // OP_PUSH33 <pubkey>
|
|
246
|
+
1 + // OP_N
|
|
247
|
+
1; // OP_CHECKMULTISIG
|
|
248
|
+
const scriptSigSize =
|
|
249
|
+
1 + // size(0)
|
|
250
|
+
this.params.input_m * (1 + this.SIGNATURE_SIZE) + // size(SIGNATURE_SIZE) + signature
|
|
251
|
+
this.getSizeOfScriptLengthElement(redeemScriptSize) +
|
|
252
|
+
redeemScriptSize;
|
|
253
|
+
inputSize = 32 + 4 + this.getSizeOfletInt(scriptSigSize) + scriptSigSize + 4;
|
|
254
|
+
break;
|
|
255
|
+
case 'p2sh-p2wsh':
|
|
256
|
+
case 'p2wsh':
|
|
257
|
+
redeemScriptSize =
|
|
258
|
+
1 + // OP_M
|
|
259
|
+
this.params.input_n * (1 + this.PUBKEY_SIZE) + // OP_PUSH33 <pubkey>
|
|
260
|
+
1 + // OP_N
|
|
261
|
+
1; // OP_CHECKMULTISIG
|
|
262
|
+
inputWitnessSize =
|
|
263
|
+
1 + // size(0)
|
|
264
|
+
this.params.input_m * (1 + this.SIGNATURE_SIZE) + // size(SIGNATURE_SIZE) + signature
|
|
265
|
+
this.getSizeOfScriptLengthElement(redeemScriptSize) +
|
|
266
|
+
redeemScriptSize;
|
|
267
|
+
inputSize =
|
|
268
|
+
36 + // outpoint (spent UTXO ID)
|
|
269
|
+
inputWitnessSize / 4 + // witness program
|
|
270
|
+
4; // nSequence
|
|
271
|
+
if (this.params.input_script === 'p2sh-p2wsh') {
|
|
272
|
+
inputSize += 32 + 3; // P2SH wrapper (redeemscript hash) + overhead?
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
return {
|
|
277
|
+
inputSize,
|
|
278
|
+
inputWitnessSize,
|
|
279
|
+
};
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
calcTxSize(opts: Partial<TxSizerParams>) {
|
|
283
|
+
this.prepareParams(opts);
|
|
284
|
+
const output_count = this.getOutputCount();
|
|
285
|
+
const { inputSize, inputWitnessSize } = this.getSizeBasedOnInputType();
|
|
286
|
+
|
|
287
|
+
const txVBytes =
|
|
288
|
+
this.getTxOverheadVBytes(this.params.input_script, this.params.input_count, output_count) +
|
|
289
|
+
inputSize * this.params.input_count +
|
|
290
|
+
this.P2PKH_OUT_SIZE * this.params.p2pkh_output_count +
|
|
291
|
+
this.P2SH_OUT_SIZE * this.params.p2sh_output_count +
|
|
292
|
+
this.P2SH_P2WPKH_OUT_SIZE * this.params.p2sh_p2wpkh_output_count +
|
|
293
|
+
this.P2SH_P2WSH_OUT_SIZE * this.params.p2sh_p2wsh_output_count +
|
|
294
|
+
this.P2WPKH_OUT_SIZE * this.params.p2wpkh_output_count +
|
|
295
|
+
this.P2WSH_OUT_SIZE * this.params.p2wsh_output_count +
|
|
296
|
+
this.P2TR_OUT_SIZE * this.params.p2tr_output_count;
|
|
297
|
+
|
|
298
|
+
const txBytes =
|
|
299
|
+
this.getTxOverheadExtraRawBytes(this.params.input_script, this.params.input_count) +
|
|
300
|
+
txVBytes +
|
|
301
|
+
inputWitnessSize * this.params.input_count;
|
|
302
|
+
const txWeight = txVBytes * 4;
|
|
303
|
+
|
|
304
|
+
return { txVBytes, txBytes, txWeight };
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
estimateFee(vbyte: number, satVb: number) {
|
|
308
|
+
if (isNaN(vbyte) || isNaN(satVb)) {
|
|
309
|
+
throw new Error('Parameters should be numbers');
|
|
310
|
+
}
|
|
311
|
+
return vbyte * satVb;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
formatFeeRange(fee: number, multiplier: number) {
|
|
315
|
+
if (isNaN(fee) || isNaN(multiplier)) {
|
|
316
|
+
throw new Error('Parameters should be numbers');
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
if (multiplier < 0) {
|
|
320
|
+
throw new Error('Multiplier cant be negative');
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
const multipliedFee = fee * multiplier;
|
|
324
|
+
|
|
325
|
+
return fee - multipliedFee + ' - ' + (fee + multipliedFee);
|
|
326
|
+
}
|
|
327
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { calculateMaxBitcoinSpend } from './calculate-max-spend';
|
|
2
|
+
import { generateMockAverageFee, mockUtxos } from './coin-selection.mocks';
|
|
3
|
+
|
|
4
|
+
describe(calculateMaxBitcoinSpend.name, () => {
|
|
5
|
+
test('with 1 sat/vb fee', () => {
|
|
6
|
+
const fee = 1;
|
|
7
|
+
const maxBitcoinSpend = calculateMaxBitcoinSpend({
|
|
8
|
+
address: '',
|
|
9
|
+
utxos: mockUtxos,
|
|
10
|
+
fetchedFeeRates: generateMockAverageFee(fee),
|
|
11
|
+
});
|
|
12
|
+
expect(maxBitcoinSpend.amount.amount.toNumber()).toEqual(50087948);
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
test('with 5 sat/vb fee', () => {
|
|
16
|
+
const fee = 5;
|
|
17
|
+
const maxBitcoinSpend = calculateMaxBitcoinSpend({
|
|
18
|
+
address: '',
|
|
19
|
+
utxos: mockUtxos,
|
|
20
|
+
fetchedFeeRates: generateMockAverageFee(fee),
|
|
21
|
+
});
|
|
22
|
+
expect(maxBitcoinSpend.amount.amount.toNumber()).toEqual(50085342);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
test('with 30 sat/vb fee', () => {
|
|
26
|
+
const fee = 30;
|
|
27
|
+
const maxBitcoinSpend = calculateMaxBitcoinSpend({
|
|
28
|
+
address: '',
|
|
29
|
+
utxos: mockUtxos,
|
|
30
|
+
fetchedFeeRates: generateMockAverageFee(fee),
|
|
31
|
+
});
|
|
32
|
+
expect(maxBitcoinSpend.amount.amount.toNumber()).toEqual(50073585);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test('with 100 sat/vb fee', () => {
|
|
36
|
+
const fee = 100;
|
|
37
|
+
const maxBitcoinSpend = calculateMaxBitcoinSpend({
|
|
38
|
+
address: '',
|
|
39
|
+
utxos: mockUtxos,
|
|
40
|
+
fetchedFeeRates: generateMockAverageFee(fee),
|
|
41
|
+
});
|
|
42
|
+
expect(maxBitcoinSpend.amount.amount.toNumber()).toEqual(50046950);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test('with 400 sat/vb fee', () => {
|
|
46
|
+
const fee = 400;
|
|
47
|
+
const maxBitcoinSpend = calculateMaxBitcoinSpend({
|
|
48
|
+
address: '',
|
|
49
|
+
utxos: mockUtxos,
|
|
50
|
+
fetchedFeeRates: generateMockAverageFee(fee),
|
|
51
|
+
});
|
|
52
|
+
expect(maxBitcoinSpend.amount.amount.toNumber()).toEqual(49969100);
|
|
53
|
+
});
|
|
54
|
+
});
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import BigNumber from 'bignumber.js';
|
|
2
|
+
|
|
3
|
+
import type { AverageBitcoinFeeRates } from '@leather.io/models';
|
|
4
|
+
import { createMoney, satToBtc } from '@leather.io/utils';
|
|
5
|
+
|
|
6
|
+
import { Utxo, filterUneconomicalUtxos, getSpendableAmount } from './coin-selection.utils';
|
|
7
|
+
|
|
8
|
+
interface CalculateMaxBitcoinSpend {
|
|
9
|
+
address: string;
|
|
10
|
+
utxos: Utxo[];
|
|
11
|
+
fetchedFeeRates?: AverageBitcoinFeeRates;
|
|
12
|
+
feeRate?: number;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function calculateMaxBitcoinSpend({
|
|
16
|
+
address,
|
|
17
|
+
utxos,
|
|
18
|
+
feeRate,
|
|
19
|
+
fetchedFeeRates,
|
|
20
|
+
}: CalculateMaxBitcoinSpend) {
|
|
21
|
+
if (!utxos.length || !fetchedFeeRates)
|
|
22
|
+
return {
|
|
23
|
+
spendAllFee: 0,
|
|
24
|
+
amount: createMoney(0, 'BTC'),
|
|
25
|
+
spendableBitcoin: new BigNumber(0),
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
const currentFeeRate = feeRate ?? fetchedFeeRates.halfHourFee.toNumber();
|
|
29
|
+
|
|
30
|
+
const filteredUtxos = filterUneconomicalUtxos({
|
|
31
|
+
utxos,
|
|
32
|
+
feeRate: currentFeeRate,
|
|
33
|
+
recipients: [{ address, amount: createMoney(0, 'BTC') }],
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
const { spendableAmount, fee } = getSpendableAmount({
|
|
37
|
+
utxos: filteredUtxos,
|
|
38
|
+
feeRate: currentFeeRate,
|
|
39
|
+
recipients: [{ address, amount: createMoney(0, 'BTC') }],
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
return {
|
|
43
|
+
spendAllFee: fee,
|
|
44
|
+
amount: createMoney(spendableAmount, 'BTC'),
|
|
45
|
+
spendableBitcoin: satToBtc(spendableAmount),
|
|
46
|
+
};
|
|
47
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { sha256 } from '@noble/hashes/sha256';
|
|
2
|
+
import { hexToBytes } from '@noble/hashes/utils';
|
|
3
|
+
import BigNumber from 'bignumber.js';
|
|
4
|
+
|
|
5
|
+
import { Utxo } from './coin-selection.utils';
|
|
6
|
+
|
|
7
|
+
function generateMockHex() {
|
|
8
|
+
return Math.floor(Math.random() * 0xffffff)
|
|
9
|
+
.toString(16)
|
|
10
|
+
.padEnd(6, '0');
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function generateMockTxId(value: number): Utxo {
|
|
14
|
+
return {
|
|
15
|
+
txid: sha256(sha256(hexToBytes(generateMockHex()))).toString(),
|
|
16
|
+
vout: 0,
|
|
17
|
+
status: {
|
|
18
|
+
confirmed: true,
|
|
19
|
+
block_height: 2568495,
|
|
20
|
+
block_hash: '000000000000008622fafce4a5388861b252d534f819d0f7cb5d4f2c5f9c1638',
|
|
21
|
+
block_time: 1703787327,
|
|
22
|
+
},
|
|
23
|
+
value,
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function generateMockTransactions(values: number[]) {
|
|
28
|
+
return values.map(val => generateMockTxId(val));
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function generateMockAverageFee(value: number) {
|
|
32
|
+
return {
|
|
33
|
+
hourFee: BigNumber(value / 2),
|
|
34
|
+
halfHourFee: BigNumber(value),
|
|
35
|
+
fastestFee: BigNumber(value * 2),
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export const mockUtxos = generateMockTransactions([
|
|
40
|
+
600, 600, 1200, 1200, 10000, 10000, 25000, 40000, 50000000,
|
|
41
|
+
]);
|
|
42
|
+
|
|
43
|
+
export const mockAverageFee = generateMockAverageFee(10);
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
import { BTC_P2WPKH_DUST_AMOUNT } from '@leather.io/constants';
|
|
2
|
+
import { createMoney, createNullArrayOfLength, sumNumbers } from '@leather.io/utils';
|
|
3
|
+
|
|
4
|
+
import { determineUtxosForSpend, determineUtxosForSpendAll } from './coin-selection';
|
|
5
|
+
import { filterUneconomicalUtxos, getSizeInfo } from './coin-selection.utils';
|
|
6
|
+
|
|
7
|
+
const demoUtxos = [
|
|
8
|
+
{ value: 8200 },
|
|
9
|
+
{ value: 8490 },
|
|
10
|
+
{ value: 8790 },
|
|
11
|
+
{ value: 19 },
|
|
12
|
+
{ value: 2000 },
|
|
13
|
+
{ value: 2340 },
|
|
14
|
+
{ value: 1230 },
|
|
15
|
+
{ value: 120 },
|
|
16
|
+
{ value: 8 },
|
|
17
|
+
{ value: 1002 },
|
|
18
|
+
{ value: 1382 },
|
|
19
|
+
{ value: 1400 },
|
|
20
|
+
{ value: 909 },
|
|
21
|
+
];
|
|
22
|
+
|
|
23
|
+
function generate10kSpendWithDummyUtxoSet(recipient: string) {
|
|
24
|
+
return determineUtxosForSpend({
|
|
25
|
+
utxos: demoUtxos as any,
|
|
26
|
+
feeRate: 20,
|
|
27
|
+
recipients: [{ address: recipient, amount: createMoney(10_000, 'BTC') }],
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
describe(determineUtxosForSpend.name, () => {
|
|
32
|
+
describe('Estimated size', () => {
|
|
33
|
+
test('that Native Segwit, 1 input 2 outputs weighs 140 vBytes', () => {
|
|
34
|
+
const estimation = determineUtxosForSpend({
|
|
35
|
+
utxos: [{ value: 50_000 }] as any[],
|
|
36
|
+
recipients: [
|
|
37
|
+
{
|
|
38
|
+
address: 'tb1qt28eagxcl9gvhq2rpj5slg7dwgxae2dn2hk93m',
|
|
39
|
+
amount: createMoney(40_000, 'BTC'),
|
|
40
|
+
},
|
|
41
|
+
],
|
|
42
|
+
feeRate: 20,
|
|
43
|
+
});
|
|
44
|
+
expect(estimation.txVBytes).toBeGreaterThan(140);
|
|
45
|
+
expect(estimation.txVBytes).toBeLessThan(142);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test('that Native Segwit, 2 input 2 outputs weighs 200vBytes', () => {
|
|
49
|
+
const estimation = determineUtxosForSpend({
|
|
50
|
+
utxos: [{ value: 50_000 }, { value: 50_000 }] as any[],
|
|
51
|
+
recipients: [
|
|
52
|
+
{
|
|
53
|
+
address: 'tb1qt28eagxcl9gvhq2rpj5slg7dwgxae2dn2hk93m',
|
|
54
|
+
amount: createMoney(60_000, 'BTC'),
|
|
55
|
+
},
|
|
56
|
+
],
|
|
57
|
+
feeRate: 20,
|
|
58
|
+
});
|
|
59
|
+
expect(estimation.txVBytes).toBeGreaterThan(208);
|
|
60
|
+
expect(estimation.txVBytes).toBeLessThan(209);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test('that Native Segwit, 10 input 2 outputs weighs 200vBytes', () => {
|
|
64
|
+
const estimation = determineUtxosForSpend({
|
|
65
|
+
utxos: [
|
|
66
|
+
{ value: 20_000 },
|
|
67
|
+
{ value: 20_000 },
|
|
68
|
+
{ value: 10_000 },
|
|
69
|
+
{ value: 10_000 },
|
|
70
|
+
{ value: 10_000 },
|
|
71
|
+
{ value: 10_000 },
|
|
72
|
+
{ value: 10_000 },
|
|
73
|
+
{ value: 10_000 },
|
|
74
|
+
{ value: 10_000 },
|
|
75
|
+
{ value: 10_000 },
|
|
76
|
+
] as any[],
|
|
77
|
+
recipients: [
|
|
78
|
+
{
|
|
79
|
+
address: 'tb1qt28eagxcl9gvhq2rpj5slg7dwgxae2dn2hk93m',
|
|
80
|
+
amount: createMoney(100_000, 'BTC'),
|
|
81
|
+
},
|
|
82
|
+
],
|
|
83
|
+
feeRate: 20,
|
|
84
|
+
});
|
|
85
|
+
expect(estimation.txVBytes).toBeGreaterThan(750);
|
|
86
|
+
expect(estimation.txVBytes).toBeLessThan(751);
|
|
87
|
+
});
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
describe('sorting algorithm', () => {
|
|
91
|
+
test('that it filters out dust utxos', () => {
|
|
92
|
+
const result = generate10kSpendWithDummyUtxoSet('tb1qt28eagxcl9gvhq2rpj5slg7dwgxae2dn2hk93m');
|
|
93
|
+
const hasDust = result.filteredUtxos.some(utxo => utxo.value <= BTC_P2WPKH_DUST_AMOUNT);
|
|
94
|
+
expect(hasDust).toBeFalsy();
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
test('that it sorts utxos in decending order', () => {
|
|
98
|
+
const result = generate10kSpendWithDummyUtxoSet('tb1qt28eagxcl9gvhq2rpj5slg7dwgxae2dn2hk93m');
|
|
99
|
+
result.inputs.forEach((u, i) => {
|
|
100
|
+
const nextUtxo = result.inputs[i + 1];
|
|
101
|
+
if (!nextUtxo) return;
|
|
102
|
+
expect(u.value >= nextUtxo.value).toEqual(true);
|
|
103
|
+
});
|
|
104
|
+
});
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
test('that it accepts a wrapped segwit address', () =>
|
|
108
|
+
expect(() =>
|
|
109
|
+
generate10kSpendWithDummyUtxoSet('33SVjoCHJovrXxjDKLFSXo1h3t5KgkPzfH')
|
|
110
|
+
).not.toThrowError());
|
|
111
|
+
|
|
112
|
+
test('that it accepts a legacy addresses', () =>
|
|
113
|
+
expect(() =>
|
|
114
|
+
generate10kSpendWithDummyUtxoSet('15PyZveQd28E2SHZu2ugkWZBp6iER41vXj')
|
|
115
|
+
).not.toThrowError());
|
|
116
|
+
|
|
117
|
+
test('that it throws an error with non-legit address', () => {
|
|
118
|
+
expect(() =>
|
|
119
|
+
generate10kSpendWithDummyUtxoSet('whoop-de-da-boop-da-de-not-a-bitcoin-address')
|
|
120
|
+
).toThrowError();
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
test('that given a set of utxos, legacy is more expensive', () => {
|
|
124
|
+
const legacy = generate10kSpendWithDummyUtxoSet('15PyZveQd28E2SHZu2ugkWZBp6iER41vXj');
|
|
125
|
+
const segwit = generate10kSpendWithDummyUtxoSet('33SVjoCHJovrXxjDKLFSXo1h3t5KgkPzfH');
|
|
126
|
+
expect(legacy.fee).toBeGreaterThan(segwit.fee);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
test('that given a set of utxos, wrapped segwit is more expensive than native', () => {
|
|
130
|
+
const segwit = generate10kSpendWithDummyUtxoSet('33SVjoCHJovrXxjDKLFSXo1h3t5KgkPzfH');
|
|
131
|
+
const native = generate10kSpendWithDummyUtxoSet('tb1qt28eagxcl9gvhq2rpj5slg7dwgxae2dn2hk93m');
|
|
132
|
+
expect(segwit.fee).toBeGreaterThan(native.fee);
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
test('that given a set of utxos, taproot is more expensive than native segwit', () => {
|
|
136
|
+
// Non-obvious behaviour.
|
|
137
|
+
// P2TR outputs = 34 vBytes
|
|
138
|
+
// P2WPKH outputs = 22 vBytes
|
|
139
|
+
const native = generate10kSpendWithDummyUtxoSet('tb1qt28eagxcl9gvhq2rpj5slg7dwgxae2dn2hk93m');
|
|
140
|
+
const taproot = generate10kSpendWithDummyUtxoSet(
|
|
141
|
+
'tb1parwmj7533de3k2fw2kntyqacspvhm67qnjcmpqnnpfvzu05l69nsczdywd'
|
|
142
|
+
);
|
|
143
|
+
expect(taproot.fee).toBeGreaterThan(native.fee);
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
test('against a random set of generated utxos', () => {
|
|
147
|
+
const testData = createNullArrayOfLength(50).map(() => ({
|
|
148
|
+
value: Math.ceil(Math.random() * 10000),
|
|
149
|
+
}));
|
|
150
|
+
const amount = 29123n;
|
|
151
|
+
const result = determineUtxosForSpend({
|
|
152
|
+
utxos: testData as any,
|
|
153
|
+
recipients: [
|
|
154
|
+
{
|
|
155
|
+
address: 'tb1qt28eagxcl9gvhq2rpj5slg7dwgxae2dn2hk93m',
|
|
156
|
+
amount: createMoney(Number(amount), 'BTC'),
|
|
157
|
+
},
|
|
158
|
+
],
|
|
159
|
+
feeRate: 3,
|
|
160
|
+
});
|
|
161
|
+
expect(result.outputs[0].value).toEqual(29123n);
|
|
162
|
+
|
|
163
|
+
expect(result.outputs[1].value.toString()).toEqual(
|
|
164
|
+
sumNumbers(result.inputs.map(i => i.value))
|
|
165
|
+
.minus(result.fee)
|
|
166
|
+
.minus(amount.toString())
|
|
167
|
+
.toString()
|
|
168
|
+
);
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
test('that spending all economical spendable utxos does not result in dust utxos', () => {
|
|
172
|
+
const feeRate = 3;
|
|
173
|
+
const recipients = [
|
|
174
|
+
{
|
|
175
|
+
address: 'tb1qt28eagxcl9gvhq2rpj5slg7dwgxae2dn2hk93m',
|
|
176
|
+
amount: createMoney(Number(1), 'BTC'),
|
|
177
|
+
},
|
|
178
|
+
];
|
|
179
|
+
const filteredUtxos = filterUneconomicalUtxos({
|
|
180
|
+
utxos: demoUtxos.sort((a, b) => b.value - a.value) as any,
|
|
181
|
+
feeRate,
|
|
182
|
+
recipients,
|
|
183
|
+
});
|
|
184
|
+
const amount = filteredUtxos.reduce((total, utxo) => total + utxo.value, 0) - 2251;
|
|
185
|
+
recipients[0].amount = createMoney(Number(amount), 'BTC');
|
|
186
|
+
|
|
187
|
+
const result = determineUtxosForSpend({
|
|
188
|
+
utxos: filteredUtxos as any,
|
|
189
|
+
recipients: [
|
|
190
|
+
{
|
|
191
|
+
address: 'tb1qt28eagxcl9gvhq2rpj5slg7dwgxae2dn2hk93m',
|
|
192
|
+
amount: createMoney(Number(amount), 'BTC'),
|
|
193
|
+
},
|
|
194
|
+
],
|
|
195
|
+
feeRate,
|
|
196
|
+
});
|
|
197
|
+
expect(result.inputs.length).toEqual(10);
|
|
198
|
+
expect(result.outputs.length).toEqual(1);
|
|
199
|
+
expect(result.fee).toEqual(2251);
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
test('that spending all utxos with sendMax does not result in dust utxos', () => {
|
|
203
|
+
const utxos = [{ value: 1000 }, { value: 2000 }, { value: 3000 }];
|
|
204
|
+
const recipients = [
|
|
205
|
+
{
|
|
206
|
+
address: 'tb1qt28eagxcl9gvhq2rpj5slg7dwgxae2dn2hk93m',
|
|
207
|
+
amount: createMoney(Number(1), 'BTC'),
|
|
208
|
+
},
|
|
209
|
+
];
|
|
210
|
+
const sizeInfo = getSizeInfo({
|
|
211
|
+
inputLength: utxos.length,
|
|
212
|
+
isSendMax: true,
|
|
213
|
+
recipients,
|
|
214
|
+
});
|
|
215
|
+
const feeRate = 3;
|
|
216
|
+
const fee = Math.floor(sizeInfo.txVBytes * feeRate);
|
|
217
|
+
const amount = utxos.reduce((total, utxo) => total + utxo.value, 0) - fee;
|
|
218
|
+
recipients[0].amount = createMoney(Number(amount), 'BTC');
|
|
219
|
+
|
|
220
|
+
const result = determineUtxosForSpendAll({
|
|
221
|
+
utxos: utxos as any,
|
|
222
|
+
recipients,
|
|
223
|
+
feeRate,
|
|
224
|
+
});
|
|
225
|
+
expect(result.inputs.length).toEqual(utxos.length);
|
|
226
|
+
expect(result.outputs.length).toEqual(1);
|
|
227
|
+
expect(result.fee).toEqual(735);
|
|
228
|
+
expect(fee).toEqual(735);
|
|
229
|
+
});
|
|
230
|
+
});
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import BigNumber from 'bignumber.js';
|
|
2
|
+
import { validate } from 'bitcoin-address-validation';
|
|
3
|
+
|
|
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
|
+
}
|
|
19
|
+
|
|
20
|
+
interface Output {
|
|
21
|
+
value: bigint;
|
|
22
|
+
address?: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface DetermineUtxosForSpendArgs {
|
|
26
|
+
feeRate: number;
|
|
27
|
+
recipients: TransferRecipient[];
|
|
28
|
+
utxos: Utxo[];
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function getUtxoTotal(utxos: Utxo[]) {
|
|
32
|
+
return sumNumbers(utxos.map(utxo => utxo.value));
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function determineUtxosForSpendAll({
|
|
36
|
+
feeRate,
|
|
37
|
+
recipients,
|
|
38
|
+
utxos,
|
|
39
|
+
}: DetermineUtxosForSpendArgs) {
|
|
40
|
+
recipients.forEach(recipient => {
|
|
41
|
+
if (!validate(recipient.address))
|
|
42
|
+
throw new Error('Cannot calculate spend of invalid address type');
|
|
43
|
+
});
|
|
44
|
+
const filteredUtxos = filterUneconomicalUtxos({ utxos, feeRate, recipients });
|
|
45
|
+
|
|
46
|
+
const sizeInfo = getSizeInfo({
|
|
47
|
+
inputLength: filteredUtxos.length,
|
|
48
|
+
isSendMax: true,
|
|
49
|
+
recipients,
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
// Fee has already been deducted from the amount with send all
|
|
53
|
+
const outputs = recipients.map(({ address, amount }) => ({
|
|
54
|
+
value: BigInt(amount.amount.toNumber()),
|
|
55
|
+
address,
|
|
56
|
+
}));
|
|
57
|
+
|
|
58
|
+
const fee = Math.ceil(sizeInfo.txVBytes * feeRate);
|
|
59
|
+
|
|
60
|
+
return {
|
|
61
|
+
inputs: filteredUtxos,
|
|
62
|
+
outputs,
|
|
63
|
+
size: sizeInfo.txVBytes,
|
|
64
|
+
fee,
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function determineUtxosForSpend({ feeRate, recipients, utxos }: DetermineUtxosForSpendArgs) {
|
|
69
|
+
recipients.forEach(recipient => {
|
|
70
|
+
if (!validate(recipient.address))
|
|
71
|
+
throw new Error('Cannot calculate spend of invalid address type');
|
|
72
|
+
});
|
|
73
|
+
const filteredUtxos = filterUneconomicalUtxos({
|
|
74
|
+
utxos: utxos.sort((a, b) => b.value - a.value),
|
|
75
|
+
feeRate,
|
|
76
|
+
recipients,
|
|
77
|
+
});
|
|
78
|
+
if (!filteredUtxos.length) throw new InsufficientFundsError();
|
|
79
|
+
|
|
80
|
+
const amount = sumMoney(recipients.map(recipient => recipient.amount));
|
|
81
|
+
|
|
82
|
+
// Prepopulate with first UTXO, at least one is needed
|
|
83
|
+
const neededUtxos: Utxo[] = [filteredUtxos[0]];
|
|
84
|
+
|
|
85
|
+
function estimateTransactionSize() {
|
|
86
|
+
return getSizeInfo({
|
|
87
|
+
inputLength: neededUtxos.length,
|
|
88
|
+
recipients,
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function hasSufficientUtxosForTx() {
|
|
93
|
+
const txEstimation = estimateTransactionSize();
|
|
94
|
+
const neededAmount = new BigNumber(txEstimation.txVBytes * feeRate).plus(amount.amount);
|
|
95
|
+
return getUtxoTotal(neededUtxos).isGreaterThanOrEqualTo(neededAmount);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function getRemainingUnspentUtxos() {
|
|
99
|
+
return filteredUtxos.filter(utxo => !neededUtxos.includes(utxo));
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
while (!hasSufficientUtxosForTx()) {
|
|
103
|
+
const [nextUtxo] = getRemainingUnspentUtxos();
|
|
104
|
+
if (!nextUtxo) throw new InsufficientFundsError();
|
|
105
|
+
neededUtxos.push(nextUtxo);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const fee = Math.ceil(
|
|
109
|
+
new BigNumber(estimateTransactionSize().txVBytes).multipliedBy(feeRate).toNumber()
|
|
110
|
+
);
|
|
111
|
+
|
|
112
|
+
const changeAmount =
|
|
113
|
+
BigInt(getUtxoTotal(neededUtxos).toString()) - BigInt(amount.amount.toNumber()) - BigInt(fee);
|
|
114
|
+
|
|
115
|
+
const changeUtxos: Output[] =
|
|
116
|
+
changeAmount > BTC_P2WPKH_DUST_AMOUNT
|
|
117
|
+
? [
|
|
118
|
+
{
|
|
119
|
+
value: changeAmount,
|
|
120
|
+
},
|
|
121
|
+
]
|
|
122
|
+
: [];
|
|
123
|
+
|
|
124
|
+
const outputs: Output[] = [
|
|
125
|
+
...recipients.map(({ address, amount }) => ({
|
|
126
|
+
value: BigInt(amount.amount.toNumber()),
|
|
127
|
+
address,
|
|
128
|
+
})),
|
|
129
|
+
...changeUtxos,
|
|
130
|
+
];
|
|
131
|
+
|
|
132
|
+
return {
|
|
133
|
+
filteredUtxos,
|
|
134
|
+
inputs: neededUtxos,
|
|
135
|
+
outputs,
|
|
136
|
+
size: estimateTransactionSize().txVBytes,
|
|
137
|
+
fee,
|
|
138
|
+
...estimateTransactionSize(),
|
|
139
|
+
};
|
|
140
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { createMoney } from '@leather.io/utils';
|
|
2
|
+
|
|
3
|
+
import { mockUtxos } from './coin-selection.mocks';
|
|
4
|
+
import { filterUneconomicalUtxos } from './coin-selection.utils';
|
|
5
|
+
|
|
6
|
+
describe(filterUneconomicalUtxos.name, () => {
|
|
7
|
+
const recipients = [
|
|
8
|
+
{
|
|
9
|
+
address: '',
|
|
10
|
+
amount: createMoney(0, 'BTC'),
|
|
11
|
+
},
|
|
12
|
+
];
|
|
13
|
+
|
|
14
|
+
test('with 1 sat/vb fee', () => {
|
|
15
|
+
const fee = 1;
|
|
16
|
+
const filteredUtxos = filterUneconomicalUtxos({
|
|
17
|
+
utxos: mockUtxos,
|
|
18
|
+
feeRate: fee,
|
|
19
|
+
recipients,
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
expect(filteredUtxos.length).toEqual(9);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
test('with 10 sat/vb fee', () => {
|
|
26
|
+
const fee = 10;
|
|
27
|
+
const filteredUtxos = filterUneconomicalUtxos({
|
|
28
|
+
recipients,
|
|
29
|
+
utxos: mockUtxos,
|
|
30
|
+
feeRate: fee,
|
|
31
|
+
});
|
|
32
|
+
expect(filteredUtxos.length).toEqual(7);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test('with 30 sat/vb fee', () => {
|
|
36
|
+
const fee = 30;
|
|
37
|
+
const filteredUtxos = filterUneconomicalUtxos({
|
|
38
|
+
recipients,
|
|
39
|
+
utxos: mockUtxos,
|
|
40
|
+
feeRate: fee,
|
|
41
|
+
});
|
|
42
|
+
expect(filteredUtxos.length).toEqual(5);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test('with 200 sat/vb fee', () => {
|
|
46
|
+
const fee = 200;
|
|
47
|
+
const filteredUtxos = filterUneconomicalUtxos({
|
|
48
|
+
recipients,
|
|
49
|
+
utxos: mockUtxos,
|
|
50
|
+
feeRate: fee,
|
|
51
|
+
});
|
|
52
|
+
expect(filteredUtxos.length).toEqual(3);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test('with 400 sat/vb fee', () => {
|
|
56
|
+
const fee = 400;
|
|
57
|
+
const filteredUtxos = filterUneconomicalUtxos({
|
|
58
|
+
recipients,
|
|
59
|
+
utxos: mockUtxos,
|
|
60
|
+
feeRate: fee,
|
|
61
|
+
});
|
|
62
|
+
expect(filteredUtxos.length).toEqual(2);
|
|
63
|
+
});
|
|
64
|
+
});
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import BigNumber from 'bignumber.js';
|
|
2
|
+
import validate, { AddressInfo, AddressType, getAddressInfo } from 'bitcoin-address-validation';
|
|
3
|
+
|
|
4
|
+
import { BTC_P2WPKH_DUST_AMOUNT } from '@leather.io/constants';
|
|
5
|
+
import { Money } from '@leather.io/models';
|
|
6
|
+
|
|
7
|
+
import { BtcSizeFeeEstimator } from '../btc-size-fee-estimator';
|
|
8
|
+
|
|
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;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function getSizeInfo(payload: {
|
|
20
|
+
inputLength: number;
|
|
21
|
+
recipients: TransferRecipient[];
|
|
22
|
+
isSendMax?: boolean;
|
|
23
|
+
}) {
|
|
24
|
+
const { inputLength, recipients, isSendMax } = payload;
|
|
25
|
+
|
|
26
|
+
const validAddressesInfo = recipients
|
|
27
|
+
.map(recipient => validate(recipient.address) && getAddressInfo(recipient.address))
|
|
28
|
+
.filter(Boolean) as AddressInfo[];
|
|
29
|
+
|
|
30
|
+
function getTxOutputsLengthByPaymentType() {
|
|
31
|
+
return validAddressesInfo.reduce(
|
|
32
|
+
(acc, { type }) => {
|
|
33
|
+
acc[type] = (acc[type] || 0) + 1;
|
|
34
|
+
return acc;
|
|
35
|
+
},
|
|
36
|
+
{} as Record<AddressType, number>
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const outputTypesCount = getTxOutputsLengthByPaymentType();
|
|
41
|
+
|
|
42
|
+
// Add a change address if not sending max (defaults to p2wpkh)
|
|
43
|
+
if (!isSendMax) {
|
|
44
|
+
outputTypesCount[AddressType.p2wpkh] = (outputTypesCount[AddressType.p2wpkh] || 0) + 1;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Prepare the output data map for consumption by the txSizer
|
|
48
|
+
const outputsData = Object.entries(outputTypesCount).reduce(
|
|
49
|
+
(acc, [type, count]) => {
|
|
50
|
+
acc[type + '_output_count'] = count;
|
|
51
|
+
return acc;
|
|
52
|
+
},
|
|
53
|
+
{} as Record<string, number>
|
|
54
|
+
);
|
|
55
|
+
|
|
56
|
+
const txSizer = new BtcSizeFeeEstimator();
|
|
57
|
+
const sizeInfo = txSizer.calcTxSize({
|
|
58
|
+
input_script: 'p2wpkh',
|
|
59
|
+
input_count: inputLength,
|
|
60
|
+
...outputsData,
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
return sizeInfo;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function getSpendableAmount({
|
|
67
|
+
utxos,
|
|
68
|
+
feeRate,
|
|
69
|
+
recipients,
|
|
70
|
+
}: {
|
|
71
|
+
utxos: Utxo[];
|
|
72
|
+
feeRate: number;
|
|
73
|
+
recipients: TransferRecipient[];
|
|
74
|
+
}) {
|
|
75
|
+
const balance = utxos.map(utxo => utxo.value).reduce((prevVal, curVal) => prevVal + curVal, 0);
|
|
76
|
+
|
|
77
|
+
const size = getSizeInfo({
|
|
78
|
+
inputLength: utxos.length,
|
|
79
|
+
recipients,
|
|
80
|
+
});
|
|
81
|
+
const fee = Math.ceil(size.txVBytes * feeRate);
|
|
82
|
+
const bigNumberBalance = BigNumber(balance);
|
|
83
|
+
return {
|
|
84
|
+
spendableAmount: BigNumber.max(0, bigNumberBalance.minus(fee)),
|
|
85
|
+
fee,
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Check if the spendable amount drops when adding a utxo
|
|
90
|
+
export function filterUneconomicalUtxos({
|
|
91
|
+
utxos,
|
|
92
|
+
feeRate,
|
|
93
|
+
recipients,
|
|
94
|
+
}: {
|
|
95
|
+
utxos: Utxo[];
|
|
96
|
+
feeRate: number;
|
|
97
|
+
recipients: TransferRecipient[];
|
|
98
|
+
}) {
|
|
99
|
+
const { spendableAmount: fullSpendableAmount } = getSpendableAmount({
|
|
100
|
+
utxos,
|
|
101
|
+
feeRate,
|
|
102
|
+
recipients,
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
const filteredUtxos = utxos
|
|
106
|
+
.filter(utxo => utxo.value >= BTC_P2WPKH_DUST_AMOUNT)
|
|
107
|
+
.filter(utxo => {
|
|
108
|
+
// Calculate spendableAmount without that utxo
|
|
109
|
+
const { spendableAmount } = getSpendableAmount({
|
|
110
|
+
utxos: utxos.filter(u => u.txid !== utxo.txid),
|
|
111
|
+
feeRate,
|
|
112
|
+
recipients,
|
|
113
|
+
});
|
|
114
|
+
// If fullSpendableAmount is greater, do not use that utxo
|
|
115
|
+
return spendableAmount.toNumber() < fullSpendableAmount.toNumber();
|
|
116
|
+
});
|
|
117
|
+
return filteredUtxos;
|
|
118
|
+
}
|