bsv-wallet 0.10.0 → 0.11.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: c9ebc3008fda1b4430f4e813abd4d9e4e87329a6845b247adab0d967b2c4fe5a
4
- data.tar.gz: b05c60a9f6d73e85c2bd0d179abe77e9d80018112336ce20b92782fc35b91b6e
3
+ metadata.gz: 43a1c78810a0351eb11cf29575e9d2a9e8ffca2d5e844f8122bc28dc5dfffb02
4
+ data.tar.gz: 358f2f7514b5d44f1896ec3ca6898cfb2e195142639711e787f545d27eda6f03
5
5
  SHA512:
6
- metadata.gz: f1d6d32a26a674919fb659da57e6305b641dacbeb51eb0582af92f6e60396a73eed9ff373c48a90f95a4d5e1f86cefe9e10d69e71da515b8e08fc383fb8dc872
7
- data.tar.gz: 36e41e56f39d3b6ee945605a2ce831a2a220975f92458dfe081b627dc6ce9419f1a8fc69735fc6bec18cbb6e09bf5c55bf387b43ac09416c49d1af166785458c
6
+ metadata.gz: c82bcf291d49346fd574d8bf442b690575f4b42d3c66e94a8262a32ceab596831d7c9b8c69a3617e5540f29e93417014f16979aa0e345eff0b48577de6ddf10e
7
+ data.tar.gz: 8a5adfbe6bb90ea76d8478d181f3a453d5a9c4a15ad3a1f7eb833ef9d1b2862b5305d2dcaab016f4b10d83f5f091338f1e66c9ba3fdf0858f25597304d345308
data/CHANGELOG.md CHANGED
@@ -5,6 +5,43 @@ 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.11.0 — 2026-04-22
9
+
10
+ ### Added
11
+ - `chain_data_source:` constructor param on `Client` — injectable chain
12
+ data provider for local blockchain queries without a remote substrate
13
+ - `sync_utxos` restored — discovers on-chain UTXOs via the chain data
14
+ source and imports them into storage as `:spendable` with
15
+ `derivation_type: :identity`. Deduplicates transaction fetches and
16
+ validates UTXO API values against transaction outputs (#599)
17
+ - `get_height` restored with local chain data fallback — returns
18
+ `{ height: Integer }` without requiring a substrate (#597)
19
+ - `get_header_for_height` restored with local chain data fallback —
20
+ returns WoC block header JSON (#598)
21
+ - BEEF SPV merkle root verification restored in `internalize_action`
22
+ when a chain data source with `valid_root_for_height?` is available;
23
+ error messages now distinguish SPV rejection from structural
24
+ invalidity (#600)
25
+ - Store conformance shared examples extracted to
26
+ `lib/bsv/wallet/testing/` — downstream adapter gems can now
27
+ `require 'bsv/wallet/testing/store_conformance'` for interface and
28
+ wallet-level test coverage (#591)
29
+
30
+ ### Changed
31
+ - `MemoryStore` guarded in `Client.new` — raises `ArgumentError` unless
32
+ `allow_memory_store: true` is passed, preventing accidental data loss
33
+ in production
34
+ - Wallet-level dual-store specs refactored to use shared examples via
35
+ `it_behaves_like`; no test coverage lost (1281 examples preserved)
36
+
37
+ ### Fixed
38
+ - `sync_utxos` deduplicates UTXO response entries before processing
39
+ and caches fetched transactions by txid to minimise WoC API calls
40
+ - `internalize_action` chain tracker guard uses explicit nil check for
41
+ clarity
42
+ - Consistent error message wording across `get_height` and
43
+ `get_header_for_height`
44
+
8
45
  ## 0.10.0 — 2026-04-21
9
46
 
10
47
  ### Added
@@ -7,26 +7,46 @@ module BSV
7
7
  module Network
8
8
  # Returns the current blockchain height.
9
9
  #
10
- # Requires a substrate raises {UnsupportedActionError} locally.
10
+ # Delegates to the substrate when configured. Falls back to the local
11
+ # chain data source when present. Raises {UnsupportedActionError} when
12
+ # neither is available.
11
13
  #
12
14
  # @return [Hash] { height: Integer }
13
15
  def get_height(args = {}, originator: nil)
14
16
  return @substrate.get_height(args, originator: originator) if @substrate
15
17
 
16
- raise UnsupportedActionError, 'get_height requires a remote substrate'
18
+ raise UnsupportedActionError, 'get_height requires a chain_data_source or remote substrate' unless @chain_data_source
19
+
20
+ { height: @chain_data_source.current_height }
17
21
  end
18
22
 
19
23
  # Returns the block header at the given height.
20
24
  #
21
- # Requires a substrate raises {UnsupportedActionError} locally.
25
+ # Delegates to the substrate when configured; falls back to the local
26
+ # chain data source otherwise.
27
+ #
28
+ # Note: BRC-100 specifies +{ header: String }+ (80-byte raw hex), but the
29
+ # local fallback returns the richer WoC JSON hash (containing +hash+,
30
+ # +merkleroot+, +previousblockhash+, +time+, +nonce+, +bits+,
31
+ # +version+, and +height+) under the +header+ key. This is strictly more
32
+ # useful for local callers and avoids error-prone byte-order reassembly.
33
+ #
34
+ # *Warning:* the WalletWire binary serialiser expects +header+ to be a
35
+ # hex string. This local fallback should not be used behind a wire
36
+ # transport without first converting the JSON hash to raw 80-byte hex.
22
37
  #
23
38
  # @param args [Hash]
24
- # @option args [Integer] :height block height
25
- # @return [Hash] { header: String } 80-byte hex-encoded block header
39
+ # @option args [Integer] :height block height (must be >= 0)
40
+ # @return [Hash] { header: Hash } WoC block header JSON
26
41
  def get_header_for_height(args, originator: nil)
27
42
  return @substrate.get_header_for_height(args, originator: originator) if @substrate
28
43
 
29
- raise UnsupportedActionError, 'get_header_for_height requires a remote substrate'
44
+ raise UnsupportedActionError, 'get_header_for_height requires a chain_data_source or remote substrate' unless @chain_data_source
45
+
46
+ height = args[:height]
47
+ raise InvalidParameterError.new('height', 'a non-negative Integer') unless height.is_a?(Integer) && !height.negative?
48
+
49
+ { header: @chain_data_source.get_block_header(height) }
30
50
  end
31
51
 
32
52
  # Returns the network this wallet is configured for.
@@ -175,7 +175,19 @@ module BSV
175
175
  beef_binary = args[:tx].pack('C*')
176
176
  beef = BSV::Transaction::Beef.from_binary(beef_binary)
177
177
 
178
- raise WalletError, 'BEEF verification failed: the bundle is structurally invalid' unless beef.verify(nil)
178
+ # Use the chain data source for SPV merkle root verification when available;
179
+ # fall back to structural-only verification otherwise.
180
+ chain_tracker = @chain_data_source if @chain_data_source.respond_to?(:valid_root_for_height?)
181
+ unless beef.verify(chain_tracker)
182
+ # Distinguish SPV failures from structural invalidity so callers
183
+ # can tell whether the problem is the BEEF itself or the merkle root.
184
+ message = if chain_tracker && beef.verify(nil)
185
+ 'BEEF verification failed: merkle root not confirmed by chain tracker'
186
+ else
187
+ 'BEEF verification failed: the bundle is structurally invalid'
188
+ end
189
+ raise WalletError, message
190
+ end
179
191
 
180
192
  tx = extract_subject_transaction(beef)
181
193
 
@@ -56,7 +56,7 @@ module BSV
56
56
  # a fresh +Client.new('anyone', storage: Store::Memory.new)+
57
57
  # @return [true] when the signature verifies
58
58
  # @raise [InvalidError] otherwise
59
- def verify!(cert, verifier: Client.new('anyone', storage: Store::Memory.new))
59
+ def verify!(cert, verifier: Client.new('anyone', storage: Store::Memory.new, allow_memory_store: true))
60
60
  signature_hex = cert[:signature]
61
61
  raise InvalidError, 'signature is missing' if signature_hex.nil? || signature_hex.empty?
62
62
 
@@ -50,9 +50,13 @@ module BSV
50
50
  # @return [Interface, nil] the optional substrate for remote wallet delegation
51
51
  attr_reader :substrate
52
52
 
53
+ # @return [#current_height, #get_block_header, #fetch_utxos, #fetch_transaction, nil]
54
+ # optional chain data source for SPV and block header lookups
55
+ attr_reader :chain_data_source
56
+
53
57
  # @param key [BSV::Primitives::PrivateKey, String, KeyDeriver] signing key
54
- # @param storage [Store] persistence adapter (default: Store::File.new).
55
- # Use +storage: Store::Memory.new+ for tests.
58
+ # @param storage [Store] persistence adapter (default: Store::File.new)
59
+ # @param allow_memory_store [Boolean] set +true+ to suppress the MemoryStore safety guard
56
60
  # @param network [String] 'mainnet' (default) or 'testnet'
57
61
  # @param proof_store [ProofStore, nil] merkle proof store (default: LocalProofStore backed by storage)
58
62
  # @param http_client [#request, nil] injectable HTTP client for certificate issuance
@@ -62,6 +66,8 @@ module BSV
62
66
  # @param broadcaster [#broadcast, nil] optional broadcaster
63
67
  # @param broadcast_queue [BroadcastQueue, nil] optional broadcast queue; defaults to BroadcastQueue::Inline
64
68
  # @param substrate [Interface, nil] optional remote wallet substrate
69
+ # @param chain_data_source [#current_height, #get_block_header, #fetch_utxos, #fetch_transaction, nil]
70
+ # optional chain data source for SPV and block header lookups
65
71
  def initialize(
66
72
  key,
67
73
  storage: Store::File.new,
@@ -73,10 +79,19 @@ module BSV
73
79
  change_generator: nil,
74
80
  broadcaster: nil,
75
81
  broadcast_queue: nil,
76
- substrate: nil
82
+ substrate: nil,
83
+ chain_data_source: nil,
84
+ allow_memory_store: false
77
85
  )
86
+ if storage.is_a?(Store::Memory) && !storage.is_a?(Store::File) && !allow_memory_store
87
+ raise ArgumentError,
88
+ 'MemoryStore is not a safe storage adapter for wallets. ' \
89
+ 'See: https://sgbett.github.io/bsv-ruby-sdk/gems/wallet/#storage-adapters'
90
+ end
91
+
78
92
  @key_deriver = key.is_a?(KeyDeriver) ? key : KeyDeriver.new(key)
79
93
  @substrate = substrate
94
+ @chain_data_source = chain_data_source
80
95
  @storage = storage
81
96
  @network = network
82
97
  @proof_store = proof_store || LocalProofStore.new(storage)
@@ -98,9 +113,68 @@ module BSV
98
113
  @broadcast_queue.broadcast_enabled?
99
114
  end
100
115
 
101
- # Raises {UnsupportedActionError}.
116
+ # Discovers UTXOs on-chain for the wallet's identity address and imports
117
+ # any that are not already in local storage.
118
+ #
119
+ # Requires either a substrate or a chain_data_source. When both are present,
120
+ # the substrate takes priority.
121
+ #
122
+ # @return [Integer] number of UTXOs imported (0 if nothing new)
123
+ # @raise [UnsupportedActionError] when neither substrate nor chain_data_source is set
124
+ # @raise [WalletError] when a fetched transaction has an out-of-bounds tx_pos
102
125
  def sync_utxos
103
- raise UnsupportedActionError, 'sync_utxos requires a remote substrate or custom integration'
126
+ return @substrate.sync_utxos if @substrate
127
+
128
+ raise UnsupportedActionError, 'sync_utxos requires a chain_data_source or remote substrate' unless @chain_data_source
129
+
130
+ address = identity_address
131
+ utxos = @chain_data_source.fetch_utxos(address)
132
+ return 0 if utxos.empty?
133
+
134
+ # Group UTXOs by tx_hash to minimise WoC API calls — rate limiting
135
+ # is aggressive and penalties are harsh, so one fetch per transaction
136
+ # is far better than one fetch per UTXO.
137
+ tx_cache = {}
138
+ new_utxos = utxos.uniq { |u| "#{u.tx_hash}.#{u.tx_pos}" }
139
+ .reject { |u| output_exists?("#{u.tx_hash}.#{u.tx_pos}") }
140
+ return 0 if new_utxos.empty?
141
+
142
+ new_utxos.each do |utxo|
143
+ tx = tx_cache[utxo.tx_hash] ||= @chain_data_source.fetch_transaction(utxo.tx_hash)
144
+
145
+ pos = utxo.tx_pos
146
+ unless pos.is_a?(Integer) && pos >= 0 && pos < tx.outputs.length
147
+ raise WalletError, "Invalid tx_pos #{pos.inspect} for #{utxo.tx_hash} (#{tx.outputs.length} outputs)"
148
+ end
149
+
150
+ output = tx.outputs[pos]
151
+ output_satoshis = output.satoshis
152
+
153
+ # The transaction output is the authoritative source for satoshis —
154
+ # a mismatch with the UTXO API would produce invalid sighashes.
155
+ if !utxo.satoshis.nil? && utxo.satoshis != output_satoshis
156
+ raise WalletError,
157
+ "UTXO value mismatch for #{utxo.tx_hash}.#{pos}: " \
158
+ "chain reported #{utxo.satoshis}, tx output is #{output_satoshis}"
159
+ end
160
+
161
+ locking_script_hex = output.locking_script.to_hex
162
+ outpoint = "#{utxo.tx_hash}.#{utxo.tx_pos}"
163
+
164
+ @storage.store_output({
165
+ outpoint: outpoint,
166
+ satoshis: output_satoshis,
167
+ locking_script: locking_script_hex,
168
+ basket: 'default',
169
+ tags: [],
170
+ derivation_type: :identity,
171
+ state: :spendable,
172
+ source_tx_hex: tx.to_hex
173
+ })
174
+ @storage.store_transaction(utxo.tx_hash, tx.to_hex)
175
+ end
176
+
177
+ new_utxos.length
104
178
  end
105
179
 
106
180
  # --- UTXO Pool & Settings ---
@@ -3,6 +3,14 @@
3
3
  module BSV
4
4
  module Wallet
5
5
  # Storage implementations. See {Interface::Store} for the contract.
6
+ #
7
+ # The recommended adapter is {Store::File} (JSON file-backed) or
8
+ # +bsv-wallet-postgres+ for production use.
9
+ #
10
+ # {Store::Memory} is available but will raise when passed to
11
+ # +Client.new+ unless +allow_memory_store: true+ is set — data is
12
+ # lost on process exit, including derived key material needed to
13
+ # spend change outputs.
6
14
  module Store
7
15
  autoload :Memory, 'bsv/wallet/store/memory'
8
16
  autoload :File, 'bsv/wallet/store/file'