diff --git a/coinlib/lib/src/coinlib_base.dart b/coinlib/lib/src/coinlib_base.dart index b4f8478..09bafb3 100644 --- a/coinlib/lib/src/coinlib_base.dart +++ b/coinlib/lib/src/coinlib_base.dart @@ -32,6 +32,7 @@ export 'package:coinlib/src/scripts/programs/p2witness.dart'; export 'package:coinlib/src/scripts/programs/p2wpkh.dart'; export 'package:coinlib/src/scripts/programs/p2wsh.dart'; +export 'package:coinlib/src/tx/coin_selection.dart'; export 'package:coinlib/src/tx/transaction.dart'; export 'package:coinlib/src/tx/outpoint.dart'; export 'package:coinlib/src/tx/output.dart'; diff --git a/coinlib/lib/src/crypto/random.dart b/coinlib/lib/src/crypto/random.dart index a8ccb46..acceb1f 100644 --- a/coinlib/lib/src/crypto/random.dart +++ b/coinlib/lib/src/crypto/random.dart @@ -14,3 +14,8 @@ Uint8List generateRandomBytes(int size) { return bytes; } +List insertRandom(List list, T element) { + final newList = List.from(list); + newList.insert(Random.secure().nextInt(newList.length+1), element); + return newList; +} diff --git a/coinlib/lib/src/network_params.dart b/coinlib/lib/src/network_params.dart index bb4078c..c5bf042 100644 --- a/coinlib/lib/src/network_params.dart +++ b/coinlib/lib/src/network_params.dart @@ -1,15 +1,11 @@ class NetworkParams { - final int wifPrefix; - final int p2pkhPrefix; - final int p2shPrefix; - final int privHDPrefix; - final int pubHDPrefix; - final String bech32Hrp; - final String messagePrefix; + final int wifPrefix, p2pkhPrefix, p2shPrefix, privHDPrefix, pubHDPrefix; + final String bech32Hrp, messagePrefix; + final BigInt minFee, minOutput, feePerKb; - const NetworkParams({ + NetworkParams({ required this.wifPrefix, required this.p2pkhPrefix, required this.p2shPrefix, @@ -17,9 +13,12 @@ class NetworkParams { required this.pubHDPrefix, required this.bech32Hrp, required this.messagePrefix, + required this.minFee, + required this.minOutput, + required this.feePerKb, }); - static const mainnet = NetworkParams( + static final mainnet = NetworkParams( wifPrefix: 183, p2pkhPrefix: 55, p2shPrefix: 117, @@ -27,9 +26,12 @@ class NetworkParams { pubHDPrefix: 0x0488b21e, bech32Hrp: "pc", messagePrefix: "Peercoin Signed Message:\n", + minFee: BigInt.from(1000), + minOutput: BigInt.from(10000), + feePerKb: BigInt.from(10000), ); - static const testnet = NetworkParams( + static final testnet = NetworkParams( wifPrefix: 239, p2pkhPrefix: 111, p2shPrefix: 196, @@ -37,6 +39,9 @@ class NetworkParams { pubHDPrefix: 0x04358394, bech32Hrp: "tpc", messagePrefix: "Peercoin Signed Message:\n", + minFee: BigInt.from(1000), + minOutput: BigInt.from(10000), + feePerKb: BigInt.from(10000), ); } diff --git a/coinlib/lib/src/tx/coin_selection.dart b/coinlib/lib/src/tx/coin_selection.dart new file mode 100644 index 0000000..8b7357d --- /dev/null +++ b/coinlib/lib/src/tx/coin_selection.dart @@ -0,0 +1,153 @@ +import 'package:coinlib/src/common/serial.dart'; +import 'package:coinlib/src/crypto/random.dart'; +import 'package:coinlib/src/scripts/program.dart'; +import 'inputs/input.dart'; +import 'output.dart'; +import 'transaction.dart'; + +class InsufficientFunds implements Exception {} + +/// A candidate input to spend a UTXO with the UTXO value +class InputCandidate { + /// Input that can spend the UTXO + final Input input; + /// Value of UTXO to be spent + final BigInt value; + InputCandidate({ required this.input, required this.value }); +} + +/// Represents a selection of inputs to fund a transaction. If the inputs +/// provide sufficient value to cover the ouputs and fee for a transaction that +/// isn't too large, [ready] shall be true and it is possible to obtain a +/// signable [transaction]. +class CoinSelection { + + final int version; + final List selected; + final List recipients; + final Program changeProgram; + final BigInt feePerKb; + final BigInt minFee; + final BigInt minChange; + final int locktime; + + /// The total value of selected inputs + late final BigInt inputValue; + /// The total value of all recipient outputs + late final BigInt recipientValue; + /// The fee to be paid by the transaction + late final BigInt fee; + /// The value of the change output. This is 0 for a changeless transaction or + /// negative if there aren't enough funds. + late final BigInt changeValue; + /// The maximum size of the transaction after being fully signed + late final int signedSize; + + int _sizeGivenChange(int fixedSize, bool includeChange) + => fixedSize + + recipients.fold(0, (acc, output) => acc + output.size) + + (includeChange ? Output.fromProgram(BigInt.zero, changeProgram).size : 0) + + MeasureWriter.varIntSizeOfInt( + recipients.length + (includeChange ? 1 : 0), + ) as int; + + BigInt _feeForSize(int size) { + final feeForSize = feePerKb * BigInt.from(size) ~/ BigInt.from(1000); + return feeForSize.compareTo(minFee) > 0 ? feeForSize : minFee; + } + + /// Selects all the inputs from [selected] to send to the [recipients] outputs + /// and provide change to the [changeProgram]. The [feePerKb] specifies the + /// required fee in sats per KB with a minimum fee specified with + /// [minFee]. The [minChange] is the minimum allowed change. + CoinSelection({ + this.version = Transaction.currentVersion, + required Iterable selected, + required Iterable recipients, + required this.changeProgram, + required this.feePerKb, + required this.minFee, + required this.minChange, + this.locktime = 0, + }) : selected = List.unmodifiable(selected), + recipients = List.unmodifiable(recipients) { + + if (selected.any((candidate) => candidate.input.signedSize == null)) { + throw ArgumentError("Cannot select inputs without known max signed size"); + } + + // Get input and recipient values + inputValue = selected + .fold(BigInt.zero, (acc, candidate) => acc + candidate.value); + recipientValue = recipients + .fold(BigInt.zero, (acc, output) => acc + output.value); + + // Get unchanging size + final int fixedSize + // Version and locktime + = 8 + // Fully signed inputs + + MeasureWriter.varIntSizeOfInt(selected.length) + + selected.fold(0, (acc, candidate) => acc + candidate.input.signedSize!); + + // Determine size and fee with change + final sizeWithChange = _sizeGivenChange(fixedSize, true); + final feeWithChange = _feeForSize(sizeWithChange); + final includedChangeValue = inputValue - recipientValue - feeWithChange; + + // If change is under the required minimum, remove the change output + if (includedChangeValue.compareTo(minChange) < 0) { + + final changelessSize = _sizeGivenChange(fixedSize, false); + final feeForSize = _feeForSize(changelessSize); + final excess = inputValue - recipientValue - feeForSize; + + if (!excess.isNegative) { + // Exceeded without change. Fee is the input value minus the recipient + // value + signedSize = changelessSize; + fee = inputValue - recipientValue; + changeValue = BigInt.zero; + return; + } + // Else haven't met requirement + + } + + // Either haven't met requirement, or have met requirement with change so + // provide details of change-containing transaction + signedSize = sizeWithChange; + fee = feeWithChange; + changeValue = includedChangeValue; + + } + + /// Obtains the transaction with selected inputs and outputs including any + /// change at a random location, ready to be signed. Throws + /// [InsufficientFunds] if there is not enough input value to meet the output + /// value and fee, or [TransactionTooLarge] if the resulting signed + /// transaction would be too large. + Transaction get transaction { + if (!enoughFunds) throw InsufficientFunds(); + if (tooLarge) throw TransactionTooLarge(); + return Transaction( + version: version, + inputs: selected.map((candidate) => candidate.input), + outputs: changeless ? recipients : insertRandom( + recipients, + Output.fromProgram(changeValue, changeProgram), + ), + locktime: locktime, + ); + } + + /// True when the input value covers the outputs and fee + bool get enoughFunds => !changeValue.isNegative; + /// True when the change output is omitted + bool get changeless => changeValue.compareTo(BigInt.zero) == 0; + /// True if the resulting fully signed transaction will be too large + bool get tooLarge => signedSize > Transaction.maxSize; + /// True if a signable solution have been found + bool get ready => enoughFunds && !tooLarge; + +} diff --git a/coinlib/test/address_test.dart b/coinlib/test/address_test.dart index 7cb66f7..39cec1d 100644 --- a/coinlib/test/address_test.dart +++ b/coinlib/test/address_test.dart @@ -3,7 +3,7 @@ import 'package:coinlib/coinlib.dart'; import 'package:test/test.dart'; import 'vectors/taproot.dart'; -const wrongNetwork = NetworkParams( +final wrongNetwork = NetworkParams( wifPrefix: 0, p2pkhPrefix: 0xfa, p2shPrefix: 0xfb, @@ -11,6 +11,9 @@ const wrongNetwork = NetworkParams( pubHDPrefix: 0, bech32Hrp: "wrong", messagePrefix: "", + feePerKb: BigInt.from(10000), + minFee: BigInt.from(1000), + minOutput: BigInt.from(10000), ); expectBase58Equal(Base58Address addr, Base58Address expected) { @@ -366,6 +369,9 @@ void main() { wifPrefix: 0, p2shPrefix: 0, p2pkhPrefix: 0, privHDPrefix: 0, pubHDPrefix: 0, bech32Hrp: longHrp, messagePrefix: "", + feePerKb: BigInt.from(10000), + minFee: BigInt.from(1000), + minOutput: BigInt.from(10000), ), addr, ); diff --git a/coinlib/test/tx/coin_selection_test.dart b/coinlib/test/tx/coin_selection_test.dart new file mode 100644 index 0000000..c4719c8 --- /dev/null +++ b/coinlib/test/tx/coin_selection_test.dart @@ -0,0 +1,277 @@ +import 'dart:typed_data'; + +import 'package:coinlib/coinlib.dart'; +import 'package:test/test.dart'; +import '../vectors/keys.dart'; +import '../vectors/tx.dart'; + +class CoinSelectionVector { + final List inputValues; + final List outputValues; + final int expFee, expSignedSize; + final bool expEnoughFunds, expChangeless; + CoinSelectionVector({ + required this.inputValues, + required this.outputValues, + required this.expFee, + required this.expSignedSize, + required this.expEnoughFunds, + required this.expChangeless, + }); + + int get inputValue => inputValues.fold(0, (a,b) => a+b); + int get outputValue => outputValues.fold(0, (a,b) => a+b); + int get expChangeValue => inputValue - outputValue - expFee; + +} + +final coin = 1000000; +final feePerKb = BigInt.from(10000); +final minFee = BigInt.from(1000); +final minChange = BigInt.from(100000); + +final vectors = [ + // No inputs + CoinSelectionVector( + inputValues: [], + outputValues: [coin], + expFee: minFee.toInt(), + expSignedSize: 78, + expEnoughFunds: false, + expChangeless: false, + ), + // Covers outputs but not fee + CoinSelectionVector( + inputValues: [coin], + outputValues: [coin], + expFee: 2250, + expSignedSize: 225, + expEnoughFunds: false, + expChangeless: false, + ), + // Exact amount + CoinSelectionVector( + inputValues: [coin+1910], + outputValues: [coin], + expFee: 1910, + expSignedSize: 191, + expEnoughFunds: true, + expChangeless: true, + ), + // Just under exact amount + CoinSelectionVector( + inputValues: [coin+1910-1], + outputValues: [coin], + expFee: 2250, + expSignedSize: 225, + expEnoughFunds: false, + expChangeless: false, + ), + // Reach minimum change + CoinSelectionVector( + inputValues: [coin+2250+minChange.toInt()], + outputValues: [coin], + expFee: 2250, + expSignedSize: 225, + expEnoughFunds: true, + expChangeless: false, + ), + // Just under minimum change + CoinSelectionVector( + inputValues: [coin+2250+minChange.toInt()-1], + outputValues: [coin], + expFee: 2250+minChange.toInt()-1, + expSignedSize: 191, + expEnoughFunds: true, + expChangeless: true, + ), + // Change = 1 coin + CoinSelectionVector( + inputValues: [coin+2250+coin], + outputValues: [coin], + expFee: 2250, + expSignedSize: 225, + expEnoughFunds: true, + expChangeless: false, + ), + // Multi input and outputs + CoinSelectionVector( + inputValues: [coin, coin*2, coin*3, coin+7340], + outputValues: [coin, coin*2, coin*3], + expFee: 7340, + expSignedSize: 734, + expEnoughFunds: true, + expChangeless: false, + ), +]; + +void main() { + + group("CoinSelection()", () { + + late P2PKHInput input; + late P2PKH changeProgram; + setUpAll(() async { + await loadCoinlib(); + input = P2PKHInput( + prevOut: examplePrevOut, + publicKey: keyPairVectors[0].publicObj, + ); + changeProgram = P2PKH.fromPublicKey(examplePubkey); + }); + + InputCandidate candidateForValue(int value) => InputCandidate( + input: input, value: BigInt.from(value), + ); + + Output outputForValue(int value) => Output.fromProgram( + BigInt.from(value), exampleOutput.program!, + ); + + test("gives correct calculated fields", () { + // Assume feePerKb of 10000, min fee of 1000, min change of 100000 + + for (final vector in vectors) { + + final selection = CoinSelection( + selected: vector.inputValues.map(candidateForValue), + recipients: vector.outputValues.map(outputForValue), + changeProgram: changeProgram, + feePerKb: feePerKb, + minFee: minFee, + minChange: minChange, + ); + + expect(selection.fee.toInt(), vector.expFee); + expect(selection.changeValue.toInt(), vector.expChangeValue); + expect(selection.signedSize.toInt(), vector.expSignedSize); + expect(selection.enoughFunds, vector.expEnoughFunds); + expect(selection.changeless, vector.expChangeless); + expect(selection.tooLarge, false); + expect(selection.ready, vector.expEnoughFunds); + + if (vector.expEnoughFunds) { + + var tx = selection.transaction; + for (int i = 0; i < vector.inputValues.length; i++) { + tx = tx.sign(inputN: i, key: keyPairVectors[0].privateObj); + } + + expect( + tx.outputs.any( + (output) => output.program!.script.asm == changeProgram.script.asm + && output.value.toInt() == vector.expChangeValue, + ), + !vector.expChangeless, + ); + expect(tx.complete, true); + expect(tx.size, lessThanOrEqualTo(selection.signedSize)); + expect( + tx.outputs.fold(0, (i, output) => i + output.value.toInt()), + vector.outputValue + vector.expChangeValue, + ); + expect(tx.version, Transaction.currentVersion); + expect(tx.locktime, 0); + + } else { + expect( + () => selection.transaction, + throwsA(isA()), + ); + } + + } + + }); + + test("passes version and locktime", () { + + final selection = CoinSelection( + version: 1234, + locktime: 54, + selected: [candidateForValue(coin)], + recipients: [outputForValue(10000)], + changeProgram: changeProgram, + feePerKb: feePerKb, + minFee: minFee, + minChange: minChange, + ); + + final tx = selection.transaction; + expect(tx.version, 1234); + expect(tx.locktime, 54); + + }); + + test( + "requires signedSize for inputs", + () => expect( + () => CoinSelection( + selected: [ + candidateForValue(coin), + InputCandidate( + input: RawInput(prevOut: examplePrevOut, scriptSig: Uint8List(0)), + value: BigInt.from(coin), + ), + ], + recipients: [exampleOutput], + changeProgram: changeProgram, + feePerKb: feePerKb, + minFee: minFee, + minChange: minChange, + ), + throwsArgumentError, + ), + ); + + test("fields are immutable", () { + + final selected = [candidateForValue(coin)]; + final recipients = [outputForValue(coin)]; + + final selection = CoinSelection( + selected: selected, + recipients: recipients, + changeProgram: changeProgram, + feePerKb: feePerKb, + minFee: minFee, + minChange: minChange, + ); + + selected.add(candidateForValue(coin*2)); + recipients.add(outputForValue(coin*2)); + + expect( + () => selection.selected.add(candidateForValue(coin*3)), + throwsUnsupportedError, + ); + expect( + () => selection.recipients.add(outputForValue(coin*3)), + throwsUnsupportedError, + ); + + expect(selection.selected.length, 1); + expect(selection.recipients.length, 1); + + }); + + test("gives tooLarge when signedSize is over 1MB", () { + + final selection = CoinSelection( + selected: List.filled(6803, candidateForValue(coin)), + recipients: [exampleOutput], + changeProgram: changeProgram, + feePerKb: feePerKb, + minFee: minFee, + minChange: minChange, + ); + + expect(selection.tooLarge, true); + expect(selection.ready, false); + expect(() => selection.transaction, throwsA(isA())); + + }); + + }); + +}