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 +4 -4
- data/CHANGELOG-wallet.md +65 -0
- data/lib/bsv/wallet_interface/chain_provider.rb +14 -0
- data/lib/bsv/wallet_interface/change_generator.rb +192 -0
- data/lib/bsv/wallet_interface/coin_selector.rb +132 -0
- data/lib/bsv/wallet_interface/fee_estimator.rb +124 -0
- data/lib/bsv/wallet_interface/fee_model.rb +21 -0
- data/lib/bsv/wallet_interface/file_store.rb +39 -1
- data/lib/bsv/wallet_interface/memory_store.rb +166 -3
- data/lib/bsv/wallet_interface/null_chain_provider.rb +9 -1
- data/lib/bsv/wallet_interface/storage_adapter.rb +79 -0
- data/lib/bsv/wallet_interface/version.rb +1 -1
- data/lib/bsv/wallet_interface/wallet_client.rb +515 -9
- data/lib/bsv/wallet_interface/whats_on_chain_provider.rb +62 -0
- data/lib/bsv/wallet_interface.rb +8 -2
- metadata +7 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: bf66409b10a18ba1c43a05efb6e7909e4ba83a87d4421a68248f3f3174a8483d
|
|
4
|
+
data.tar.gz: 2ae27d6080353acb67b11e557e5e38c063abd243e1a36e2bc4c6ee6fa43fc885
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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"
|