bsv-wallet 0.3.4 → 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: 1f3c7d9f919b64229f0c2c9c99a05fbd58850803af74861157f884ade90f7af6
4
- data.tar.gz: c655fbf296f6a4a3ed554681b8ab673c8dd3d108d37e6832cbf48814b3ed2169
3
+ metadata.gz: bf66409b10a18ba1c43a05efb6e7909e4ba83a87d4421a68248f3f3174a8483d
4
+ data.tar.gz: 2ae27d6080353acb67b11e557e5e38c063abd243e1a36e2bc4c6ee6fa43fc885
5
5
  SHA512:
6
- metadata.gz: 87e2b8653d787445a281fb78393e380857799852908487cbef82af26a3e1e04f16df5f67ce295ed4162742527d7ebc5d1380b4670f8a949b78d4c44524e6ab66
7
- data.tar.gz: 9d288d1787f7448278eb95de1e96e41384e0c06141c755812549b9bc693c0269b8361cf31982f6b38472be3418ef575e7f373bde11d3ef0892743041c14dab10
6
+ metadata.gz: ad6a39da955d05c18460291ec2f1e7f56cae7c3a82d32da175477ea62c3e82bfd4dcce11bd9c4a53236b38b6091176badb55b264e05489e71722ab6ca8a651df
7
+ data.tar.gz: 3724b1e7cbb060a750eb865688b2859645a7837e05fd964214f2c8852ebd4aa515c8ee1afa51bd9fd50fd7bde0c9aa8299e0766057c40196b169e5f595db82fc
@@ -0,0 +1,332 @@
1
+ # Changelog — bsv-wallet
2
+
3
+ All notable changes to the `bsv-wallet` gem are documented here.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/)
6
+ and this gem adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
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
+
73
+ ## 0.4.0 — 2026-04-10
74
+
75
+ ### Added
76
+
77
+ - **Protocol-ID normalisation** (F8.7): `Validators.validate_protocol_id!` now
78
+ strips and downcases the name before applying rules, so `' MyProtocol '` and
79
+ `'myprotocol'` are treated identically and cannot silently fork to different
80
+ key-derivation paths.
81
+ - **Permission-rule constants** (F8.8): Reserved prefix/suffix strings are now
82
+ named constants on `BSV::Wallet::Validators` (`RESERVED_PROTOCOL_PREFIXES`,
83
+ `RESERVED_PROTOCOL_SUFFIX`, `RESERVED_BASKET_PREFIXES`, `RESERVED_BASKET_SUFFIX`,
84
+ `RESERVED_BASKET_NAME`), making them discoverable and documentable.
85
+ - **BEEF verification in `internalize_action`** (F8.14): The BEEF bundle is now
86
+ verified via `Beef#verify` before any outputs are stored. If the bundle is
87
+ structurally invalid, a `WalletError` is raised rather than storing unverified
88
+ data. When the chain provider supports `valid_root_for_height?`, full SPV
89
+ verification is performed.
90
+ - **Depth cap and cycle detection in `wire_source_tx_ancestors`** (F8.18):
91
+ Recursion is now bounded by `WalletClient::ANCESTOR_DEPTH_CAP` (64 levels)
92
+ and a visited-txid `Set`, preventing stack overflow on deep or cyclic
93
+ transaction ancestry chains.
94
+
95
+ ### Changed
96
+
97
+ - **`ProtoWallet#create_signature` default counterparty** (P305.1): The default
98
+ value for `counterparty` has changed from `'self'` to `'anyone'`, matching the
99
+ behaviour of `ts-sdk`'s `ProtoWallet.createSignature`. Callers that rely on
100
+ the `'self'` derivation path when omitting `counterparty:` must now pass
101
+ `counterparty: 'self'` explicitly.
102
+
103
+ ### Migration notes
104
+
105
+ **P305.1 — `create_signature` counterparty default change (breaking)**
106
+
107
+ Previously, calling `wallet.create_signature({ protocol_id: ..., key_id: ...,
108
+ data: ... })` without a `counterparty:` key would derive using `'self'`. It now
109
+ derives using `'anyone'`. This changes the resulting private key and therefore
110
+ the signature. If your application omits `counterparty:`, add
111
+ `counterparty: 'self'` to preserve the old behaviour.
112
+
113
+ **F8.14 — BEEF verification now mandatory in `internalize_action`**
114
+
115
+ Calls to `internalize_action` that previously succeeded with a malformed or
116
+ unverifiable BEEF will now raise `BSV::Wallet::WalletError`. In practice this
117
+ only affects callers passing synthetic or hand-crafted BEEF bytes; legitimate
118
+ BEEF produced by `create_action` or broadcast round-trips will continue to work.
119
+
120
+ ### Added
121
+
122
+ - **Shared conformance suite** for `StorageAdapter`
123
+ implementations at `spec/support/shared_examples_for_storage_adapter.rb`.
124
+ `MemoryStore` and `FileStore` now both drive their behavioural
125
+ tests through `it_behaves_like 'a storage adapter'`, and the
126
+ extraction backfilled previously-missing coverage (certificate
127
+ `:attributes` filter, `count_certificates`, proof and transaction
128
+ round-trip, pagination ordering).
129
+
130
+ ## 0.3.4 — 2026-04-08
131
+
132
+ Paired security patch release. Three P0 findings from the
133
+ [2026-04-08 cross-SDK compliance review](.architecture/reviews/20260408-cross-sdk-compliance-review.md)
134
+ plus follow-up hardening from the PR review pass. Must be installed
135
+ together — the `bsv-wallet` gemspec now pins its `bsv-sdk` dependency
136
+ to `>= 0.8.2, < 1.0` to enforce the paired upgrade and prevent a stale
137
+ pair where one gem has its fixes and the other doesn't.
138
+
139
+ Two GitHub Security Advisories accompany this release (draft until
140
+ CVE IDs return from MITRE):
141
+
142
+
143
+ ### Security
144
+
145
+ - **`acquire_certificate` now verifies certifier signatures** before
146
+ persisting (BRC-52). Both the `'direct'` and `'issuance'` acquisition
147
+ paths previously wrote user-supplied `signature:` values to storage
148
+ without any verification — a caller could forge a certificate that
149
+ `list_certificates` / `prove_certificate` would later treat as
150
+ authentic. This was a credential forgery primitive masquerading as
151
+ an API finding. The new `BSV::Wallet::CertificateSignature` module
152
+ builds the canonical BRC-52 preimage (matching the TS reference
153
+ `Certificate#toBinary(false)` byte-for-byte) and delegates to
154
+ `ProtoWallet#verify_signature`. Invalid certificates raise
155
+ `BSV::Wallet::CertificateSignature::InvalidError` and are not
156
+ persisted. Closes F8.15 (and the verification aspect of F8.16).
157
+
158
+
159
+
160
+ ### Changed
161
+
162
+ - **`bsv-wallet.gemspec` bsv-sdk dependency pinned** to
163
+ `>= 0.8.2, < 1.0`. The previous `~> 0.4` constraint was stale (wallet
164
+ hasn't been tested against bsv-sdk 0.4.x in months) and would have
165
+ let a user install `bsv-wallet 0.3.4` against an old `bsv-sdk` that
166
+ was missing F1.3 and F5.13. Technically breaking — any consumer
167
+ pinned to `bsv-sdk < 0.8.2` must upgrade — but un-breaking in
168
+ practice: it forces users to the known-good pair rather than a
169
+ silently-broken combination.
170
+
171
+ ### Internal
172
+
173
+ - `lib/bsv/wallet_interface/**/*` added to the
174
+ `Metrics/ModuleLength` exclusion list (was previously only excluded
175
+ from `Metrics/ClassLength`). The new `CertificateSignature` module
176
+ triggered the discrepancy.
177
+ - Review-feedback hardening bundled into the same PR to
178
+ keep the security-patch window small: case-insensitive ARC failure
179
+ matching, `Base64.strict_decode64` on BRC-52 preimage fields,
180
+ `EncodingError` rescue in `CertificateSignature.verify!`, rejection
181
+ of mixed string / symbol duplicate field names, malformed 2xx
182
+ rejection in ARC, and even-length guard on hex signatures.
183
+
184
+ ### Migration notes
185
+
186
+ - **Existing `bsv-wallet` users** pinned to `bsv-sdk ~> 0.4` will need
187
+ to relax their constraint or upgrade. Anything installed before
188
+ `bsv-wallet 0.3.4` is vulnerable to the F8.15 certificate forgery
189
+ primitive.
190
+ - **Callers passing negative integers to `VarInt.encode`** (unlikely —
191
+ the docstring already disallowed it) will now get an `ArgumentError`
192
+ instead of silent corruption. Fix: pass non-negative values.
193
+ - **Callers relying on ARC broadcaster silently succeeding for INVALID
194
+ / MALFORMED / MINED_IN_STALE_BLOCK / ORPHAN responses** will now see
195
+ `BroadcastError` raised. Fix: handle the error — the previous
196
+ behaviour was objectively wrong and any downstream logic that
197
+ tolerated it was silently corrupt.
198
+ - **Callers of `acquire_certificate` with a fake or untrusted
199
+ `signature:` field** will now see
200
+ `BSV::Wallet::CertificateSignature::InvalidError`. Fix: ensure the
201
+ certificate has been properly signed by the declared certifier.
202
+
203
+ ### Test suite
204
+
205
+ - 3112 examples, 0 failures (up from 3080 on 0.8.1)
206
+ - 16 new regression tests for F1.3, F5.13, and F8.15
207
+ - 16 further regression tests for the review-feedback hardening
208
+ - Ruby 2.7 — 3.4 matrix green
209
+ - CodeQL clean; RuboCop clean across 266 files
210
+
211
+ ## 0.3.3 — 2026-04-06
212
+
213
+ ### Fixed
214
+
215
+ - `finalize_action` now stores the spending transaction so subsequent
216
+ `internalize_action` / proof resolution flows can find it. Previously the
217
+ wallet remembered the inputs and outputs but not the finalised tx itself.
218
+
219
+ ## 0.3.2 — 2026-04-06
220
+
221
+ ### Fixed
222
+
223
+ - `internalize_action` now stores **all** transactions from the
224
+ incoming BEEF, not just the proven ones. Unproven ancestors are needed for
225
+ later BEEF reconstruction in `create_action` → `to_beef`.
226
+
227
+ ## 0.3.1 — 2026-04-06
228
+
229
+ ### Fixed
230
+
231
+ - `internalize_action` now stores the subject transaction hex (not
232
+ just its proof and outputs), so the wallet can rebuild BEEF for spends of
233
+ the inbound outputs without re-fetching the tx.
234
+
235
+ ## 0.3.0 — 2026-04-06
236
+
237
+ ### Added
238
+
239
+ - **Pluggable proof store** for merkle proof persistence. The wallet
240
+ is now a lightweight SPV node: `internalize_action` extracts and stores
241
+ merkle proofs from incoming BEEF; `create_action` reattaches them to
242
+ produce valid BEEF with BUMPs for ARC broadcast.
243
+ - `ProofStore` interface with `store_proof` / `resolve_proof`.
244
+ - `LocalProofStore` default implementation using `StorageAdapter`.
245
+ - `WalletClient` accepts injectable `proof_store:` parameter.
246
+ - Transaction caching (`store_transaction` / `find_transaction`) for
247
+ ancestry reconstruction.
248
+ - `StorageAdapter` gains `store_proof`, `find_proof`,
249
+ `store_transaction`, `find_transaction` methods, implemented in both
250
+ `MemoryStore` and `FileStore`.
251
+
252
+ ### Fixed
253
+
254
+ - `wire_source_from_storage` resolves merkle proofs via proof store
255
+ so `to_beef` produces valid BEEF that ARC accepts. Previously, BEEF
256
+ contained source transactions without proofs, causing ARC 463/468
257
+ rejections.
258
+
259
+ ## 0.2.2 — 2026-04-06
260
+
261
+ ### Fixed
262
+
263
+ - `to_beef` now includes source transactions in the BEEF output, not
264
+ just the subject transaction. Without ancestors, ARC could not validate the
265
+ spend graph.
266
+
267
+ ## 0.2.1 — 2026-04-06
268
+
269
+ ### Added
270
+
271
+ - `WalletClient#create_action` now accepts `UnlockingScriptTemplate`
272
+ objects (e.g. `P2PKH`) as input unlocking scripts, enabling template-based
273
+ signing without BEEF.
274
+ - `wire_source_from_storage` fallback populates `source_satoshis`
275
+ and `source_locking_script` from wallet storage when BEEF is absent or
276
+ incomplete, enabling BIP-143 sighash computation for wallet-tracked
277
+ outputs.
278
+ - `finalize_action` resolves template inputs via `sign_all` before
279
+ serialisation.
280
+ - `MemoryStore#filter_outputs` supports outpoint filtering for
281
+ efficient single-output lookups.
282
+
283
+ The sdk gem was re-released alongside this wallet change with no
284
+ behavioural changes of its own.
285
+
286
+ ## 0.2.0 — 2026-04-01
287
+
288
+ ### Added
289
+
290
+ #### Primitives
291
+
292
+
293
+ #### Transaction
294
+
295
+
296
+ #### Wallet
297
+
298
+ - **FileStore** — JSON file-backed persistent storage, now the
299
+ default for `WalletClient`. Data survives process restarts. `MemoryStore`
300
+ becomes explicit opt-in for tests.
301
+ - **File permissions** — directory created with 0700, files with
302
+ 0600. Warns via Logger on startup if permissions are too open.
303
+
304
+ ## 0.1.2 — 2026-03-30
305
+
306
+ ### Added
307
+
308
+ #### Script
309
+
310
+
311
+ #### Transaction
312
+
313
+
314
+ #### Wallet
315
+
316
+ - **BRC-31 Auth/Peer** — mutual authentication with nonce-based
317
+ challenges, ECDSA signatures, and session management.
318
+ - **BRC-100 wire protocol** — binary ABI serialisation for all 28
319
+ BRC-100 methods (call codes 1-28, VarInt encoding).
320
+ - **Certificate issuance** — `acquire_certificate` with
321
+ `'issuance'` protocol (POST to certifier URL).
322
+
323
+ ### Fixed
324
+
325
+ - Subject and certifier pinned in certificate issuance response
326
+ (not overridable by remote certifier).
327
+ - Wire reader negative `privileged_reason` length crash.
328
+
329
+ This was the first formal `bsv-wallet` gem release tag. Wallet code that
330
+ landed in master before this date (notably the BRC-100 identity certificate
331
+ methods and the BRC-100 blockchain-data / authentication methods committed
332
+ during the sdk-0.3.1 window) is part of this gem's initial released state.
@@ -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