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 +4 -4
- data/CHANGELOG-wallet.md +332 -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/proto_wallet.rb +1 -1
- data/lib/bsv/wallet_interface/storage_adapter.rb +79 -0
- data/lib/bsv/wallet_interface/validators.rb +36 -7
- data/lib/bsv/wallet_interface/version.rb +1 -1
- data/lib/bsv/wallet_interface/wallet_client.rb +539 -11
- data/lib/bsv/wallet_interface/whats_on_chain_provider.rb +62 -0
- data/lib/bsv/wallet_interface.rb +8 -2
- metadata +11 -5
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
ADDED
|
@@ -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
|