From 7eeaaee7101e98f4045a5326cc3aa60136942b83 Mon Sep 17 00:00:00 2001 From: Simon Bettison Date: Sat, 7 Mar 2026 01:22:42 +0000 Subject: [PATCH 1/5] feat: add FeeModel base class (#127) Define BSV::Transaction::FeeModel with compute_fee(transaction) contract. Subclasses must implement to return fee in satoshis. Implements sub-task #127 of HLR #94. Co-Authored-By: Claude --- lib/bsv/transaction.rb | 1 + lib/bsv/transaction/fee_model.rb | 28 ++++++++++++++++++++++++++ spec/bsv/transaction/fee_model_spec.rb | 27 +++++++++++++++++++++++++ 3 files changed, 56 insertions(+) create mode 100644 lib/bsv/transaction/fee_model.rb create mode 100644 spec/bsv/transaction/fee_model_spec.rb diff --git a/lib/bsv/transaction.rb b/lib/bsv/transaction.rb index 65321997..ecfe2577 100644 --- a/lib/bsv/transaction.rb +++ b/lib/bsv/transaction.rb @@ -13,6 +13,7 @@ module Transaction autoload :TransactionInput, 'bsv/transaction/transaction_input' autoload :Sighash, 'bsv/transaction/sighash' autoload :MerklePath, 'bsv/transaction/merkle_path' + autoload :FeeModel, 'bsv/transaction/fee_model' autoload :ChainTracker, 'bsv/transaction/chain_tracker' autoload :ChainTrackers, 'bsv/transaction/chain_trackers' autoload :Beef, 'bsv/transaction/beef' diff --git a/lib/bsv/transaction/fee_model.rb b/lib/bsv/transaction/fee_model.rb new file mode 100644 index 00000000..089fddc0 --- /dev/null +++ b/lib/bsv/transaction/fee_model.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +module BSV + module Transaction + # Base class for fee models that compute transaction fees. + # + # Fee models determine how many satoshis a transaction should pay in fees + # based on its size or other properties. Subclasses must implement + # {#compute_fee}. + # + # @example Implementing a custom fee model + # class FixedFee < BSV::Transaction::FeeModel + # def compute_fee(_transaction) + # 1000 + # end + # end + class FeeModel + # Compute the fee for a transaction. + # + # @param transaction [Transaction] the transaction to compute the fee for + # @return [Integer] the fee in satoshis + # @raise [NotImplementedError] if not overridden by a subclass + def compute_fee(_transaction) + raise NotImplementedError, "#{self.class}#compute_fee must be implemented" + end + end + end +end diff --git a/spec/bsv/transaction/fee_model_spec.rb b/spec/bsv/transaction/fee_model_spec.rb new file mode 100644 index 00000000..f131127b --- /dev/null +++ b/spec/bsv/transaction/fee_model_spec.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +RSpec.describe BSV::Transaction::FeeModel do + subject(:model) { described_class.new } + + describe '#compute_fee' do + it 'raises NotImplementedError' do + tx = BSV::Transaction::Transaction.new + expect { model.compute_fee(tx) } + .to raise_error(NotImplementedError, /compute_fee must be implemented/) + end + end + + context 'with a concrete subclass' do + let(:concrete_class) do + Class.new(described_class) do + def compute_fee(_transaction) + 500 + end + end + end + + it 'returns the computed fee' do + expect(concrete_class.new.compute_fee(nil)).to eq(500) + end + end +end From 147b1fd2cdcb3bfa15d0265db6f021889ff1f292 Mon Sep 17 00:00:00 2001 From: Simon Bettison Date: Sat, 7 Mar 2026 01:24:35 +0000 Subject: [PATCH 2/5] feat: add SatoshisPerKilobyte fee model (#128) Implements BSV::Transaction::FeeModels::SatoshisPerKilobyte with configurable sat/kB rate (default 50). Fee formula: ceil((size / 1000) * rate) matching all three reference SDKs. Also makes Transaction#estimated_size public so fee models can access it. Implements sub-task #128 of HLR #94. Co-Authored-By: Claude --- lib/bsv/transaction.rb | 1 + lib/bsv/transaction/fee_models.rb | 10 ++++ .../fee_models/satoshis_per_kilobyte.rb | 35 ++++++++++++ lib/bsv/transaction/transaction.rb | 44 ++++++++------- .../fee_models/satoshis_per_kilobyte_spec.rb | 56 +++++++++++++++++++ 5 files changed, 127 insertions(+), 19 deletions(-) create mode 100644 lib/bsv/transaction/fee_models.rb create mode 100644 lib/bsv/transaction/fee_models/satoshis_per_kilobyte.rb create mode 100644 spec/bsv/transaction/fee_models/satoshis_per_kilobyte_spec.rb diff --git a/lib/bsv/transaction.rb b/lib/bsv/transaction.rb index ecfe2577..7d3f5dc1 100644 --- a/lib/bsv/transaction.rb +++ b/lib/bsv/transaction.rb @@ -14,6 +14,7 @@ module Transaction autoload :Sighash, 'bsv/transaction/sighash' autoload :MerklePath, 'bsv/transaction/merkle_path' autoload :FeeModel, 'bsv/transaction/fee_model' + autoload :FeeModels, 'bsv/transaction/fee_models' autoload :ChainTracker, 'bsv/transaction/chain_tracker' autoload :ChainTrackers, 'bsv/transaction/chain_trackers' autoload :Beef, 'bsv/transaction/beef' diff --git a/lib/bsv/transaction/fee_models.rb b/lib/bsv/transaction/fee_models.rb new file mode 100644 index 00000000..fb895e63 --- /dev/null +++ b/lib/bsv/transaction/fee_models.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +module BSV + module Transaction + # Namespace for fee model implementations. + module FeeModels + autoload :SatoshisPerKilobyte, 'bsv/transaction/fee_models/satoshis_per_kilobyte' + end + end +end diff --git a/lib/bsv/transaction/fee_models/satoshis_per_kilobyte.rb b/lib/bsv/transaction/fee_models/satoshis_per_kilobyte.rb new file mode 100644 index 00000000..f28d9fbc --- /dev/null +++ b/lib/bsv/transaction/fee_models/satoshis_per_kilobyte.rb @@ -0,0 +1,35 @@ +# frozen_string_literal: true + +module BSV + module Transaction + module FeeModels + # Fee model that charges a configurable number of satoshis per kilobyte. + # + # Uses the transaction's estimated size (from templates for unsigned inputs, + # actual size for signed inputs) to compute the fee. + # + # @example + # model = BSV::Transaction::FeeModels::SatoshisPerKilobyte.new(value: 100) + # fee = model.compute_fee(transaction) # => 25 (for a ~250 byte tx) + class SatoshisPerKilobyte < FeeModel + # @return [Integer] satoshis per kilobyte rate + attr_reader :value + + # @param value [Integer] satoshis per kilobyte (default: 50) + def initialize(value: 50) + super() + @value = value + end + + # Compute the fee for a transaction based on its estimated size. + # + # @param transaction [Transaction] the transaction to compute the fee for + # @return [Integer] the fee in satoshis + def compute_fee(transaction) + size = transaction.estimated_size + (size / 1000.0 * @value).ceil + end + end + end + end +end diff --git a/lib/bsv/transaction/transaction.rb b/lib/bsv/transaction/transaction.rb index d8ef2d82..c6bc5ec6 100644 --- a/lib/bsv/transaction/transaction.rb +++ b/lib/bsv/transaction/transaction.rb @@ -524,6 +524,31 @@ def estimated_fee(satoshis_per_byte: 0.5) (size * satoshis_per_byte).ceil end + # Estimate the serialised transaction size in bytes. + # + # Uses actual unlocking script size for signed inputs and template + # estimated length for unsigned inputs. + # + # @return [Integer] estimated size in bytes + def estimated_size + size = 4 # version + size += VarInt.encode(@inputs.length).bytesize + @inputs.each_with_index do |input, index| + size += if input.unlocking_script + input.to_binary.bytesize + elsif input.unlocking_script_template + script_len = input.unlocking_script_template.estimated_length(self, index) + 32 + 4 + VarInt.encode(script_len).bytesize + script_len + 4 + else + UNSIGNED_P2PKH_INPUT_SIZE + end + end + size += VarInt.encode(@outputs.length).bytesize + @outputs.each { |o| size += o.to_binary.bytesize } + size += 4 # lock_time + size + end + private ZERO_HASH = "\x00".b * 32 @@ -582,25 +607,6 @@ def collect_ancestors_recursive(tx, seen, result) seen[txid] = true result << tx end - - def estimated_size - size = 4 # version - size += VarInt.encode(@inputs.length).bytesize - @inputs.each_with_index do |input, index| - size += if input.unlocking_script - input.to_binary.bytesize - elsif input.unlocking_script_template - script_len = input.unlocking_script_template.estimated_length(self, index) - 32 + 4 + VarInt.encode(script_len).bytesize + script_len + 4 - else - UNSIGNED_P2PKH_INPUT_SIZE - end - end - size += VarInt.encode(@outputs.length).bytesize - @outputs.each { |o| size += o.to_binary.bytesize } - size += 4 # lock_time - size - end end end end diff --git a/spec/bsv/transaction/fee_models/satoshis_per_kilobyte_spec.rb b/spec/bsv/transaction/fee_models/satoshis_per_kilobyte_spec.rb new file mode 100644 index 00000000..6ad79c9c --- /dev/null +++ b/spec/bsv/transaction/fee_models/satoshis_per_kilobyte_spec.rb @@ -0,0 +1,56 @@ +# frozen_string_literal: true + +RSpec.describe BSV::Transaction::FeeModels::SatoshisPerKilobyte do + describe '#compute_fee' do + it 'computes fee for a 1 KB transaction at default rate' do + tx = instance_double(BSV::Transaction::Transaction, estimated_size: 1000) + model = described_class.new + + expect(model.compute_fee(tx)).to eq(50) # 1000/1000 * 50 + end + + it 'computes fee for a 250 byte transaction at default rate' do + tx = instance_double(BSV::Transaction::Transaction, estimated_size: 250) + model = described_class.new + + expect(model.compute_fee(tx)).to eq(13) # ceil(250/1000 * 50) = ceil(12.5) = 13 + end + + it 'computes fee with custom rate' do + tx = instance_double(BSV::Transaction::Transaction, estimated_size: 500) + model = described_class.new(value: 100) + + expect(model.compute_fee(tx)).to eq(50) # 500/1000 * 100 = 50 + end + + it 'rounds up to ensure minimum 1 satoshi fee' do + tx = instance_double(BSV::Transaction::Transaction, estimated_size: 1) + model = described_class.new(value: 1) + + expect(model.compute_fee(tx)).to eq(1) # ceil(1/1000 * 1) = ceil(0.001) = 1 + end + + it 'computes correct fee for large transaction' do + tx = instance_double(BSV::Transaction::Transaction, estimated_size: 10_000) + model = described_class.new(value: 50) + + expect(model.compute_fee(tx)).to eq(500) # 10000/1000 * 50 = 500 + end + end + + describe '#value' do + it 'defaults to 50 sat/kB' do + expect(described_class.new.value).to eq(50) + end + + it 'accepts a custom rate' do + expect(described_class.new(value: 200).value).to eq(200) + end + end + + describe 'inheritance' do + it 'inherits from FeeModel' do + expect(described_class.superclass).to eq(BSV::Transaction::FeeModel) + end + end +end From 1f0713c4d24ff46f8cd85ece2e93ef4d2a98edad Mon Sep 17 00:00:00 2001 From: Simon Bettison Date: Sat, 7 Mar 2026 01:30:22 +0000 Subject: [PATCH 3/5] feat: add Transaction#fee with change output adjustment (#129) Add TransactionOutput#change flag and Transaction#fee(model_or_value) that accepts a FeeModel, numeric fee, or nil (defaults to 50 sat/kB). Distributes change equally across change outputs; removes them if insufficient (excess goes to miners). Backwards-compatible with existing estimated_fee. Implements sub-task #129 of HLR #94. Co-Authored-By: Claude --- lib/bsv/transaction/transaction.rb | 49 +++++ lib/bsv/transaction/transaction_output.rb | 9 +- spec/bsv/transaction/transaction_fee_spec.rb | 179 +++++++++++++++++++ 3 files changed, 235 insertions(+), 2 deletions(-) create mode 100644 spec/bsv/transaction/transaction_fee_spec.rb diff --git a/lib/bsv/transaction/transaction.rb b/lib/bsv/transaction/transaction.rb index c6bc5ec6..b60f38ad 100644 --- a/lib/bsv/transaction/transaction.rb +++ b/lib/bsv/transaction/transaction.rb @@ -549,6 +549,23 @@ def estimated_size size end + # Compute the fee and distribute change across change outputs. + # + # Accepts a {FeeModel} instance, a numeric fee in satoshis, or nil + # (defaults to {FeeModels::SatoshisPerKilobyte} at 50 sat/kB). + # + # After computing the fee, distributes remaining satoshis equally + # across outputs marked as change. If insufficient change, removes + # all change outputs (excess goes to miners). + # + # @param model_or_fee [FeeModel, Integer, nil] fee model, fixed fee, or nil for default + # @return [self] for chaining + def fee(model_or_fee = nil) + fee_sats = compute_fee_sats(model_or_fee) + distribute_change(fee_sats) + self + end + private ZERO_HASH = "\x00".b * 32 @@ -607,6 +624,38 @@ def collect_ancestors_recursive(tx, seen, result) seen[txid] = true result << tx end + + def compute_fee_sats(model_or_fee) + case model_or_fee + when nil + FeeModels::SatoshisPerKilobyte.new.compute_fee(self) + when FeeModel + model_or_fee.compute_fee(self) + when Numeric + model_or_fee.ceil + else + raise ArgumentError, "expected FeeModel, Numeric, or nil; got #{model_or_fee.class}" + end + end + + def distribute_change(fee_sats) + change_outputs = @outputs.select(&:change) + return if change_outputs.empty? + + input_sats = @inputs.sum { |i| i.source_satoshis || 0 } + non_change_sats = @outputs.reject(&:change).sum(&:satoshis) + available = input_sats - non_change_sats - fee_sats + + if available <= change_outputs.length + @outputs.reject!(&:change) + else + per_output = available / change_outputs.length + remainder = available % change_outputs.length + change_outputs.each_with_index do |output, i| + output.satoshis = per_output + (i < remainder ? 1 : 0) + end + end + end end end end diff --git a/lib/bsv/transaction/transaction_output.rb b/lib/bsv/transaction/transaction_output.rb index 49b559c0..09c3802c 100644 --- a/lib/bsv/transaction/transaction_output.rb +++ b/lib/bsv/transaction/transaction_output.rb @@ -9,16 +9,21 @@ module Transaction # unlocking scripts. class TransactionOutput # @return [Integer] the output value in satoshis - attr_reader :satoshis + attr_accessor :satoshis # @return [Script::Script] the locking script (spending conditions) attr_reader :locking_script + # @return [Boolean] whether this output receives change + attr_accessor :change + # @param satoshis [Integer] output value in satoshis # @param locking_script [Script::Script] the locking script - def initialize(satoshis:, locking_script:) + # @param change [Boolean] whether this is a change output (default: false) + def initialize(satoshis:, locking_script:, change: false) @satoshis = satoshis @locking_script = locking_script + @change = change end # Serialise the output to its binary wire format. diff --git a/spec/bsv/transaction/transaction_fee_spec.rb b/spec/bsv/transaction/transaction_fee_spec.rb new file mode 100644 index 00000000..1e09fa0a --- /dev/null +++ b/spec/bsv/transaction/transaction_fee_spec.rb @@ -0,0 +1,179 @@ +# frozen_string_literal: true + +RSpec.describe BSV::Transaction::Transaction do + describe '#fee' do + let(:priv) { BSV::Primitives::PrivateKey.generate } + let(:lock_script) { BSV::Script::Script.p2pkh_lock(priv.public_key.hash160) } + + def build_tx(input_sats:, output_sats:, change_sats: 0, change_count: 1) + tx = described_class.new + input = BSV::Transaction::TransactionInput.new( + prev_tx_id: BSV::Primitives::Digest.sha256d('test'), + prev_tx_out_index: 0 + ) + input.source_satoshis = input_sats + input.source_locking_script = lock_script + input.unlocking_script_template = BSV::Transaction::P2PKH.new(priv) + tx.add_input(input) + + tx.add_output(BSV::Transaction::TransactionOutput.new( + satoshis: output_sats, + locking_script: lock_script + )) + + change_count.times do + tx.add_output(BSV::Transaction::TransactionOutput.new( + satoshis: change_sats, + locking_script: lock_script, + change: true + )) + end + + tx + end + + describe 'with default fee model' do + it 'computes fee and adjusts change output' do + tx = build_tx(input_sats: 100_000, output_sats: 50_000) + tx.fee + + change_outputs = tx.outputs.select(&:change) + expect(change_outputs.length).to eq(1) + expect(change_outputs[0].satoshis).to be > 0 + + total_out = tx.outputs.sum(&:satoshis) + expect(total_out).to be < 100_000 # fee deducted + end + end + + describe 'with SatoshisPerKilobyte model' do + it 'computes fee at custom rate' do + tx = build_tx(input_sats: 100_000, output_sats: 50_000) + model = BSV::Transaction::FeeModels::SatoshisPerKilobyte.new(value: 100) + tx.fee(model) + + change_outputs = tx.outputs.select(&:change) + expect(change_outputs.length).to eq(1) + expect(change_outputs[0].satoshis).to be > 0 + end + end + + describe 'with fixed numeric fee' do + it 'uses the exact fee amount' do + tx = build_tx(input_sats: 100_000, output_sats: 50_000) + tx.fee(1000) + + change_outputs = tx.outputs.select(&:change) + expect(change_outputs.length).to eq(1) + expect(change_outputs[0].satoshis).to eq(49_000) + end + end + + describe 'change distribution' do + it 'distributes change across multiple change outputs equally' do + tx = build_tx(input_sats: 100_000, output_sats: 50_000, change_count: 2) + tx.fee(1000) + + change_outputs = tx.outputs.select(&:change) + expect(change_outputs.length).to eq(2) + expect(change_outputs.sum(&:satoshis)).to eq(49_000) + # Equal split: 24500 each + expect(change_outputs[0].satoshis).to eq(24_500) + expect(change_outputs[1].satoshis).to eq(24_500) + end + + it 'distributes remainder to first outputs' do + tx = build_tx(input_sats: 100_000, output_sats: 50_000, change_count: 3) + tx.fee(1000) + + change_outputs = tx.outputs.select(&:change) + expect(change_outputs.length).to eq(3) + total = change_outputs.sum(&:satoshis) + expect(total).to eq(49_000) + # 49000 / 3 = 16333 remainder 1 + expect(change_outputs[0].satoshis).to eq(16_334) + expect(change_outputs[1].satoshis).to eq(16_333) + expect(change_outputs[2].satoshis).to eq(16_333) + end + + it 'removes change outputs when insufficient change' do + tx = build_tx(input_sats: 51_000, output_sats: 50_000) + tx.fee(1000) + + # 51000 - 50000 - 1000 = 0, which is <= 1 change output + expect(tx.outputs.select(&:change)).to be_empty + expect(tx.outputs.length).to eq(1) + end + + it 'removes change outputs when change is negative (overspend to miners)' do + tx = build_tx(input_sats: 50_500, output_sats: 50_000) + tx.fee(1000) + + # 50500 - 50000 - 1000 = -500, remove change outputs + expect(tx.outputs.select(&:change)).to be_empty + end + end + + describe 'no change outputs' do + it 'works without change outputs' do + tx = described_class.new + input = BSV::Transaction::TransactionInput.new( + prev_tx_id: BSV::Primitives::Digest.sha256d('test'), + prev_tx_out_index: 0 + ) + input.source_satoshis = 100_000 + input.source_locking_script = lock_script + input.unlocking_script_template = BSV::Transaction::P2PKH.new(priv) + tx.add_input(input) + tx.add_output(BSV::Transaction::TransactionOutput.new(satoshis: 99_000, locking_script: lock_script)) + + expect { tx.fee(1000) }.not_to raise_error + expect(tx.outputs.length).to eq(1) + expect(tx.outputs[0].satoshis).to eq(99_000) + end + end + + describe 'returns self for chaining' do + it 'returns the transaction' do + tx = build_tx(input_sats: 100_000, output_sats: 50_000) + expect(tx.fee(1000)).to eq(tx) + end + end + + describe 'backwards compatibility' do + it 'estimated_fee still works' do + tx = build_tx(input_sats: 100_000, output_sats: 50_000) + expect(tx.estimated_fee).to be_a(Integer) + expect(tx.estimated_fee).to be > 0 + end + end + + describe 'invalid argument' do + it 'raises ArgumentError for unexpected types' do + tx = build_tx(input_sats: 100_000, output_sats: 50_000) + expect { tx.fee('invalid') }.to raise_error(ArgumentError, /expected FeeModel/) + end + end + end + + describe BSV::Transaction::TransactionOutput do + describe '#change' do + it 'defaults to false' do + output = described_class.new( + satoshis: 1000, + locking_script: BSV::Script::Script.from_asm('OP_TRUE') + ) + expect(output.change).to be false + end + + it 'can be set to true' do + output = described_class.new( + satoshis: 1000, + locking_script: BSV::Script::Script.from_asm('OP_TRUE'), + change: true + ) + expect(output.change).to be true + end + end + end +end From c87da068122c36564ee1bee37e92f24685a51a3b Mon Sep 17 00:00:00 2001 From: Simon Bettison Date: Sat, 7 Mar 2026 01:37:05 +0000 Subject: [PATCH 4/5] test: add fee model conformance specs (#130) 16 specs validating fee formula against all three reference SDKs, contract conformance, default rate, change distribution, and Transaction#fee integration. Implements sub-task #130 of HLR #94. Co-Authored-By: Claude --- spec/conformance/fee_model_spec.rb | 128 +++++++++++++++++++++++++++++ 1 file changed, 128 insertions(+) create mode 100644 spec/conformance/fee_model_spec.rb diff --git a/spec/conformance/fee_model_spec.rb b/spec/conformance/fee_model_spec.rb new file mode 100644 index 00000000..96a2885b --- /dev/null +++ b/spec/conformance/fee_model_spec.rb @@ -0,0 +1,128 @@ +# frozen_string_literal: true + +require 'spec_helper' + +# Protocol conformance: Fee model interface and SatoshisPerKilobyte +# +# Cross-validates the Ruby SDK's fee model against the formula used by all +# three reference SDKs: ceil((size / 1000) * satoshis_per_kb). +# +# Reference implementations: +# TS SDK: src/transaction/fee-models/SatoshisPerKilobyte.ts +# Go SDK: transaction/fee_model/sats_per_kb.go +# Python: fee_models/satoshis_per_kilobyte.py + +RSpec.describe BSV::Transaction::FeeModels::SatoshisPerKilobyte do # rubocop:disable RSpec/MultipleDescribes + describe 'formula conformance: ceil((size / 1000) * rate)' do + def expected_fee(size_bytes, sat_per_kb) + (size_bytes / 1000.0 * sat_per_kb).ceil + end + + [ + { size: 1000, rate: 50, expected: 50, desc: '1 KB at 50 sat/kB' }, + { size: 250, rate: 50, expected: 13, desc: '250 bytes at 50 sat/kB (rounds up 12.5)' }, + { size: 500, rate: 100, expected: 50, desc: '500 bytes at 100 sat/kB' }, + { size: 1500, rate: 100, expected: 150, desc: '1.5 KB at 100 sat/kB' }, + { size: 10_000, rate: 50, expected: 500, desc: '10 KB at 50 sat/kB' }, + { size: 1, rate: 1, expected: 1, desc: 'minimum: 1 byte at 1 sat/kB (rounds up 0.001)' }, + { size: 226, rate: 50, expected: 12, desc: 'typical 1-in-1-out P2PKH at 50 sat/kB' }, + { size: 374, rate: 50, expected: 19, desc: 'typical 1-in-2-out P2PKH at 50 sat/kB' } + ].each do |v| + it "computes #{v[:expected]} sat for #{v[:desc]}" do + tx = instance_double(BSV::Transaction::Transaction, estimated_size: v[:size]) + model = described_class.new(value: v[:rate]) + + fee = model.compute_fee(tx) + + expect(fee).to eq(v[:expected]) + expect(fee).to eq(expected_fee(v[:size], v[:rate])) + end + end + end + + it 'inherits from FeeModel' do + expect(described_class.superclass).to eq(BSV::Transaction::FeeModel) + end + + it 'returns an Integer' do + tx = instance_double(BSV::Transaction::Transaction, estimated_size: 250) + expect(described_class.new.compute_fee(tx)).to be_an(Integer) + end + + it 'defaults to 50 sat/kB matching reference SDKs' do + expect(described_class.new.value).to eq(50) + end +end + +RSpec.describe BSV::Transaction::Transaction do + describe '#fee conformance' do + let(:priv) { BSV::Primitives::PrivateKey.generate } + let(:lock_script) { BSV::Script::Script.p2pkh_lock(priv.public_key.hash160) } + + def build_funded_tx(input_sats:, output_sats:) + tx = described_class.new + input = BSV::Transaction::TransactionInput.new( + prev_tx_id: BSV::Primitives::Digest.sha256d('conformance'), + prev_tx_out_index: 0 + ) + input.source_satoshis = input_sats + input.source_locking_script = lock_script + input.unlocking_script_template = BSV::Transaction::P2PKH.new(priv) + tx.add_input(input) + + tx.add_output(BSV::Transaction::TransactionOutput.new( + satoshis: output_sats, + locking_script: lock_script + )) + tx.add_output(BSV::Transaction::TransactionOutput.new( + satoshis: 0, + locking_script: lock_script, + change: true + )) + tx + end + + it 'assigns remaining satoshis to change output (matching SDK pattern)' do + tx = build_funded_tx(input_sats: 100_000, output_sats: 50_000) + tx.fee(500) + + change = tx.outputs.select(&:change) + expect(change.length).to eq(1) + expect(change[0].satoshis).to eq(49_500) # 100000 - 50000 - 500 + end + + it 'removes change outputs when dust (matching SDK pattern)' do + tx = build_funded_tx(input_sats: 50_100, output_sats: 50_000) + tx.fee(500) + + expect(tx.outputs.select(&:change)).to be_empty + end + + it 'accepts nil for default model' do + tx = build_funded_tx(input_sats: 100_000, output_sats: 50_000) + tx.fee + + change = tx.outputs.select(&:change) + expect(change.length).to eq(1) + expect(change[0].satoshis).to be > 0 + end + + it 'accepts FeeModel instance' do + tx = build_funded_tx(input_sats: 100_000, output_sats: 50_000) + model = BSV::Transaction::FeeModels::SatoshisPerKilobyte.new(value: 100) + tx.fee(model) + + change = tx.outputs.select(&:change) + expect(change.length).to eq(1) + expect(change[0].satoshis).to be > 0 + end + + it 'accepts numeric fee (backwards compatibility)' do + tx = build_funded_tx(input_sats: 100_000, output_sats: 50_000) + tx.fee(1000) + + change = tx.outputs.select(&:change) + expect(change[0].satoshis).to eq(49_000) + end + end +end From 1ab70c08405d30c49181b33dca53873a2460500c Mon Sep 17 00:00:00 2001 From: Simon Bettison Date: Sat, 7 Mar 2026 02:10:13 +0000 Subject: [PATCH 5/5] fix: raise on nil source_satoshis in fee change distribution Use total_input_satoshis (which raises ArgumentError on nil) instead of silently coercing nil to 0, which could cause silent fund loss to miners. Co-Authored-By: Claude Opus 4.6 --- lib/bsv/transaction/transaction.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/bsv/transaction/transaction.rb b/lib/bsv/transaction/transaction.rb index b60f38ad..9c200744 100644 --- a/lib/bsv/transaction/transaction.rb +++ b/lib/bsv/transaction/transaction.rb @@ -642,7 +642,7 @@ def distribute_change(fee_sats) change_outputs = @outputs.select(&:change) return if change_outputs.empty? - input_sats = @inputs.sum { |i| i.source_satoshis || 0 } + input_sats = total_input_satoshis non_change_sats = @outputs.reject(&:change).sum(&:satoshis) available = input_sats - non_change_sats - fee_sats