bsv-wallet 0.4.0 → 0.5.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: e7f0eba4cf58a9a63db79d2b731700f5d2d3ae04b82416ccc0288a1c5659a365
4
- data.tar.gz: 2453264034fbbe664ced66bd853e8e5b4a52eb668e87a6eb01fd71e5b589d61b
3
+ metadata.gz: bf66409b10a18ba1c43a05efb6e7909e4ba83a87d4421a68248f3f3174a8483d
4
+ data.tar.gz: 2ae27d6080353acb67b11e557e5e38c063abd243e1a36e2bc4c6ee6fa43fc885
5
5
  SHA512:
6
- metadata.gz: '092fa8e32a2ac7b7b4429c0fe715858ba16bba9869530cb97c4dfab0ff5730b2b2ff018da87a60cea5c5beaf7e527945c1158127e27806825db8ba29e895e2ed'
7
- data.tar.gz: 68c27f04fa860ce116c7ddaa1a76a25b4ccfc56ab4b8b3af454dbbf5b2f452c719b884df92497b01ff6b74dbcb4eced994fbe519a51443bf7200a87cdaed65df
6
+ metadata.gz: ad6a39da955d05c18460291ec2f1e7f56cae7c3a82d32da175477ea62c3e82bfd4dcce11bd9c4a53236b38b6091176badb55b264e05489e71722ab6ca8a651df
7
+ data.tar.gz: 3724b1e7cbb060a750eb865688b2859645a7837e05fd964214f2c8852ebd4aa515c8ee1afa51bd9fd50fd7bde0c9aa8299e0766057c40196b169e5f595db82fc
data/CHANGELOG-wallet.md CHANGED
@@ -5,6 +5,71 @@ All notable changes to the `bsv-wallet` gem are documented here.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/)
6
6
  and this gem adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## 0.5.0 — 2026-04-11
9
+
10
+ Native UTXO management, coin selection, and automatic change handling.
11
+ The wallet can now fund transactions end-to-end without an external
12
+ wallet server — `create_action` with outputs but no inputs triggers
13
+ automatic UTXO selection, fee estimation, and change generation.
14
+
15
+ ### Added
16
+
17
+ - **UTXO management pipeline** (#264, #265–#272): complete transaction-
18
+ funding pipeline with `CoinSelector` (exact-match, smallest-sufficient,
19
+ largest-first strategies), `ChangeGenerator` (BRC-29 multi-output change
20
+ with dust consolidation, randomised splits, pool-health-aware output
21
+ caps), and `FeeEstimator` (size-based sats/kB with ceil rounding).
22
+ - **`WhatsOnChainProvider`**: chain UTXO discovery via the WhatsOnChain API,
23
+ implementing `sync_utxos` for on-chain balance loading.
24
+ - **StorageAdapter extensions**: 6 new interface methods —
25
+ `update_output_state`, `lock_utxos`, `find_spendable_outputs`,
26
+ `release_stale_pending!`, `store_setting`, `find_setting`. All default
27
+ to `NotImplementedError`; `MemoryStore` and `FileStore` implement them.
28
+ - **Atomic UTXO locking**: `lock_utxos` checks and marks outputs as
29
+ `:pending` within a single mutex hold, closing the TOCTOU race where two
30
+ threads could select the same UTXO.
31
+ - **Pending state management**: outputs transition through `:spendable` →
32
+ `:pending` → `:spent`, with timestamps and caller references for stale
33
+ lock recovery.
34
+ - **Auto-funding in `create_action`**: when given outputs without inputs,
35
+ the wallet selects UTXOs, estimates fees, generates change outputs, and
36
+ locks inputs automatically.
37
+ - **`set_wallet_change_params`**: configures target UTXO count and value
38
+ for the change pool.
39
+
40
+ ### Fixed
41
+
42
+ - **Identity UTXO filter alignment**: `sync_utxos` stores
43
+ `derivation_type: :identity` but auto-fund filtered on a field that was
44
+ never set. Fixed the filter and added a signing branch that uses
45
+ `root_key` directly for identity UTXOs.
46
+ - **`tx_pos` bounds validation in `sync_utxos`**: untrusted WhatsOnChain
47
+ API responses with negative or out-of-bounds `tx_pos` could exploit
48
+ Ruby's negative array indexing or raise `NoMethodError` on nil. Now
49
+ validates before use.
50
+ - **Stale pending recovery rate-limited**: `release_stale_pending!` now
51
+ skips if invoked within 30 seconds, preventing O(n) output scans on
52
+ every `create_action` call.
53
+ - **`no_send` change outputs stored as `:pending`**, matching the TS SDK
54
+ where `noSend` outputs have `spendable: false`. Prevents them from
55
+ being auto-selected by concurrent `create_action` calls.
56
+ - **`abort_action` cleans up change outputs** created by the aborted
57
+ transaction, matching TS SDK behaviour.
58
+ - **Nil state guard**: `effective_state` handles `state: nil` (from NULL
59
+ DB column) without raising `NoMethodError`.
60
+ - **`change_params` wiring**: stored change params and pool size are now
61
+ wired into `converge_change` so `set_wallet_change_params` actually
62
+ affects auto-fund output count.
63
+
64
+ ### Changed
65
+
66
+ - **`FeeModel` consolidated into `FeeEstimator`**: `FeeModel` is now a
67
+ backward-compatible alias for `FeeEstimator`, which is the canonical
68
+ implementation with dust floor, varint handling, and extra-bytes
69
+ support.
70
+ - **`BRC29_PROTOCOL_ID` constant** replaces hardcoded protocol ID in
71
+ `internalize_payment`.
72
+
8
73
  ## 0.4.0 — 2026-04-10
9
74
 
10
75
  ### Added
@@ -32,6 +32,20 @@ module BSV
32
32
  def get_header(_height)
33
33
  raise NotImplementedError, "#{self.class}#get_header not implemented"
34
34
  end
35
+
36
+ # Returns unspent transaction outputs for the given address.
37
+ # @param _address [String] BSV address
38
+ # @return [Array<Hash>] array of hashes with :tx_hash, :tx_pos, :value keys
39
+ def get_utxos(_address)
40
+ raise NotImplementedError, "#{self.class}#get_utxos not implemented"
41
+ end
42
+
43
+ # Returns the raw transaction hex for the given txid.
44
+ # @param _txid [String] transaction ID (hex)
45
+ # @return [String] raw transaction hex string
46
+ def get_transaction(_txid)
47
+ raise NotImplementedError, "#{self.class}#get_transaction not implemented"
48
+ end
35
49
  end
36
50
  end
37
51
  end
@@ -0,0 +1,192 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'securerandom'
4
+
5
+ module BSV
6
+ module Wallet
7
+ # Generates BRC-29 change outputs for excess satoshis in a transaction.
8
+ #
9
+ # Handles the full lifecycle of change output creation:
10
+ # - Dust-floor enforcement (a change output is only worthwhile if its
11
+ # value covers at least 2× the cost to spend it later)
12
+ # - Optional distribution across multiple outputs with randomised splits
13
+ # - BRC-29 key derivation metadata attached to each output so the wallet
14
+ # can later identify and spend the change
15
+ #
16
+ # @example Single change output
17
+ # generator = BSV::Wallet::ChangeGenerator.new(key_deriver: deriver, fee_estimator: estimator)
18
+ # outputs = generator.generate(excess_satoshis: 5000)
19
+ # # => [{ satoshis: 5000, locking_script: ..., derivation_prefix: ..., ... }]
20
+ #
21
+ # @example Spread across multiple outputs
22
+ # generator = BSV::Wallet::ChangeGenerator.new(key_deriver: deriver, fee_estimator: estimator, max_outputs: 4)
23
+ # outputs = generator.generate(excess_satoshis: 10_000)
24
+ # # => up to 4 outputs summing to 10_000
25
+ class ChangeGenerator
26
+ # BRC-29 protocol identifier used for change key derivation.
27
+ BRC29_PROTOCOL_ID = [2, '3241645161d8'].freeze
28
+
29
+ # @return [Integer] maximum number of change outputs that may be created
30
+ attr_reader :max_outputs
31
+
32
+ # @param key_deriver [BSV::Wallet::KeyDeriver] derives child keys for each change output
33
+ # @param fee_estimator [BSV::Wallet::FeeEstimator] used to compute the dust floor
34
+ # (also accepted as +fee_model:+ for backwards compatibility)
35
+ # @param identity_key [String, nil] override identity key for change derivation;
36
+ # defaults to key_deriver.identity_key when nil
37
+ # @param max_outputs [Integer] upper bound on change outputs (default: 8)
38
+ # @raise [ArgumentError] if max_outputs is less than 1
39
+ def initialize(key_deriver:, fee_estimator: nil, fee_model: nil, identity_key: nil, max_outputs: 8)
40
+ raise ArgumentError, 'max_outputs must be at least 1' unless max_outputs >= 1
41
+ raise ArgumentError, 'provide fee_estimator: or fee_model:, not both' if fee_estimator && fee_model
42
+
43
+ @key_deriver = key_deriver
44
+ @fee_model = fee_estimator || fee_model || raise(ArgumentError, 'fee_estimator: (or fee_model:) is required')
45
+ @identity_key_override = identity_key
46
+ @max_outputs = max_outputs
47
+ end
48
+
49
+ # Generates change outputs for the given excess satoshis.
50
+ #
51
+ # Returns an empty array when the excess is zero or below the dust floor.
52
+ # Otherwise returns between 1 and {#max_outputs} output hashes, each with:
53
+ # - +:satoshis+ — value of the output
54
+ # - +:locking_script+ — P2PKH locking script for the derived key
55
+ # - +:derivation_prefix+ — random hex string for BRC-29 key derivation
56
+ # - +:derivation_suffix+ — random hex string for BRC-29 key derivation
57
+ # - +:sender_identity_key+ — wallet's own identity key (self-payment)
58
+ #
59
+ # When +pool_size:+ and +change_params:+ are both provided, the number of
60
+ # change outputs is adjusted based on the pool's health:
61
+ # - Pool below target count: produce more outputs (up to +max_outputs+)
62
+ # to build up the UTXO pool.
63
+ # - Pool at or above target count: produce fewer outputs (1-2) to avoid
64
+ # unnecessary fragmentation.
65
+ #
66
+ # @param excess_satoshis [Integer] satoshis remaining after inputs minus outputs minus fee
67
+ # @param num_existing_outputs [Integer] number of outputs already in the transaction
68
+ # (unused here but kept for interface symmetry with future callers)
69
+ # @param pool_size [Integer, nil] current number of spendable UTXOs in the pool
70
+ # @param change_params [Hash, nil] pool targets with +:count+ and +:satoshis+ keys
71
+ # @return [Array<Hash>] change output descriptors (may be empty)
72
+ def generate(excess_satoshis:, num_existing_outputs: 0, pool_size: nil, change_params: nil) # rubocop:disable Lint/UnusedMethodArgument
73
+ return [] if excess_satoshis <= 0
74
+ return [] if excess_satoshis < dust_floor
75
+
76
+ effective_max = pool_aware_max_outputs(pool_size, change_params)
77
+ amounts = split_amounts(excess_satoshis, effective_max)
78
+ amounts.map { |amount| build_output(amount) }
79
+ end
80
+
81
+ private
82
+
83
+ # The minimum value a change output must carry to be economically viable.
84
+ # Set to 2× the estimated fee to spend a single P2PKH input, ensuring the
85
+ # recipient never loses money by spending the output.
86
+ #
87
+ # @return [Integer] dust floor in satoshis (minimum 2)
88
+ def dust_floor
89
+ @dust_floor ||= [2, @fee_model.estimate(p2pkh_inputs: 1, p2pkh_outputs: 1) * 2].max
90
+ end
91
+
92
+ # Determines the effective maximum number of change outputs to produce,
93
+ # taking pool health into account when +pool_size+ and +change_params+
94
+ # are both present.
95
+ #
96
+ # - Pool below target: use full +max_outputs+ to build up the pool.
97
+ # - Pool at/above target: cap at 2 to avoid unnecessary fragmentation.
98
+ # - No params: return +max_outputs+ unchanged.
99
+ #
100
+ # @param pool_size [Integer, nil]
101
+ # @param change_params [Hash, nil] hash with +:count+ key
102
+ # @return [Integer]
103
+ def pool_aware_max_outputs(pool_size, change_params)
104
+ return @max_outputs unless pool_size && change_params
105
+
106
+ target_count = change_params[:count] || change_params['count']
107
+ return @max_outputs unless target_count
108
+
109
+ pool_size < target_count ? @max_outputs : [2, @max_outputs].min
110
+ end
111
+
112
+ # Splits +excess+ across up to +cap+ amounts, each at least
113
+ # {#dust_floor} satoshis. Any sub-dust remainder is folded into the largest
114
+ # output.
115
+ #
116
+ # @param excess [Integer] total satoshis to split
117
+ # @param cap [Integer] maximum number of outputs (defaults to +max_outputs+)
118
+ # @return [Array<Integer>] per-output satoshi amounts
119
+ def split_amounts(excess, cap = @max_outputs)
120
+ return [excess] if cap == 1
121
+
122
+ # Determine the maximum number of outputs we can afford given the dust floor.
123
+ max_possible = [excess / dust_floor, cap].min
124
+ num_outputs = [max_possible, 1].max
125
+
126
+ return [excess] if num_outputs == 1
127
+
128
+ distribute(excess, num_outputs)
129
+ end
130
+
131
+ # Randomly distributes +total+ satoshis across +count+ buckets, each
132
+ # guaranteed to be at least {#dust_floor} satoshis. Any remainder below the
133
+ # dust floor is consolidated into the largest bucket.
134
+ #
135
+ # @param total [Integer]
136
+ # @param count [Integer] number of output buckets (>= 2)
137
+ # @return [Array<Integer>]
138
+ def distribute(total, count)
139
+ # Reserve the dust floor for every bucket, then distribute the surplus.
140
+ reserved = dust_floor * count
141
+ surplus = total - reserved
142
+
143
+ # Generate (count - 1) random cut-points within the surplus range,
144
+ # then derive bucket sizes from the gaps between cut-points.
145
+ cuts = Array.new(count - 1) { rand(surplus + 1) }.sort
146
+ gaps = [cuts.first] + cuts.each_cons(2).map { |a, b| b - a } + [surplus - cuts.last]
147
+
148
+ amounts = gaps.map { |g| g + dust_floor }
149
+
150
+ consolidate_sub_dust(amounts)
151
+ end
152
+
153
+ # Folds any amounts that fell below the dust floor into the largest output.
154
+ # This should be rare given the reservation logic, but guards against edge
155
+ # cases from integer rounding.
156
+ #
157
+ # @param amounts [Array<Integer>]
158
+ # @return [Array<Integer>]
159
+ def consolidate_sub_dust(amounts)
160
+ valid, sub_dust = amounts.partition { |a| a >= dust_floor }
161
+ return amounts if sub_dust.empty?
162
+
163
+ remainder = sub_dust.sum
164
+ max_index = valid.each_with_index.max_by { |a, _| a }[1]
165
+ valid[max_index] += remainder
166
+ valid
167
+ end
168
+
169
+ # Builds a single change output hash with a freshly derived BRC-29 key.
170
+ #
171
+ # @param satoshis [Integer]
172
+ # @return [Hash]
173
+ def build_output(satoshis)
174
+ prefix = SecureRandom.hex(16)
175
+ suffix = SecureRandom.hex(16)
176
+ identity_key = @identity_key_override || @key_deriver.identity_key
177
+ key_id = "#{prefix} #{suffix}"
178
+
179
+ pub_key = @key_deriver.derive_public_key(BRC29_PROTOCOL_ID, key_id, identity_key, for_self: true)
180
+ locking_script = BSV::Script::Script.p2pkh_lock(pub_key.hash160)
181
+
182
+ {
183
+ satoshis: satoshis,
184
+ locking_script: locking_script,
185
+ derivation_prefix: prefix,
186
+ derivation_suffix: suffix,
187
+ sender_identity_key: identity_key
188
+ }
189
+ end
190
+ end
191
+ end
192
+ end
@@ -0,0 +1,132 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BSV
4
+ module Wallet
5
+ # Selects UTXOs from an available pool to fund a transaction.
6
+ #
7
+ # Given a target amount and a fee model, the selector chooses the minimum
8
+ # set of UTXOs needed to cover `target + fee`. It supports two strategies:
9
+ #
10
+ # - `:standard` — tries an exact match, then smallest-sufficient, then
11
+ # falls back to largest-first accumulation.
12
+ # - `:largest_first` — skips exact/smallest checks and goes straight to
13
+ # largest-first accumulation.
14
+ #
15
+ # Fee estimation uses {BSV::Wallet::FeeEstimator} with P2PKH size constants.
16
+ # During accumulation a potential change output is included in the fee
17
+ # estimate, so the caller can always produce change if needed.
18
+ #
19
+ # @example
20
+ # estimator = BSV::Wallet::FeeEstimator.new(sats_per_kb: 1)
21
+ # selector = BSV::Wallet::CoinSelector.new(fee_estimator: estimator)
22
+ # result = selector.select(available: utxos, target_satoshis: 1000, num_outputs: 1)
23
+ # # => { inputs: [...], fee: 1, total_satoshis: 1001, excess: 0 }
24
+ class CoinSelector
25
+ VALID_STRATEGIES = %i[standard largest_first].freeze
26
+
27
+ # @param fee_estimator [BSV::Wallet::FeeEstimator] fee estimation model
28
+ # (also accepted as +fee_model:+ for backwards compatibility)
29
+ # @param strategy [Symbol] selection strategy — `:standard` or `:largest_first`
30
+ # @raise [ArgumentError] if strategy is not recognised
31
+ def initialize(fee_estimator: nil, fee_model: nil, strategy: :standard)
32
+ raise ArgumentError, "unknown strategy #{strategy.inspect}; use :standard or :largest_first" unless VALID_STRATEGIES.include?(strategy)
33
+ raise ArgumentError, 'provide fee_estimator: or fee_model:, not both' if fee_estimator && fee_model
34
+
35
+ @fee_model = fee_estimator || fee_model || raise(ArgumentError, 'fee_estimator: (or fee_model:) is required')
36
+ @strategy = strategy
37
+ end
38
+
39
+ # Selects UTXOs to cover +target_satoshis+ plus estimated fees.
40
+ #
41
+ # @param available [Array<Hash>] pool of UTXOs; each hash must have a +:satoshis+ key
42
+ # @param target_satoshis [Integer] amount to fund (excluding fee)
43
+ # @param num_outputs [Integer] number of recipient outputs in the transaction
44
+ # @return [Hash] with keys +:inputs+, +:fee+, +:total_satoshis+, +:excess+
45
+ # @raise [BSV::Wallet::InsufficientFundsError] when the pool cannot cover target + fees
46
+ def select(available:, target_satoshis:, num_outputs:)
47
+ raise InsufficientFundsError.new(available: 0, required: target_satoshis) if available.empty?
48
+
49
+ result = case @strategy
50
+ when :standard then standard_select(available, target_satoshis, num_outputs)
51
+ when :largest_first then accumulate(available, target_satoshis, num_outputs)
52
+ end
53
+
54
+ result || raise_insufficient(available, target_satoshis)
55
+ end
56
+
57
+ private
58
+
59
+ # Tries exact match → smallest sufficient → accumulation.
60
+ def standard_select(available, target_satoshis, num_outputs)
61
+ exact_match(available, target_satoshis, num_outputs) ||
62
+ smallest_sufficient(available, target_satoshis, num_outputs) ||
63
+ accumulate(available, target_satoshis, num_outputs)
64
+ end
65
+
66
+ # Returns a result if a single UTXO covers exactly target + fee (no change).
67
+ def exact_match(available, target_satoshis, num_outputs)
68
+ fee = @fee_model.estimate(p2pkh_inputs: 1, p2pkh_outputs: num_outputs)
69
+ needed = target_satoshis + fee
70
+
71
+ utxo = available.find { |u| u[:satoshis] == needed }
72
+ return unless utxo
73
+
74
+ build_result([utxo], target_satoshis, fee)
75
+ end
76
+
77
+ # Returns the smallest single UTXO that is ≥ target + fee.
78
+ def smallest_sufficient(available, target_satoshis, num_outputs)
79
+ fee = @fee_model.estimate(p2pkh_inputs: 1, p2pkh_outputs: num_outputs)
80
+ needed = target_satoshis + fee
81
+
82
+ utxo = available
83
+ .select { |u| u[:satoshis] >= needed }
84
+ .min_by { |u| u[:satoshis] }
85
+ return unless utxo
86
+
87
+ build_result([utxo], target_satoshis, fee)
88
+ end
89
+
90
+ # Accumulates UTXOs largest-first until total ≥ target + fee.
91
+ # Fee is recalculated with each additional input and includes one
92
+ # potential change output (+1 to num_outputs).
93
+ def accumulate(available, target_satoshis, num_outputs)
94
+ sorted = available.sort_by { |u| -u[:satoshis] }
95
+ selected = []
96
+ total = 0
97
+
98
+ sorted.each do |utxo|
99
+ selected << utxo
100
+ total += utxo[:satoshis]
101
+
102
+ fee = @fee_model.estimate(
103
+ p2pkh_inputs: selected.size,
104
+ p2pkh_outputs: num_outputs + 1
105
+ )
106
+
107
+ return build_result(selected, target_satoshis, fee) if total >= target_satoshis + fee
108
+ end
109
+
110
+ nil
111
+ end
112
+
113
+ def build_result(inputs, target_satoshis, fee)
114
+ total = inputs.sum { |u| u[:satoshis] }
115
+ {
116
+ inputs: inputs,
117
+ fee: fee,
118
+ total_satoshis: total,
119
+ excess: total - target_satoshis - fee
120
+ }
121
+ end
122
+
123
+ def raise_insufficient(available, target_satoshis)
124
+ total_available = available.sum { |u| u[:satoshis] }
125
+ raise InsufficientFundsError.new(
126
+ available: total_available,
127
+ required: target_satoshis
128
+ )
129
+ end
130
+ end
131
+ end
132
+ end
@@ -0,0 +1,124 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BSV
4
+ module Wallet
5
+ # Estimates transaction fees before a {BSV::Transaction::Transaction} object exists.
6
+ #
7
+ # Wraps {BSV::Transaction::FeeModels::SatoshisPerKilobyte} to provide pre-construction
8
+ # fee estimation given only input and output counts. Once a real {Transaction} is
9
+ # available, delegates directly to the underlying SDK fee model via {#estimate_for_tx}.
10
+ #
11
+ # The default rate is 1 sat/kB — the current BSV network standard. The SDK's own
12
+ # {SatoshisPerKilobyte} defaults to 100 sat/kB; this class intentionally differs.
13
+ #
14
+ # @example Pre-construction estimate
15
+ # estimator = BSV::Wallet::FeeEstimator.new
16
+ # fee = estimator.estimate(p2pkh_inputs: 2, p2pkh_outputs: 3)
17
+ # # => 1 (at 1 sat/kB, ceil(408/1000 * 1) = 1)
18
+ #
19
+ # @example Custom rate
20
+ # estimator = BSV::Wallet::FeeEstimator.new(sats_per_kb: 50)
21
+ # fee = estimator.estimate(p2pkh_inputs: 1, p2pkh_outputs: 1)
22
+ # # => 10 (ceil(192/1000 * 50) = 10)
23
+ class FeeEstimator
24
+ # Estimated size in bytes of an unsigned P2PKH input.
25
+ # Matches {BSV::Transaction::Transaction::UNSIGNED_P2PKH_INPUT_SIZE}.
26
+ P2PKH_INPUT_SIZE = BSV::Transaction::Transaction::UNSIGNED_P2PKH_INPUT_SIZE
27
+
28
+ # Estimated size in bytes of a P2PKH output (8 satoshis + varint(25) + 25-byte script).
29
+ P2PKH_OUTPUT_SIZE = 34
30
+
31
+ # Fixed overhead in bytes for version (4) and lock_time (4).
32
+ FIXED_OVERHEAD = 8
33
+
34
+ # Approximate overhead including typical 1-byte varints for input/output
35
+ # counts. Retained for backward compatibility with code that referenced
36
+ # +FeeModel::OVERHEAD+.
37
+ OVERHEAD = 10
38
+
39
+ # @return [Integer] the satoshis-per-kilobyte rate used for estimation
40
+ attr_reader :sats_per_kb
41
+
42
+ # @param sats_per_kb [Integer] fee rate in satoshis per kilobyte (default: 1)
43
+ # @raise [ArgumentError] if sats_per_kb is zero or negative
44
+ def initialize(sats_per_kb: 1)
45
+ raise ArgumentError, 'sats_per_kb must be greater than zero' unless sats_per_kb.positive?
46
+
47
+ @sats_per_kb = sats_per_kb
48
+ @sdk_model = BSV::Transaction::FeeModels::SatoshisPerKilobyte.new(value: sats_per_kb)
49
+ end
50
+
51
+ # Estimate the fee for a transaction described by input and output counts.
52
+ #
53
+ # Computes the serialised byte size using standard P2PKH sizes and correct
54
+ # Bitcoin varint encoding for the input/output count fields, then applies
55
+ # the configured sat/kB rate. Returns at least 1 satoshi.
56
+ #
57
+ # @param p2pkh_inputs [Integer] number of P2PKH inputs
58
+ # @param p2pkh_outputs [Integer] number of P2PKH outputs
59
+ # @param extra_bytes [Integer] additional bytes to include (e.g. OP_RETURN data)
60
+ # @return [Integer] estimated fee in satoshis (minimum 1)
61
+ def estimate(p2pkh_inputs:, p2pkh_outputs:, extra_bytes: 0)
62
+ total_bytes = byte_size(p2pkh_inputs, p2pkh_outputs) + extra_bytes
63
+ fee = (total_bytes / 1000.0 * @sats_per_kb).ceil
64
+ [1, fee].max
65
+ end
66
+
67
+ # Compute the fee for a fully-constructed transaction.
68
+ #
69
+ # Delegates to the underlying {BSV::Transaction::FeeModels::SatoshisPerKilobyte}
70
+ # model, which uses {BSV::Transaction::Transaction#estimated_size} internally.
71
+ #
72
+ # @param tx [BSV::Transaction::Transaction] the transaction to compute the fee for
73
+ # @return [Integer] fee in satoshis
74
+ def estimate_for_tx(tx)
75
+ @sdk_model.compute_fee(tx)
76
+ end
77
+
78
+ # Alias for {#estimate_for_tx} — matches the SDK's +compute_fee+ naming.
79
+ alias compute estimate_for_tx
80
+
81
+ # Minimum viable change output value at the configured rate.
82
+ #
83
+ # An output is economically viable only if its value exceeds twice the cost of
84
+ # spending it. The cost to spend a single P2PKH input is estimated as the fee
85
+ # for a minimal 1-input / 1-output transaction.
86
+ #
87
+ # @return [Integer] minimum satoshis for a change output to be worth creating
88
+ def dust_floor
89
+ cost = (spend_one_p2pkh_bytes / 1000.0 * @sats_per_kb).ceil
90
+ [1, cost * 2].max
91
+ end
92
+
93
+ private
94
+
95
+ # Total byte size for a transaction with the given input/output counts.
96
+ def byte_size(input_count, output_count)
97
+ FIXED_OVERHEAD +
98
+ varint_size(input_count) +
99
+ (input_count * P2PKH_INPUT_SIZE) +
100
+ varint_size(output_count) +
101
+ (output_count * P2PKH_OUTPUT_SIZE)
102
+ end
103
+
104
+ # Byte size of a minimal single-input, single-output transaction.
105
+ # Used to compute the cost of spending one P2PKH UTXO.
106
+ def spend_one_p2pkh_bytes
107
+ byte_size(1, 1)
108
+ end
109
+
110
+ # Number of bytes required to encode +n+ as a Bitcoin varint.
111
+ def varint_size(n)
112
+ if n < 0xFD
113
+ 1
114
+ elsif n <= 0xFFFF
115
+ 3
116
+ elsif n <= 0xFFFF_FFFF
117
+ 5
118
+ else
119
+ 9
120
+ end
121
+ end
122
+ end
123
+ end
124
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'fee_estimator'
4
+
5
+ module BSV
6
+ module Wallet
7
+ # Backward-compatible alias for {FeeEstimator}.
8
+ #
9
+ # All fee estimation is consolidated in {FeeEstimator}, which provides
10
+ # both pre-construction estimation (+estimate+) and post-construction
11
+ # delegation (+compute+ / +estimate_for_tx+). This alias ensures
12
+ # existing code referencing +FeeModel+ continues to work.
13
+ #
14
+ # @example
15
+ # model = BSV::Wallet::FeeModel.new(sats_per_kb: 50)
16
+ # model.estimate(p2pkh_inputs: 2, p2pkh_outputs: 3)
17
+ # model.compute(transaction)
18
+ # model.dust_floor
19
+ FeeModel = FeeEstimator
20
+ end
21
+ end
@@ -60,6 +60,18 @@ module BSV
60
60
  result
61
61
  end
62
62
 
63
+ def update_output_state(outpoint, new_state, pending_reference: nil, no_send: nil)
64
+ result = super
65
+ save_outputs
66
+ result
67
+ end
68
+
69
+ def lock_utxos(outpoints, reference:, no_send: false)
70
+ locked = super
71
+ save_outputs unless locked.empty?
72
+ locked
73
+ end
74
+
63
75
  def store_certificate(cert_data)
64
76
  result = super
65
77
  save_certificates
@@ -82,6 +94,11 @@ module BSV
82
94
  save_transactions
83
95
  end
84
96
 
97
+ def store_setting(key, value)
98
+ super
99
+ save_settings
100
+ end
101
+
85
102
  private
86
103
 
87
104
  def proofs_path
@@ -99,7 +116,7 @@ module BSV
99
116
  'Other users may be able to access wallet data.')
100
117
  end
101
118
 
102
- [actions_path, outputs_path, certificates_path, proofs_path, transactions_path].each do |path|
119
+ [actions_path, outputs_path, certificates_path, proofs_path, transactions_path, settings_path].each do |path|
103
120
  next unless File.exist?(path)
104
121
 
105
122
  file_mode = File.stat(path).mode & 0o777
@@ -114,6 +131,10 @@ module BSV
114
131
  File.join(@dir, 'actions.json')
115
132
  end
116
133
 
134
+ def settings_path
135
+ File.join(@dir, 'settings.json')
136
+ end
137
+
117
138
  def outputs_path
118
139
  File.join(@dir, 'outputs.json')
119
140
  end
@@ -128,6 +149,7 @@ module BSV
128
149
  @certificates = load_file(certificates_path)
129
150
  @proofs = load_proofs_file
130
151
  @transactions = load_transactions_file
152
+ @settings = load_settings_file
131
153
  end
132
154
 
133
155
  def load_file(path)
@@ -186,6 +208,22 @@ module BSV
186
208
  {}
187
209
  end
188
210
 
211
+ def save_settings
212
+ json = JSON.pretty_generate(stringify_keys_deep(@settings))
213
+ tmp = "#{settings_path}.tmp"
214
+ File.open(tmp, File::WRONLY | File::CREAT | File::TRUNC, 0o600) { |f| f.write(json) }
215
+ File.rename(tmp, settings_path)
216
+ end
217
+
218
+ def load_settings_file
219
+ return {} unless File.exist?(settings_path)
220
+
221
+ data = JSON.parse(File.read(settings_path))
222
+ data.is_a?(Hash) ? data : {}
223
+ rescue JSON::ParserError
224
+ {}
225
+ end
226
+
189
227
  def write_file(path, data)
190
228
  json = JSON.pretty_generate(stringify_keys_deep(data))
191
229
  tmp = "#{path}.tmp"