Skip to content

Commit 9755fa8

Browse files
committed
Move fee calculation to Transaction class and enhance coin selection
1 parent 607dbcb commit 9755fa8

16 files changed

+330
-180
lines changed

coinlib/CHANGELOG.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,16 @@ secp256k1-coinlib fork.
1313
- Adds `Locktime` abstractions for transactions. Transactions have
1414
`.isUnlocked` to determine if the transaction is available for block
1515
inclusion.
16+
- `InputCandidate.defaultSigHash` is moved to `TaprootKeyInput` and
17+
`TaprootSingleScriptSigInput` and `defaultSignedSize` is removed from
18+
Taproot inputs.
19+
- The `signedSize` of inputs is made more accurate.
20+
- Moves fee calculation logic to `Transaction` with `calculateSignedSize`,
21+
`calculateFee`, `signedSize` and `fee`.
22+
- Adds `skipSizeCheck` to `Transaction` constructor.
1623
- Moves to underlying secp256k1-coinlib.
1724
- Removed dependency to wasm_interop that had a broken js dependency.
25+
- The efficiency of `CoinSelection` is greatly improved.
1826
- `Writable.toBytes()` returns a copy of the cached bytes to avoid mutation.
1927
- Fixes `extraEntropy` being ignored for `Secp256k1Base.schnorrSign`.
2028

coinlib/lib/src/crypto/random.dart

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ Uint8List generateRandomBytes(int size) {
1414
return bytes;
1515
}
1616

17-
List<T> insertRandom<T>(List<T> list, T element) {
17+
List<T> insertRandom<T>(Iterable<T> list, T element) {
1818
final newList = List<T>.from(list);
1919
newList.insert(Random.secure().nextInt(newList.length+1), element);
2020
return newList;

coinlib/lib/src/tx/coin_selection.dart

Lines changed: 71 additions & 90 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,8 @@
1-
import 'package:coinlib/src/common/serial.dart';
1+
import 'package:coinlib/src/common/bigints.dart';
22
import 'package:coinlib/src/crypto/random.dart';
33
import 'package:coinlib/src/scripts/program.dart';
4-
import 'package:coinlib/src/tx/inputs/witness_input.dart';
54
import 'package:collection/collection.dart';
65
import 'inputs/input.dart';
7-
import 'inputs/taproot_input.dart';
86
import 'locktime.dart';
97
import 'output.dart';
108
import 'transaction.dart';
@@ -18,20 +16,12 @@ class InputCandidate {
1816
final Input input;
1917
/// Value of UTXO to be spent
2018
final BigInt value;
21-
/// True if it is known that the default sighash type is being used which
22-
/// allows one less byte to be used for Taproot signatures.
23-
final bool defaultSigHash;
2419

2520
/// Provides an [input] alongside the [value] being spent that may be
2621
/// selected.
27-
///
28-
/// [defaultSigHash] can be set to true if it is known that a Taproot input
29-
/// will definitely be signed with SIGHASH_DEFAULT. The fee calculation will
30-
/// be incorrect if this is set for a non-default sighash type.
3122
InputCandidate({
3223
required this.input,
3324
required this.value,
34-
this.defaultSigHash = false,
3525
});
3626

3727
}
@@ -55,31 +45,19 @@ class CoinSelection {
5545
late final BigInt inputValue;
5646
/// The total value of all recipient outputs
5747
late final BigInt recipientValue;
58-
/// The fee to be paid by the transaction
59-
late final BigInt fee;
6048
/// The value of the change output. This is 0 for a changeless transaction or
6149
/// negative if there aren't enough funds.
6250
late final BigInt changeValue;
6351
/// The maximum size of the transaction after being fully signed
64-
late final int signedSize;
65-
66-
int _sizeGivenChange(int fixedSize, bool includeChange)
67-
=> fixedSize
68-
+ recipients.fold(0, (acc, output) => acc + output.size)
69-
+ (includeChange ? Output.fromProgram(BigInt.zero, changeProgram).size : 0)
70-
+ MeasureWriter.varIntSizeOfInt(
71-
recipients.length + (includeChange ? 1 : 0),
72-
) as int;
73-
74-
BigInt _feeForSize(int size) {
75-
final feeForSize = feePerKb * BigInt.from(size) ~/ BigInt.from(1000);
76-
return feeForSize.compareTo(minFee) > 0 ? feeForSize : minFee;
77-
}
52+
late int signedSize;
7853

7954
/// Selects all the inputs from [selected] to send to the [recipients] outputs
8055
/// and provide change to the [changeProgram]. The [feePerKb] specifies the
8156
/// required fee in sats per KB with a minimum fee specified with
8257
/// [minFee]. The [minChange] is the minimum allowed change.
58+
///
59+
/// Will throw [ArgumentError] if a [selected] input does not have a
60+
/// calculable size.
8361
CoinSelection({
8462
this.version = Transaction.currentVersion,
8563
required Iterable<InputCandidate> selected,
@@ -97,63 +75,42 @@ class CoinSelection {
9775
}
9876

9977
// Get input and recipient values
100-
inputValue = selected
101-
.fold(BigInt.zero, (acc, candidate) => acc + candidate.value);
102-
recipientValue = recipients
103-
.fold(BigInt.zero, (acc, output) => acc + output.value);
104-
105-
final isWitness = selected.any(
106-
(candidate) => candidate.input is WitnessInput,
107-
);
108-
109-
// Get unchanging size
110-
final int fixedSize
111-
// Version and locktime
112-
= 8
113-
// Add witness marker and flag
114-
+ (isWitness ? 2 : 0)
115-
// Fully signed inputs
116-
+ MeasureWriter.varIntSizeOfInt(selected.length)
117-
+ selected.fold(
118-
0,
119-
(acc, candidate) {
120-
final input = candidate.input;
121-
final inputSize = input is TaprootInput && candidate.defaultSigHash
122-
? input.defaultSignedSize
123-
: input.signedSize;
124-
return acc + inputSize!;
125-
}
126-
);
127-
128-
// Determine size and fee with change
129-
final sizeWithChange = _sizeGivenChange(fixedSize, true);
130-
final feeWithChange = _feeForSize(sizeWithChange);
131-
final includedChangeValue = inputValue - recipientValue - feeWithChange;
132-
133-
// If change is under the required minimum, remove the change output
134-
if (includedChangeValue.compareTo(minChange) < 0) {
135-
136-
final changelessSize = _sizeGivenChange(fixedSize, false);
137-
final feeForSize = _feeForSize(changelessSize);
138-
final excess = inputValue - recipientValue - feeForSize;
139-
140-
if (!excess.isNegative) {
141-
// Exceeded without change. Fee is the input value minus the recipient
142-
// value
143-
signedSize = changelessSize;
144-
fee = inputValue - recipientValue;
145-
changeValue = BigInt.zero;
146-
return;
147-
}
148-
// Else haven't met requirement
149-
78+
inputValue = addBigInts(selected.map((candidate) => candidate.value));
79+
recipientValue = addBigInts(recipients.map((output) => output.value));
80+
final inputExcess = inputValue - recipientValue;
81+
82+
final inputs = selected.map((candidate) => candidate.input).toList();
83+
final isWitness = Transaction.inputsHaveWitness(inputs);
84+
final outputProgram = Output.fromProgram(BigInt.zero, changeProgram);
85+
86+
int getSize(bool withChange) => Transaction.calculateSignedSize(
87+
inputs: inputs,
88+
outputs: [...recipients, if (withChange) outputProgram ],
89+
isWitness: isWitness,
90+
)!; // Assert null as all inputs will have signedSize as tested above
91+
92+
BigInt getFeeExcess(int size)
93+
=> inputExcess - Transaction.calculateFee(size, feePerKb, minFee);
94+
95+
// Try to create change tranasction first
96+
final sizeWithChange = getSize(true);
97+
final change = getFeeExcess(sizeWithChange);
98+
99+
// Transaction with change is successful if the change is above the minimum
100+
if (change.compareTo(minChange) >= 0) {
101+
signedSize = sizeWithChange;
102+
changeValue = change;
103+
return;
150104
}
151105

152-
// Either haven't met requirement, or have met requirement with change so
153-
// provide details of change-containing transaction
154-
signedSize = sizeWithChange;
155-
fee = feeWithChange;
156-
changeValue = includedChangeValue;
106+
// Else target the transaction without the change output
107+
signedSize = getSize(false);
108+
final feeExcess = getFeeExcess(signedSize);
109+
110+
// Clamp the change value to no more than 0 as it is a changeless
111+
// transaction.
112+
// If the excess is negative, it is a shortfall.
113+
changeValue = feeExcess.isNegative ? feeExcess : BigInt.zero;
157114

158115
}
159116

@@ -203,9 +160,11 @@ class CoinSelection {
203160
/// in the order that they are given until the required amount has been
204161
/// reached. If there are not enough coins, all shall be selected and
205162
/// [enoughFunds] shall be false.
163+
///
206164
/// If [randomise] is set to true, the order of inputs shall be randomised
207165
/// after being selected. This is useful for candidates that are not already
208166
/// randomised as it may avoid giving clues to the algorithm being used.
167+
///
209168
/// The algorithm will only take upto 6800 candidates by default to avoid
210169
/// taking too long and due to size limitations. This can be changed with
211170
/// [maxCandidates].
@@ -234,15 +193,29 @@ class CoinSelection {
234193
locktime: locktime,
235194
);
236195

196+
if (candidates.isEmpty) return trySelection([]);
197+
237198
// Restrict number of candidates due to size limitation and for efficiency
238199
final list = candidates.take(maxCandidates).toList();
239200

240-
CoinSelection selection = trySelection([]);
241-
for (int i = 0; i < list.length; i++) {
242-
selection = trySelection(list.take(i+1));
243-
if (selection.enoughFunds) break;
201+
// Use binary search to find the required amount
202+
CoinSelection search(int left, int right, CoinSelection? cacheRight) {
203+
204+
if (left == right) {
205+
return cacheRight ?? trySelection(list.take(left));
206+
}
207+
208+
final middle = (left + right) ~/ 2;
209+
final middleSelection = trySelection(list.take(middle));
210+
211+
return middleSelection.enoughFunds
212+
? search(left, middle, middleSelection)
213+
: search(middle+1, right, cacheRight);
214+
244215
}
245216

217+
final selection = search(1, list.length, null);
218+
246219
return randomise
247220
? trySelection(selection.selected.toList()..shuffle())
248221
: selection;
@@ -303,17 +276,23 @@ class CoinSelection {
303276
/// value and fee, or [TransactionTooLarge] if the resulting signed
304277
/// transaction would be too large.
305278
Transaction get transaction {
279+
306280
if (!enoughFunds) throw InsufficientFunds();
307281
if (tooLarge) throw TransactionTooLarge();
282+
308283
return Transaction(
309284
version: version,
310285
inputs: selected.map((candidate) => candidate.input),
311-
outputs: changeless ? recipients : insertRandom(
312-
recipients,
313-
Output.fromProgram(changeValue, changeProgram),
314-
),
286+
outputs: changeless
287+
? recipients
288+
: insertRandom(
289+
recipients,
290+
Output.fromProgram(changeValue, changeProgram),
291+
),
315292
locktime: locktime,
293+
skipSizeCheck: true,
316294
);
295+
317296
}
318297

319298
/// True when the input value covers the outputs and fee
@@ -324,5 +303,7 @@ class CoinSelection {
324303
bool get tooLarge => signedSize > Transaction.maxSize;
325304
/// True if a signable solution has been found
326305
bool get ready => enoughFunds && !tooLarge;
306+
/// The fee to be paid by the transaction
307+
BigInt get fee => inputValue - recipientValue - changeValue;
327308

328309
}

coinlib/lib/src/tx/inputs/p2pkh_input.dart

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -21,20 +21,25 @@ class P2PKHInput extends LegacyInput with PKHInput {
2121
final ECPublicKey publicKey;
2222
@override
2323
final ECDSAInputSignature? insig;
24+
2425
@override
25-
final int? signedSize = 147;
26+
/// For an input without a signature, it is assumed the signature will be a
27+
/// low-R signature with a size of 71 bytes.
28+
final int signedSize;
2629

2730
P2PKHInput({
2831
required super.prevOut,
2932
required this.publicKey,
3033
this.insig,
3134
super.sequence = InputSequence.enforceLocktime,
32-
}) : super(
33-
scriptSig: Script([
34-
if (insig != null) ScriptPushData(insig.bytes),
35-
ScriptPushData(publicKey.data),
36-
]).compiled,
37-
);
35+
}) :
36+
signedSize = PKHInput.signedSizeCalc(publicKey, insig),
37+
super(
38+
scriptSig: Script([
39+
if (insig != null) ScriptPushData(insig.bytes),
40+
ScriptPushData(publicKey.data),
41+
]).compiled,
42+
);
3843

3944
/// Checks if the [RawInput] matches the expected format for a [P2PKHInput],
4045
/// with or without a signature. If it does it returns a [P2PKHInput] for the

coinlib/lib/src/tx/inputs/p2sh_multisig_input.dart

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -200,12 +200,16 @@ class P2SHMultisigInput extends LegacyInput {
200200

201201
int get _signedScriptSize
202202
=> 1 // Extra 0
203-
+ program.threshold*73 // Add 73 bytes per signature
203+
+ sigs.fold(0, (acc, sig) => acc + sig.bytes.length + 1)
204+
// For unadded signatures assume 71 bytes plus push data
205+
+ (program.threshold-sigs.length)*72
204206
// Determine the length of the program pushdata by actually compiling it.
205207
// Not the most efficient but the simplest solution.
206-
+ ScriptPushData(program.script.compiled).compiled.length;
208+
+ ScriptPushData(program.script.compiled).compiled.length
209+
as int;
207210

208211
@override
212+
/// Assumes low-R 71-byte signatures by default
209213
int? get signedSize
210214
=> 40 // Outpoint plus sequence
211215
+ _signedScriptSize

coinlib/lib/src/tx/inputs/p2wpkh_input.dart

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -21,20 +21,23 @@ class P2WPKHInput extends LegacyWitnessInput with PKHInput {
2121
final ECPublicKey publicKey;
2222
@override
2323
final ECDSAInputSignature? insig;
24+
2425
@override
25-
final int? signedSize = 147;
26+
final int signedSize;
2627

2728
P2WPKHInput({
2829
required super.prevOut,
2930
required this.publicKey,
3031
this.insig,
3132
super.sequence = InputSequence.enforceLocktime,
32-
}) : super(
33-
witness: [
34-
if (insig != null) insig.bytes,
35-
publicKey.data,
36-
],
37-
);
33+
}) :
34+
signedSize = PKHInput.signedSizeCalc(publicKey, insig),
35+
super(
36+
witness: [
37+
if (insig != null) insig.bytes,
38+
publicKey.data,
39+
],
40+
);
3841

3942
/// Checks if the [raw] input and [witness] data match the expected format for
4043
/// a P2WPKHInput, with or without a signature. If it does it returns a

coinlib/lib/src/tx/inputs/pkh_input.dart

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,16 @@ import 'input_signature.dart';
99
/// [ECDSAInputSignature] required in these inputs.
1010
abstract mixin class PKHInput {
1111

12+
/// Gives the signed size of a P2PKH or P2WPKH input.
13+
static int signedSizeCalc(ECPublicKey publicKey, ECDSAInputSignature? insig)
14+
=>
15+
// 41 basic size
16+
// 2 for pushdata/varint of signature and key
17+
41 + 2
18+
+ publicKey.data.length
19+
// Assume signature is 71 with low-R by default
20+
+ (insig?.bytes.length ?? 71);
21+
1222
ECPublicKey get publicKey;
1323
ECDSAInputSignature? get insig;
1424
PKHInput addSignature(ECDSAInputSignature insig);

coinlib/lib/src/tx/inputs/taproot_input.dart

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,4 @@ abstract class TaprootInput extends WitnessInput {
2525
details.hashType,
2626
);
2727

28-
/// The signed size when SIGHASH_DEFAULT is used for all signatures
29-
int? get defaultSignedSize => signedSize;
30-
3128
}

0 commit comments

Comments
 (0)