Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions lib/bsv/transaction.rb
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ 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 :FeeModels, 'bsv/transaction/fee_models'
autoload :ChainTracker, 'bsv/transaction/chain_tracker'
autoload :ChainTrackers, 'bsv/transaction/chain_trackers'
autoload :Beef, 'bsv/transaction/beef'
Expand Down
28 changes: 28 additions & 0 deletions lib/bsv/transaction/fee_model.rb
Original file line number Diff line number Diff line change
@@ -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
10 changes: 10 additions & 0 deletions lib/bsv/transaction/fee_models.rb
Original file line number Diff line number Diff line change
@@ -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
35 changes: 35 additions & 0 deletions lib/bsv/transaction/fee_models/satoshis_per_kilobyte.rb
Original file line number Diff line number Diff line change
@@ -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
87 changes: 71 additions & 16 deletions lib/bsv/transaction/transaction.rb
Original file line number Diff line number Diff line change
Expand Up @@ -524,6 +524,48 @@ 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

# 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
Expand Down Expand Up @@ -583,23 +625,36 @@ def collect_ancestors_recursive(tx, seen, result)
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
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 = total_input_satoshis
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
size += VarInt.encode(@outputs.length).bytesize
@outputs.each { |o| size += o.to_binary.bytesize }
size += 4 # lock_time
size
end
end
end
Expand Down
9 changes: 7 additions & 2 deletions lib/bsv/transaction/transaction_output.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
27 changes: 27 additions & 0 deletions spec/bsv/transaction/fee_model_spec.rb
Original file line number Diff line number Diff line change
@@ -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
56 changes: 56 additions & 0 deletions spec/bsv/transaction/fee_models/satoshis_per_kilobyte_spec.rb
Original file line number Diff line number Diff line change
@@ -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
Loading