bsv-sdk 0.23.1 → 0.25.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.
Files changed (53) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +79 -0
  3. data/README.md +1 -1
  4. data/lib/bsv/auth/auth_payload.rb +5 -0
  5. data/lib/bsv/identity/client.rb +9 -5
  6. data/lib/bsv/kv_store/entry.rb +15 -0
  7. data/lib/bsv/kv_store/global.rb +210 -0
  8. data/lib/bsv/kv_store/interpreter.rb +109 -0
  9. data/lib/bsv/kv_store/token.rb +10 -0
  10. data/lib/bsv/kv_store.rb +10 -0
  11. data/lib/bsv/mcp/tools/broadcast_p2pkh.rb +5 -2
  12. data/lib/bsv/mcp/tools/decode_tx.rb +1 -1
  13. data/lib/bsv/mcp/tools/fetch_tx.rb +1 -1
  14. data/lib/bsv/mcp/tools/helpers.rb +3 -3
  15. data/lib/bsv/network/protocol.rb +12 -1
  16. data/lib/bsv/network/util.rb +13 -5
  17. data/lib/bsv/overlay/admin_token_template.rb +2 -2
  18. data/lib/bsv/overlay/historian.rb +118 -0
  19. data/lib/bsv/overlay/topic_broadcaster.rb +1 -1
  20. data/lib/bsv/overlay/types.rb +37 -0
  21. data/lib/bsv/overlay.rb +1 -0
  22. data/lib/bsv/primitives/ecies.rb +12 -3
  23. data/lib/bsv/registry/client.rb +54 -7
  24. data/lib/bsv/script/bip276.rb +143 -0
  25. data/lib/bsv/script/interpreter/interpreter.rb +1 -1
  26. data/lib/bsv/script/push_drop_template.rb +2 -2
  27. data/lib/bsv/script.rb +1 -0
  28. data/lib/bsv/storage/downloader.rb +174 -0
  29. data/lib/bsv/storage/errors.rb +8 -0
  30. data/lib/bsv/storage/utils.rb +90 -0
  31. data/lib/bsv/storage.rb +16 -0
  32. data/lib/bsv/transaction/beef.rb +173 -19
  33. data/lib/bsv/transaction/beef_party.rb +119 -0
  34. data/lib/bsv/transaction/chain_tracker.rb +2 -2
  35. data/lib/bsv/transaction/fee_model.rb +1 -1
  36. data/lib/bsv/transaction/fee_models/live_policy.rb +1 -1
  37. data/lib/bsv/transaction/fee_models/satoshis_per_kilobyte.rb +1 -1
  38. data/lib/bsv/transaction/merkle_path.rb +2 -2
  39. data/lib/bsv/transaction/p2pkh.rb +1 -1
  40. data/lib/bsv/transaction/transaction_input.rb +1 -1
  41. data/lib/bsv/transaction/{transaction.rb → tx.rb} +18 -18
  42. data/lib/bsv/transaction/unlocking_script_template.rb +2 -2
  43. data/lib/bsv/transaction.rb +3 -2
  44. data/lib/bsv/version.rb +1 -1
  45. data/lib/bsv/wallet/proto_wallet/key_deriver.rb +13 -0
  46. data/lib/bsv/wallet/proto_wallet.rb +12 -2
  47. data/lib/bsv/wallet/serializer/create_signature.rb +7 -0
  48. data/lib/bsv/wallet/serializer/get_public_key.rb +5 -1
  49. data/lib/bsv/wallet/serializer/reveal_counterparty_key_linkage.rb +6 -3
  50. data/lib/bsv/wallet/serializer/reveal_specific_key_linkage.rb +6 -3
  51. data/lib/bsv/wallet/serializer/verify_signature.rb +7 -0
  52. data/lib/bsv-sdk.rb +2 -0
  53. metadata +14 -2
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: ce9ccc589a407c6ad98d92e40b9ac05925f3fbf32cc73aa235b9fd9a35b7ad43
4
- data.tar.gz: 46992e9ea4f2465c4ca80ad2bb84e2591426610b46d1eea72e11171efdc271bf
3
+ metadata.gz: 9a0dbc7f524004e1db715058dd17d336e62f99687239ec4c7807512d730e6ef5
4
+ data.tar.gz: 45b1fc354cbfe718a38e01c0d4896d537600d188bba909f351da1bc17bac66c6
5
5
  SHA512:
6
- metadata.gz: ac7da8fbd4e5cb9e0385bb3035dc34d44460232afe826b095d19cccc04faece15cd090045c49d7d176f68d7ba98b5a16105812c66ae8ac27d657df8682b8c499
7
- data.tar.gz: 1b6946dd9762ac777aa8d1fff4392c96e30cfabd5c6b14f3eba7a8997e7ef0f8d5eca86383c131dfb479ce7f6ac94b7049bc50490edde5b0ff6b6994d8392629
6
+ metadata.gz: 6e212bf3c077ab32a6d5187c0e3cbd93fd61f26844d40dce31b28c0574a1111db3a081dd0d843be90dc682779ede74ece03f0f7eccc0eec930a372306cdc3ca2
7
+ data.tar.gz: ec1e1b2e0c7b5ae142b52cb1863000fd262d0c1dc30346c2a7d412af2e1f66cbc3057c864bc9d6229737be59c30eae2eaf6d7fee7c296a9393c6e0f8b636b558
data/CHANGELOG.md CHANGED
@@ -5,6 +5,85 @@ All notable changes to the `bsv-sdk` 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.25.0 — 2026-06-18
9
+
10
+ ### Added
11
+ - `BSV::Storage::Utils` — UHRP URL helpers (`normalise_url`, `url_for_hash`,
12
+ `url_for_file`, `hash_from_url`, `valid_url?`) (#817).
13
+ - `BSV::Overlay::Historian` — generic output-history walker over `LookupResolver`,
14
+ enabling protocol-specific replay of an outpoint's lineage (#818).
15
+ - `BSV::Registry` — typed `resolve_basket`, `resolve_protocol`, and
16
+ `resolve_certificate` methods on the registry client (#819).
17
+ - `BSV::KVStore::Interpreter` — `Historian`-compatible interpreter for KV
18
+ protocol outputs (#820).
19
+ - `BSV::Storage::Downloader` — UHRP resolver and content fetcher with
20
+ hash verification (#821).
21
+ - `BSV::KVStore::Global` — overlay-backed read-only key-value reader (#822).
22
+ - `BSV::Script::BIP276` — text encoding for scripts and templates (`encode`,
23
+ `decode`) (#830).
24
+ - `BSV::Transaction::BeefParty` — `Beef` subclass tracking per-party knowledge
25
+ of transactions for multi-party BEEF exchange, plus supporting methods on
26
+ `Beef` (#831).
27
+
28
+ ### Changed
29
+ - `Beef` wire-inputs debug log now uses the existing `TransactionInput#dtxid_hex`
30
+ and `Tx#dtxid` accessors instead of inlined byte-order conversions (#828).
31
+
32
+ ### Fixed
33
+ - ECIES bitcore variant — added `iv:` kwarg and TypeScript SDK conformance
34
+ vector (#829).
35
+
36
+ ## 0.24.0 — 2026-06-11
37
+
38
+ ### Breaking Changes
39
+ - **`BSV::Transaction::Transaction` renamed to `BSV::Transaction::Tx`** (#795). Class only;
40
+ module `BSV::Transaction` and peer classes (`TransactionInput`, `TransactionOutput`,
41
+ `ChainTracker`, `Beef`, etc.) unchanged. No compat alias — pre-1.0 clean break.
42
+ Downstream consumers update with `s/BSV::Transaction::Transaction/BSV::Transaction::Tx/g`.
43
+ - **`BSV::Wallet::WalletWireTransceiver` pubkey results are now 66-char hex** (#798),
44
+ matching the BRC-100 `PubKeyHex` contract and `ProtoWallet`'s direct-call shape.
45
+ Affects `get_public_key`, `reveal_counterparty_key_linkage`, `reveal_specific_key_linkage`.
46
+ Wire bytes remain 33-byte compressed binary; only the deserialised Ruby return shape
47
+ changed. See ADR-001 for the rationale.
48
+ - **`BSV::Identity::Client` and `BSV::Registry::Client` raise `BSV::Overlay::OverlayError`
49
+ subclasses on overlay broadcast failure** (#802) — `publicly_reveal_attributes`,
50
+ `revoke_certificate_revelation`, `register_definition`, `sign_and_broadcast` previously
51
+ returned a result hash and required callers to inspect `status` themselves.
52
+
53
+ ### Added
54
+ - `BSV::Wallet::ProtoWallet::KeyDeriver#identity_key_bytes` — binary accessor for the
55
+ identity pubkey, alongside the hex `identity_key`. Mirrors the planned bsv-wallet
56
+ KeyDeriver shape.
57
+ - `BSV::Overlay::OverlayBroadcastResult#success?` and `#raise_if_error!` — let callers
58
+ treat broadcast failure as an exception with the appropriate `OverlayError` subclass.
59
+ - ADR-001 documenting the SDK's binary-internal rule and the public-key hex exception
60
+ (`docs/guides/wtxid-dtxid.md` extension + `.architecture/decisions/adrs/ADR-001-…`).
61
+
62
+ ### Fixed
63
+ - `BSV::Network::Util.resolve_tx_hex` rejects empty input rather than silently producing
64
+ an empty `rawTx` broadcast (#799).
65
+ - `BSV::Network::Protocol#default_call` converts transport-level exceptions
66
+ (`SocketError`, `Errno::ECONNREFUSED`, `Net::OpenTimeout`, `OpenSSL::SSL::SSLError`,
67
+ etc.) into structured `ProtocolResponse` with `http_success: false` and descriptive
68
+ `error_message`, rather than propagating raw exceptions to callers (#807).
69
+ - `BSV::Auth::AuthPayload.serialize_request` raises `ArgumentError` for `request_nonce`
70
+ that isn't exactly 32 bytes, preventing malformed BRC-103 wire frames (#800).
71
+ - `BSV::MCP::Tools::BroadcastP2pkh` rescues `StandardError` rather than just
72
+ `ArgumentError`, so non-`ArgumentError` exceptions from invalid WIF input (e.g.
73
+ `NoMethodError` on `nil`) return a structured MCP error instead of crashing the tool
74
+ handler (#801).
75
+ - `BSV::Wallet::ProtoWallet#create_signature` / `#verify_signature` and matching wire
76
+ serialisers validate that `hash_to_directly_sign` / `hash_to_directly_verify` are
77
+ exactly 32 bytes, preventing malformed signatures (#805).
78
+ - `BSV::Attest.verify` asserts that the provider's `fetch_transaction` returns a Tx-like
79
+ object before iterating, producing a clear `ArgumentError` instead of a low-signal
80
+ `NoMethodError` (#803).
81
+
82
+ ### Changed
83
+ - Bitcoin Core script-vectors spec now enforces a bidirectional regression gate:
84
+ previously-failing vectors that now pass surface as failures so `known_failures.json`
85
+ stays honest. Cleaned out 101 stale entries (#806).
86
+
8
87
  ## 0.23.1 — 2026-06-06
9
88
 
10
89
  ### Fixed
data/README.md CHANGED
@@ -86,7 +86,7 @@ pubkey_hash = priv_key.public_key.hash160
86
86
  locking_script = BSV::Script::Script.p2pkh_lock(pubkey_hash)
87
87
 
88
88
  # Create a transaction spending a UTXO
89
- tx = BSV::Transaction::Transaction.new
89
+ tx = BSV::Transaction::Tx.new
90
90
 
91
91
  # Add an input referencing a previous transaction output
92
92
  input = BSV::Transaction::TransactionInput.new(
@@ -34,6 +34,11 @@ module BSV
34
34
  # @param body [String, nil] request body bytes; nil encodes as ABSENT
35
35
  # @return [String] binary payload (ASCII-8BIT encoding)
36
36
  def serialize_request(request_nonce:, method:, path:, query:, headers:, body:)
37
+ unless request_nonce.is_a?(String) && request_nonce.bytesize == 32
38
+ got = request_nonce.respond_to?(:bytesize) ? "#{request_nonce.class}/#{request_nonce.bytesize}" : request_nonce.class
39
+ raise ArgumentError, "request_nonce must be a 32-byte binary string, got #{got}"
40
+ end
41
+
37
42
  buf = ''.b
38
43
 
39
44
  # 32-byte request nonce — written raw, no length prefix
@@ -107,6 +107,8 @@ module BSV
107
107
  # @return [BSV::Overlay::OverlayBroadcastResult]
108
108
  # @raise [ArgumentError] if the certificate has no fields or fields_to_reveal is empty
109
109
  # @raise [RuntimeError] if certificate verification fails or create_action returns no tx
110
+ # @raise [BSV::Overlay::OverlayError] (or a subclass) if the overlay broadcast fails —
111
+ # see {BSV::Overlay::OverlayBroadcastResult#raise_if_error!} for the mapping
110
112
  def publicly_reveal_attributes(certificate, fields_to_reveal:)
111
113
  fields = certificate[:fields] || certificate['fields'] || {}
112
114
  raise ArgumentError, 'Public reveal failed: Certificate has no fields to reveal!' if fields.empty?
@@ -169,8 +171,8 @@ module BSV
169
171
 
170
172
  raise 'Public reveal failed: failed to create action!' if create_result[:tx].nil?
171
173
 
172
- tx = BSV::Transaction::Transaction.from_beef(create_result[:tx])
173
- broadcaster_for_action.broadcast(tx)
174
+ tx = BSV::Transaction::Tx.from_beef(create_result[:tx])
175
+ broadcaster_for_action.broadcast(tx).raise_if_error!
174
176
  end
175
177
 
176
178
  # Revokes a publicly revealed certificate by spending the identity token.
@@ -182,6 +184,8 @@ module BSV
182
184
  # @param serial_number [String] Base64 serial number of the certificate revelation to revoke
183
185
  # @return [void]
184
186
  # @raise [RuntimeError] if the revelation cannot be found or the transaction cannot be created
187
+ # @raise [BSV::Overlay::OverlayError] (or a subclass) if the overlay broadcast fails —
188
+ # see {BSV::Overlay::OverlayBroadcastResult#raise_if_error!} for the mapping
185
189
  def revoke_certificate_revelation(serial_number)
186
190
  question = BSV::Overlay::LookupQuestion.new(
187
191
  service: Constants::SERVICE,
@@ -232,7 +236,7 @@ module BSV
232
236
  raise 'Revoke failed: failed to create signable transaction' if create_result[:signable_transaction].nil?
233
237
 
234
238
  signable = create_result[:signable_transaction]
235
- partial_tx = BSV::Transaction::Transaction.from_beef(signable[:tx])
239
+ partial_tx = BSV::Transaction::Tx.from_beef(signable[:tx])
236
240
 
237
241
  # Unlock via PushDrop
238
242
  template = BSV::Script::PushDropTemplate.new(wallet: @wallet, originator: @originator)
@@ -255,8 +259,8 @@ module BSV
255
259
 
256
260
  raise 'Revoke failed: failed to sign transaction' if sign_result[:tx].nil?
257
261
 
258
- signed_tx = BSV::Transaction::Transaction.from_beef(sign_result[:tx])
259
- broadcaster_for_action.broadcast(signed_tx)
262
+ signed_tx = BSV::Transaction::Tx.from_beef(sign_result[:tx])
263
+ broadcaster_for_action.broadcast(signed_tx).raise_if_error!
260
264
  end
261
265
 
262
266
  private
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BSV
4
+ module KVStore
5
+ # An immutable KVStore entry returned from {Global#get}.
6
+ #
7
+ # +token+ is populated only when +include_token: true+ is passed to +#get+.
8
+ # +history+ is populated only when +history: true+ is passed to +#get+.
9
+ Entry = Data.define(:key, :value, :controller, :protocol_id, :tags, :token, :history) do
10
+ def initialize(key:, value:, controller:, protocol_id:, tags:, token: nil, history: nil)
11
+ super
12
+ end
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,210 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+
5
+ module BSV
6
+ module KVStore
7
+ # Read-only client for the global KVStore overlay service.
8
+ #
9
+ # Queries the +ls_kvstore+ lookup service via a {BSV::Overlay::LookupResolver},
10
+ # decodes PushDrop tokens (5-field legacy and 6-field current formats), verifies
11
+ # the token signature, and returns typed {Entry} objects.
12
+ #
13
+ # Set/remove operations require wallet writes and are out of scope — those live
14
+ # in the +bsv-wallet+ gem.
15
+ #
16
+ # == Selector requirement
17
+ #
18
+ # {#get} raises +ArgumentError+ unless at least one of +key+, +controller+,
19
+ # +protocol_id+, or a non-empty +tags+ array is supplied in the query.
20
+ #
21
+ # == Always returns Array
22
+ #
23
+ # {#get} always returns +Array<Entry>+, even for single-result selectors such
24
+ # as +key + controller+. Callers that expect at most one result should use
25
+ # +.first+.
26
+ #
27
+ # == Silent skipping
28
+ #
29
+ # Outputs that fail BEEF parsing, PushDrop decoding, field-count validation, or
30
+ # signature verification are silently skipped (matching the TS SDK contract).
31
+ class Global
32
+ # @param network_preset [Symbol] :mainnet, :testnet, or :local
33
+ # @param lookup_resolver [BSV::Overlay::LookupResolver, nil] injectable resolver
34
+ # @param service_name [String] lookup service identifier
35
+ # @param proto_wallet [BSV::Wallet::ProtoWallet, nil] injectable wallet used for
36
+ # signature verification; defaults to +ProtoWallet.new('anyone')+. Tests can
37
+ # substitute a double to avoid +allow_any_instance_of+.
38
+ def initialize(network_preset: :mainnet, lookup_resolver: nil, service_name: 'ls_kvstore', proto_wallet: nil)
39
+ @lookup_resolver = lookup_resolver || BSV::Overlay::LookupResolver.new(network_preset: network_preset)
40
+ @service_name = service_name
41
+ @proto_wallet = proto_wallet || BSV::Wallet::ProtoWallet.new('anyone')
42
+ end
43
+
44
+ # Query the overlay for KVStore entries.
45
+ #
46
+ # @param query [Hash] selector: +:key+, +:controller+, +:protocol_id+, +:tags+,
47
+ # +:tag_query_mode+. At least one of the first four must be present.
48
+ # @param include_token [Boolean] populate {Token} on each entry when true
49
+ # @param history [Boolean] populate history via {BSV::Overlay::Historian} when true
50
+ # @return [Array<Entry>] matching entries (empty array when nothing matches)
51
+ # @raise [ArgumentError] if no selector is provided
52
+ def get(query, include_token: false, history: false)
53
+ validate_selector!(query)
54
+
55
+ question = BSV::Overlay::LookupQuestion.new(
56
+ service: @service_name,
57
+ query: camelise(query)
58
+ )
59
+
60
+ answer = @lookup_resolver.query(question)
61
+ return [] unless answer.type == 'output-list'
62
+
63
+ answer.outputs.filter_map do |output|
64
+ build_entry(output, include_token: include_token, history: history)
65
+ rescue StandardError
66
+ nil
67
+ end
68
+ end
69
+
70
+ private
71
+
72
+ # ---- Selector validation ----
73
+
74
+ def validate_selector!(query)
75
+ raise ArgumentError, 'query must be a Hash' unless query.is_a?(Hash)
76
+
77
+ has_key = query[:key].is_a?(String) && !query[:key].empty?
78
+ has_controller = query[:controller].is_a?(String) && !query[:controller].empty?
79
+ has_protocol = query[:protocol_id].is_a?(Array) && query[:protocol_id].length == 2
80
+ has_tags = query[:tags].is_a?(Array) && !query[:tags].empty?
81
+
82
+ return if has_key || has_controller || has_protocol || has_tags
83
+
84
+ raise ArgumentError,
85
+ 'At least one selector required (key, controller, protocol_id, or non-empty tags)'
86
+ end
87
+
88
+ # ---- Entry construction ----
89
+
90
+ def build_entry(output, include_token:, history:)
91
+ beef_data = output['beef'] || output[:beef]
92
+ output_index = (output['outputIndex'] || output[:output_index] || 0).to_i
93
+ raise 'Negative outputIndex' if output_index.negative?
94
+
95
+ beef = parse_beef!(beef_data)
96
+ beef_tx = beef.transactions.last
97
+ raise 'No transaction in BEEF' if beef_tx.nil? || beef_tx.is_a?(BSV::Transaction::Beef::TxidOnlyEntry)
98
+
99
+ tx = beef_tx.transaction
100
+ txout = tx.outputs[output_index]
101
+ raise 'outputIndex out of range' if txout.nil?
102
+
103
+ locking_script = txout.locking_script
104
+ raise 'No locking script' unless locking_script&.pushdrop?
105
+
106
+ raw_fields = locking_script.pushdrop_fields
107
+ raise 'Not a PushDrop script' unless raw_fields
108
+
109
+ field_count = raw_fields.length
110
+ raise "Unexpected field count: #{field_count}" unless [5, 6].include?(field_count)
111
+
112
+ # Separate data fields from trailing signature field
113
+ data_fields = raw_fields[0...-1]
114
+ sig_bytes = raw_fields.last
115
+
116
+ # Decode common fields (indices match kvProtocol in TS SDK)
117
+ protocol_id = decode_protocol_id!(data_fields[0])
118
+ key = decode_utf8!(data_fields[1])
119
+ value = decode_utf8!(data_fields[2])
120
+ controller = data_fields[3].unpack1('H*')
121
+
122
+ # New format (6 fields) has tags at index 4; old format (5 fields) has no tags field
123
+ tags = if field_count == 6
124
+ begin
125
+ JSON.parse(decode_utf8!(data_fields[4]))
126
+ rescue StandardError
127
+ nil
128
+ end
129
+ end
130
+
131
+ verify_signature!(data_fields, sig_bytes, protocol_id, key, controller)
132
+
133
+ token = if include_token
134
+ Token.new(
135
+ dtxid: tx.dtxid_hex,
136
+ output_index: output_index,
137
+ beef: beef,
138
+ satoshis: txout.satoshis || 0
139
+ )
140
+ end
141
+
142
+ entry_history = if history
143
+ BSV::Overlay::Historian
144
+ .new(BSV::KVStore::Interpreter)
145
+ .build_history(tx, { key: key, protocol_id: protocol_id })
146
+ end
147
+
148
+ Entry.new(
149
+ key: key,
150
+ value: value,
151
+ controller: controller,
152
+ protocol_id: protocol_id,
153
+ tags: tags,
154
+ token: token,
155
+ history: entry_history
156
+ )
157
+ end
158
+
159
+ # ---- Field decoding helpers ----
160
+
161
+ def parse_beef!(beef_data)
162
+ case beef_data
163
+ when String
164
+ BSV::Transaction::Beef.from_binary(beef_data)
165
+ when Array
166
+ BSV::Transaction::Beef.from_binary(beef_data.pack('C*'))
167
+ else
168
+ raise 'Missing or unsupported BEEF data'
169
+ end
170
+ end
171
+
172
+ def decode_utf8!(bytes)
173
+ raise 'Nil field' if bytes.nil?
174
+
175
+ str = bytes.force_encoding(Encoding::UTF_8)
176
+ raise 'Invalid UTF-8' unless str.valid_encoding?
177
+
178
+ str
179
+ end
180
+
181
+ def decode_protocol_id!(bytes)
182
+ JSON.parse(decode_utf8!(bytes))
183
+ end
184
+
185
+ # ---- Signature verification ----
186
+
187
+ def verify_signature!(data_fields, sig_bytes, protocol_id, key, controller)
188
+ @proto_wallet.verify_signature(
189
+ data: data_fields.join.bytes,
190
+ signature: sig_bytes.bytes,
191
+ protocol_id: protocol_id,
192
+ key_id: key,
193
+ counterparty: controller
194
+ )
195
+ end
196
+
197
+ # ---- Query key camelisation ----
198
+
199
+ def camelise(query)
200
+ query.transform_keys do |k|
201
+ case k
202
+ when :protocol_id then :protocolID
203
+ when :tag_query_mode then :tagQueryMode
204
+ else k
205
+ end
206
+ end
207
+ end
208
+ end
209
+ end
210
+ end
@@ -0,0 +1,109 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+
5
+ module BSV
6
+ module KVStore
7
+ # Historian interpreter for KVStore PushDrop tokens.
8
+ #
9
+ # Decodes a PushDrop locking script from the specified output, validates it
10
+ # has the expected field count (5 old format / 6 new format), and filters by
11
+ # +ctx[:key]+ and +ctx[:protocol_id]+.
12
+ #
13
+ # Returns the value as a UTF-8 string, or +nil+ for any non-match or error.
14
+ # Never raises.
15
+ #
16
+ # Field layout (old format — 5 fields):
17
+ # [0] protocolID (JSON-encoded array)
18
+ # [1] key
19
+ # [2] value
20
+ # [3] controller
21
+ # [4] signature
22
+ #
23
+ # Field layout (new format — 6 fields):
24
+ # [0] protocolID (JSON-encoded array)
25
+ # [1] key
26
+ # [2] value
27
+ # [3] controller
28
+ # [4] tags
29
+ # [5] signature
30
+ module Interpreter
31
+ PROTOCOL_ID = 0
32
+ KEY = 1
33
+ VALUE = 2
34
+ CONTROLLER = 3
35
+ TAGS_NEW = 4
36
+ SIGNATURE_OLD = 4
37
+ SIGNATURE_NEW = 5
38
+
39
+ KV_PROTOCOL_FIELDS_OLD = 5
40
+ KV_PROTOCOL_FIELDS_NEW = 6
41
+
42
+ # Decode the KVStore token at +output_index+ in +tx+.
43
+ #
44
+ # Implements the Historian interpreter contract:
45
+ # +interpreter.call(tx, output_index, ctx)+ → String or nil.
46
+ #
47
+ # @param tx [Transaction::Tx, nil] the transaction to inspect
48
+ # @param output_index [Integer] index into +tx.outputs+
49
+ # @param ctx [Hash, nil] must contain +:key+ (String) and +:protocol_id+ (Array)
50
+ # @return [String, nil] the decoded UTF-8 value, or nil on any mismatch/error
51
+ def self.call(tx, output_index, ctx)
52
+ return nil if tx.nil?
53
+ return nil if ctx.nil? || ctx[:key].nil?
54
+
55
+ output = tx.outputs[output_index]
56
+ return nil if output.nil?
57
+
58
+ locking_script = output.locking_script
59
+ return nil if locking_script.nil?
60
+
61
+ return nil unless locking_script.pushdrop?
62
+
63
+ fields = locking_script.pushdrop_fields
64
+ return nil unless fields
65
+
66
+ field_count = fields.length
67
+ return nil unless [KV_PROTOCOL_FIELDS_OLD, KV_PROTOCOL_FIELDS_NEW].include?(field_count)
68
+
69
+ protocol_id_bytes = fields[PROTOCOL_ID]
70
+ return nil if protocol_id_bytes.nil?
71
+
72
+ begin
73
+ protocol_id_str = protocol_id_bytes.force_encoding(Encoding::UTF_8)
74
+ return nil unless protocol_id_str.valid_encoding?
75
+
76
+ parsed_protocol_id = JSON.parse(protocol_id_str)
77
+ return nil unless parsed_protocol_id == ctx[:protocol_id]
78
+ rescue JSON::ParserError
79
+ return nil
80
+ end
81
+
82
+ key_bytes = fields[KEY]
83
+ return nil if key_bytes.nil?
84
+
85
+ begin
86
+ key_str = key_bytes.force_encoding(Encoding::UTF_8)
87
+ return nil unless key_str.valid_encoding?
88
+ return nil unless key_str == ctx[:key]
89
+ rescue StandardError
90
+ return nil
91
+ end
92
+
93
+ begin
94
+ value_bytes = fields[VALUE]
95
+ return nil if value_bytes.nil?
96
+
97
+ value_str = value_bytes.force_encoding(Encoding::UTF_8)
98
+ return nil unless value_str.valid_encoding?
99
+
100
+ value_str
101
+ rescue StandardError
102
+ nil
103
+ end
104
+ rescue StandardError
105
+ nil
106
+ end
107
+ end
108
+ end
109
+ end
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BSV
4
+ module KVStore
5
+ # Transaction output reference for a KVStore token.
6
+ #
7
+ # Populated when +include_token: true+ is passed to {Global#get}.
8
+ Token = Data.define(:dtxid, :output_index, :beef, :satoshis)
9
+ end
10
+ end
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BSV
4
+ module KVStore
5
+ autoload :Interpreter, 'bsv/kv_store/interpreter'
6
+ autoload :Entry, 'bsv/kv_store/entry'
7
+ autoload :Token, 'bsv/kv_store/token'
8
+ autoload :Global, 'bsv/kv_store/global'
9
+ end
10
+ end
@@ -127,7 +127,10 @@ module BSV
127
127
  [::MCP::Content::Text.new(result.to_json)],
128
128
  structured_content: result
129
129
  )
130
- rescue ArgumentError => e
130
+ rescue StandardError => e
131
+ # Catch broadly so MCP returns a structured error rather than
132
+ # crashing the tool handler on unexpected exception types
133
+ # (e.g. NoMethodError on nil WIF input, encoding errors, etc.).
131
134
  Helpers.error_response(e.message)
132
135
  end
133
136
 
@@ -150,7 +153,7 @@ module BSV
150
153
  # Build, fee-compute, and sign the transaction.
151
154
  # @api private
152
155
  def self.build_transaction(selected_utxos, satoshis, to_address, sender_address, private_key)
153
- tx = BSV::Transaction::Transaction.new
156
+ tx = BSV::Transaction::Tx.new
154
157
 
155
158
  # Wire inputs with EF metadata (source_satoshis + source_locking_script)
156
159
  selected_utxos.each do |utxo|
@@ -48,7 +48,7 @@ module BSV
48
48
  )
49
49
 
50
50
  def self.call(hex:, **)
51
- tx = BSV::Transaction::Transaction.from_hex(hex)
51
+ tx = BSV::Transaction::Tx.from_hex(hex)
52
52
  result = Helpers.transaction_to_h(tx)
53
53
 
54
54
  ::MCP::Tool::Response.new(
@@ -59,7 +59,7 @@ module BSV
59
59
  return Helpers.error_response(msg)
60
60
  end
61
61
 
62
- tx = BSV::Transaction::Transaction.from_hex(fetch_result.data)
62
+ tx = BSV::Transaction::Tx.from_hex(fetch_result.data)
63
63
 
64
64
  result = {
65
65
  hex: tx.to_hex,
@@ -21,7 +21,7 @@ module BSV
21
21
 
22
22
  # Convert a Transaction to a hash suitable for JSON responses.
23
23
  #
24
- # @param tx [BSV::Transaction::Transaction]
24
+ # @param tx [Transaction::Tx]
25
25
  # @return [Hash]
26
26
  def self.transaction_to_h(tx)
27
27
  {
@@ -33,7 +33,7 @@ module BSV
33
33
  }
34
34
  end
35
35
 
36
- # Convert a TransactionInput to a hash.
36
+ # Convert a Transaction::TransactionInput to a hash.
37
37
  # @api private
38
38
  def self.input_to_h(input, _index)
39
39
  unlock_script = input.unlocking_script
@@ -46,7 +46,7 @@ module BSV
46
46
  }
47
47
  end
48
48
 
49
- # Convert a TransactionOutput to a hash.
49
+ # Convert a Transaction::TransactionOutput to a hash.
50
50
  # @api private
51
51
  def self.output_to_h(output, index)
52
52
  script = output.locking_script
@@ -2,6 +2,7 @@
2
2
 
3
3
  require 'net/http'
4
4
  require 'json'
5
+ require 'openssl'
5
6
  require 'uri'
6
7
 
7
8
  module BSV
@@ -179,7 +180,17 @@ module BSV
179
180
 
180
181
  BSV.logger&.debug { "[Protocol] #{defn[:method].upcase} #{uri}" }
181
182
 
182
- response = execute(uri, request)
183
+ begin
184
+ response = execute(uri, request)
185
+ rescue SocketError, Errno::ECONNREFUSED, Errno::ETIMEDOUT,
186
+ Errno::EHOSTUNREACH, Errno::ENETUNREACH,
187
+ Net::OpenTimeout, Net::ReadTimeout,
188
+ Net::HTTPBadResponse, Net::HTTPHeaderSyntaxError,
189
+ OpenSSL::SSL::SSLError => e
190
+ BSV.logger&.debug { "[Protocol] transport error: #{e.class}: #{e.message}" }
191
+ return ProtocolResponse.new(nil, http_success: false,
192
+ error_message: "transport error: #{e.class}: #{e.message}")
193
+ end
183
194
 
184
195
  build_response(response, defn[:response])
185
196
  end
@@ -25,19 +25,27 @@ module BSV
25
25
  # length and contains only hex characters. This handles hex strings
26
26
  # tagged as ASCII-8BIT (e.g. read from IO in binary mode).
27
27
  #
28
+ # Empty input is rejected — an empty transaction cannot meaningfully
29
+ # be broadcast and any downstream `rawTx: ''` body is a sign of a
30
+ # caller bug, not valid traffic.
31
+ #
28
32
  # @param tx [String, #to_ef_hex, #to_hex] transaction in any supported form
29
33
  # @return [String] hex-encoded transaction
34
+ # @raise [ArgumentError] if `tx` is an empty string
30
35
  def self.resolve_tx_hex(tx)
31
36
  if tx.is_a?(String)
32
- return tx if tx.match?(/\A[0-9a-fA-F]*\z/) && tx.length.even?
37
+ raise ArgumentError, 'tx cannot be empty — refusing to construct an empty broadcast' if tx.empty?
38
+ return tx if tx.match?(/\A[0-9a-fA-F]+\z/) && tx.length.even?
33
39
 
34
40
  return tx.unpack1('H*')
35
41
  end
36
42
 
37
- tx.to_ef_hex
38
- rescue ArgumentError => e
39
- BSV.logger&.debug { "[Network::Util] EF serialisation failed: #{e.message} — falling back to raw hex" }
40
- tx.to_hex
43
+ begin
44
+ tx.to_ef_hex
45
+ rescue ArgumentError => e
46
+ BSV.logger&.debug { "[Network::Util] EF serialisation failed: #{e.message} — falling back to raw hex" }
47
+ tx.to_hex
48
+ end
41
49
  end
42
50
  end
43
51
  end
@@ -80,7 +80,7 @@ module BSV
80
80
  # Computes the BIP-143 sighash (SIGHASH_ALL|FORK_ID) and signs it
81
81
  # using the wallet's derived key for the protocol.
82
82
  #
83
- # @param tx [BSV::Transaction::Transaction] the spending transaction
83
+ # @param tx [Transaction::Tx] the spending transaction
84
84
  # @param input_index [Integer] which input to sign
85
85
  # @return [BSV::Script::Script] the unlocking script
86
86
  def sign(tx, input_index)
@@ -101,7 +101,7 @@ module BSV
101
101
 
102
102
  # Estimated byte length of the unlocking script.
103
103
  #
104
- # @param _tx [BSV::Transaction::Transaction] unused
104
+ # @param _tx [Transaction::Tx] unused
105
105
  # @param _input_index [Integer] unused
106
106
  # @return [Integer]
107
107
  def estimated_length(_tx, _input_index)