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
@@ -0,0 +1,118 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BSV
4
+ module Overlay
5
+ # Builds a chronological history (oldest → newest) of typed values by traversing
6
+ # a transaction's input ancestry and interpreting each output with a provided interpreter.
7
+ #
8
+ # The interpreter contract is: +interpreter.call(tx, output_index, ctx)+, returning the
9
+ # typed value or +nil+. Any callable responding to +:call+ is accepted (lambda, proc,
10
+ # method, or object).
11
+ #
12
+ # Traversal follows +input.source_transaction+ references recursively. Callers must
13
+ # supply transactions whose inputs have +source_transaction+ populated.
14
+ #
15
+ # == Cycle safety
16
+ #
17
+ # Each transaction is visited at most once, tracked by its +wtxid+ (wire-order binary).
18
+ #
19
+ # == Value semantics
20
+ #
21
+ # Only +nil+ is excluded. Falsy non-nil values (+false+, +""+, +0+) are valid history
22
+ # entries and are included in the result.
23
+ #
24
+ # == Caching
25
+ #
26
+ # An optional +history_cache+ (any +[]/[]=+ responder) caches complete history results.
27
+ # Cache keys have the form: <tt>"#{interpreter_version}|#{dtxid_hex}|#{ctx_key}"</tt>.
28
+ # Cached arrays are stored frozen; each retrieval returns a +dup+ to protect the cache.
29
+ #
30
+ # == Note
31
+ #
32
+ # The Ruby Historian is synchronous. There is no async/await semantics.
33
+ class Historian
34
+ # @param interpreter [#call] callable: +call(tx, output_index, ctx) → value|nil+
35
+ # @param debug [Boolean] enable debug logging via +BSV.logger+
36
+ # @param history_cache [Hash, nil] optional cache store (+[]/[]=+ responder)
37
+ # @param interpreter_version [String] version tag for cache key invalidation
38
+ # @param ctx_key_fn [#call, nil] serialises context to a string cache key
39
+ def initialize(interpreter, debug: false, history_cache: nil, interpreter_version: 'v1', ctx_key_fn: nil)
40
+ @interpreter = interpreter
41
+ @debug = debug
42
+ @history_cache = history_cache
43
+ @interpreter_version = interpreter_version
44
+ @ctx_key_fn = ctx_key_fn
45
+ end
46
+
47
+ # Traverses input ancestry from +start_transaction+ and returns all interpreted
48
+ # values in chronological order (oldest first).
49
+ #
50
+ # @param start_transaction [Transaction::Tx]
51
+ # @param context [Object, nil] forwarded verbatim to the interpreter
52
+ # @return [Array] interpreted values, oldest first
53
+ def build_history(start_transaction, context = nil)
54
+ if @history_cache
55
+ key = cache_key(start_transaction, context)
56
+ cached = @history_cache[key]
57
+ unless cached.nil?
58
+ BSV.logger&.debug { "[Historian] History cache hit: #{key}" } if @debug
59
+ return cached.dup
60
+ end
61
+ end
62
+
63
+ history = []
64
+ visited = {}
65
+
66
+ traverse(start_transaction, context, history, visited)
67
+
68
+ result = history.reverse
69
+
70
+ if @history_cache
71
+ key ||= cache_key(start_transaction, context)
72
+ @history_cache[key] = result.dup.freeze
73
+ BSV.logger&.debug { "[Historian] History cached: #{key}" } if @debug
74
+ end
75
+
76
+ result
77
+ end
78
+
79
+ private
80
+
81
+ def cache_key(tx, context)
82
+ ctx_key = @ctx_key_fn ? @ctx_key_fn.call(context) : context.to_s
83
+ "#{@interpreter_version}|#{tx.dtxid_hex}|#{ctx_key}"
84
+ end
85
+
86
+ def traverse(tx, context, history, visited)
87
+ id = tx.wtxid
88
+
89
+ if visited.key?(id)
90
+ BSV.logger&.debug { "[Historian] Skipping already visited transaction: #{tx.dtxid_hex}" } if @debug
91
+ return
92
+ end
93
+
94
+ visited[id] = true
95
+
96
+ BSV.logger&.debug { "[Historian] Processing transaction: #{tx.dtxid_hex}" } if @debug
97
+
98
+ tx.outputs.each_with_index do |_output, index|
99
+ value = @interpreter.call(tx, index, context)
100
+ unless value.nil?
101
+ history << value
102
+ BSV.logger&.debug { "[Historian] Added value to history: #{value.inspect}" } if @debug
103
+ end
104
+ rescue StandardError => e
105
+ BSV.logger&.debug { "[Historian] Failed to interpret output #{index}: #{e.message}" } if @debug
106
+ end
107
+
108
+ tx.inputs.each do |input|
109
+ if input.source_transaction
110
+ traverse(input.source_transaction, context, history, visited)
111
+ elsif @debug
112
+ BSV.logger&.debug { '[Historian] Input missing sourceTransaction, skipping' }
113
+ end
114
+ end
115
+ end
116
+ end
117
+ end
118
+ end
@@ -74,7 +74,7 @@ module BSV
74
74
 
75
75
  # Broadcast a transaction to all interested overlay hosts.
76
76
  #
77
- # @param tx [BSV::Transaction::Transaction] the transaction to broadcast
77
+ # @param tx [Transaction::Tx] the transaction to broadcast
78
78
  # @return [OverlayBroadcastResult]
79
79
  def broadcast(tx)
80
80
  beef = serialise_beef(tx)
@@ -108,6 +108,43 @@ module BSV
108
108
  @code = code
109
109
  @description = description
110
110
  end
111
+
112
+ # @return [Boolean] true when the broadcast reached at least one
113
+ # competent host and satisfied any acknowledgement requirements.
114
+ def success?
115
+ @status == 'success'
116
+ end
117
+
118
+ # Raise the appropriate {OverlayError} subclass when {#success?} is
119
+ # false. Lets callers treat a broadcast failure as an exception rather
120
+ # than silently swallowing the result object — used by Identity and
121
+ # Registry clients which terminate on broadcast failure regardless.
122
+ #
123
+ # @return [self] when the result is a success (chainable)
124
+ # @raise [NoCompetentHostsError] for +ERR_NO_HOSTS_INTERESTED+
125
+ # @raise [AllHostsRejectedError] for +ERR_ALL_HOSTS_REJECTED+
126
+ # @raise [AcknowledgementError] for +ERR_REQUIRE_ACK_*_FAILED+
127
+ # @raise [OverlayError] for any other non-success status
128
+ def raise_if_error!
129
+ return self if success?
130
+
131
+ message = [@code, @description].compact.join(': ')
132
+ raise error_class_for_code, message
133
+ end
134
+
135
+ private
136
+
137
+ def error_class_for_code
138
+ case @code
139
+ when 'ERR_NO_HOSTS_INTERESTED' then NoCompetentHostsError
140
+ when 'ERR_ALL_HOSTS_REJECTED' then AllHostsRejectedError
141
+ when 'ERR_REQUIRE_ACK_FROM_ANY_HOST_FAILED',
142
+ 'ERR_REQUIRE_ACK_FROM_ALL_HOSTS_FAILED',
143
+ 'ERR_REQUIRE_ACK_FROM_SPECIFIC_HOSTS_FAILED'
144
+ AcknowledgementError
145
+ else OverlayError
146
+ end
147
+ end
111
148
  end
112
149
  end
113
150
  end
data/lib/bsv/overlay.rb CHANGED
@@ -25,5 +25,6 @@ module BSV
25
25
  autoload :HTTPSBroadcastFacilitator, 'bsv/overlay/broadcast_facilitator'
26
26
  autoload :TopicBroadcaster, 'bsv/overlay/topic_broadcaster'
27
27
  autoload :SHIPBroadcaster, 'bsv/overlay/topic_broadcaster'
28
+ autoload :Historian, 'bsv/overlay/historian'
28
29
  end
29
30
  end
@@ -147,15 +147,24 @@ module BSV
147
147
  # @param message [String] the plaintext message
148
148
  # @param public_key [PublicKey] the recipient's public key
149
149
  # @param private_key [PrivateKey, nil] optional ephemeral key (random if omitted)
150
+ # @param iv [String, nil] optional 16-byte ASCII-8BIT IV. When omitted a random IV is
151
+ # generated via +SecureRandom+. Supply a fixed value only for deterministic test
152
+ # vectors — **never use a fixed IV in production**.
150
153
  # @return [String] encrypted payload
151
- def bitcore_encrypt(message, public_key, private_key: nil)
154
+ # @raise [ArgumentError] if +iv+ is supplied but is not exactly 16 bytes
155
+ def bitcore_encrypt(message, public_key, private_key: nil, iv: nil)
152
156
  message = message.b if message.encoding != Encoding::ASCII_8BIT
153
157
 
158
+ if iv
159
+ iv = iv.b if iv.encoding != Encoding::ASCII_8BIT
160
+ raise ArgumentError, 'iv must be exactly 16 bytes' unless iv.bytesize == 16
161
+ else
162
+ iv = SecureRandom.random_bytes(16)
163
+ end
164
+
154
165
  ephemeral = private_key || PrivateKey.generate
155
166
  key_e, key_m = derive_bitcore_keys(ephemeral, public_key)
156
167
 
157
- iv = SecureRandom.random_bytes(16)
158
-
159
168
  cipher = OpenSSL::Cipher.new('aes-256-cbc')
160
169
  cipher.encrypt
161
170
  cipher.key = key_e
@@ -50,7 +50,9 @@ module BSV
50
50
  # @param data [BasketDefinitionData, ProtocolDefinitionData, CertificateDefinitionData]
51
51
  # structured definition data
52
52
  # @return [BSV::Overlay::OverlayBroadcastResult]
53
- # @raise [RuntimeError] if the transaction cannot be created or broadcast
53
+ # @raise [RuntimeError] if the transaction cannot be created
54
+ # @raise [BSV::Overlay::OverlayError] (or a subclass) if the overlay broadcast fails —
55
+ # see {BSV::Overlay::OverlayBroadcastResult#raise_if_error!} for the mapping
54
56
  def register_definition(definition_type, data)
55
57
  registry_operator = identity_key
56
58
  fields = build_pushdrop_fields(definition_type, data, registry_operator)
@@ -81,8 +83,8 @@ module BSV
81
83
 
82
84
  raise "Register failed: could not create #{definition_type} registration transaction" if create_result[:tx].nil?
83
85
 
84
- tx = BSV::Transaction::Transaction.from_beef(normalise_beef(create_result[:tx]))
85
- broadcaster_for(definition_type).broadcast(tx)
86
+ tx = BSV::Transaction::Tx.from_beef(normalise_beef(create_result[:tx]))
87
+ broadcaster_for(definition_type).broadcast(tx).raise_if_error!
86
88
  end
87
89
 
88
90
  # Resolves registry definitions of a given type using the overlay lookup service.
@@ -110,6 +112,48 @@ module BSV
110
112
  end
111
113
  end
112
114
 
115
+ # Resolves basket registry definitions.
116
+ #
117
+ # Thin wrapper around {#resolve} for cross-SDK parity with the Go SDK's
118
+ # +ResolveBasket+ method.
119
+ #
120
+ # @param query [Hash] optional filter criteria:
121
+ # - +:basket_id+ [String] exact basket identifier
122
+ # - +:name+ [String] human-readable basket name
123
+ # - +:registry_operators+ [Array<String>] operator public key hexes
124
+ # @return [Array<RegisteredDefinition>] matching registered basket definitions
125
+ def resolve_basket(query = {})
126
+ resolve(DefinitionType::BASKET, query)
127
+ end
128
+
129
+ # Resolves protocol registry definitions.
130
+ #
131
+ # Thin wrapper around {#resolve} for cross-SDK parity with the Go SDK's
132
+ # +ResolveProtocol+ method.
133
+ #
134
+ # @param query [Hash] optional filter criteria:
135
+ # - +:name+ [String] human-readable protocol name
136
+ # - +:protocol_id+ [Array] BRC-43 two-element protocol ID, e.g. +[1, 'protomap']+
137
+ # - +:registry_operators+ [Array<String>] operator public key hexes
138
+ # @return [Array<RegisteredDefinition>] matching registered protocol definitions
139
+ def resolve_protocol(query = {})
140
+ resolve(DefinitionType::PROTOCOL, query)
141
+ end
142
+
143
+ # Resolves certificate type registry definitions.
144
+ #
145
+ # Thin wrapper around {#resolve} for cross-SDK parity with the Go SDK's
146
+ # +ResolveCertificate+ method.
147
+ #
148
+ # @param query [Hash] optional filter criteria:
149
+ # - +:type+ [String] Base64-encoded certificate type identifier
150
+ # - +:name+ [String] human-readable certificate type name
151
+ # - +:registry_operators+ [Array<String>] operator public key hexes
152
+ # @return [Array<RegisteredDefinition>] matching registered certificate type definitions
153
+ def resolve_certificate(query = {})
154
+ resolve(DefinitionType::CERTIFICATE, query)
155
+ end
156
+
113
157
  # Lists the registry operator's own published definitions for the given type.
114
158
  #
115
159
  # Queries the wallet for spendable outputs in the appropriate basket,
@@ -428,7 +472,7 @@ module BSV
428
472
  #
429
473
  # @param definition_type [String]
430
474
  # @param output [Hash] wallet output with :outpoint, :satoshis keys
431
- # @param beef [BSV::Transaction::Beef] parsed BEEF
475
+ # @param beef [Transaction::Beef] parsed BEEF
432
476
  # @param beef_raw [String] raw BEEF bytes
433
477
  # @return [RegisteredDefinition, nil]
434
478
  def parse_own_output_to_registered_definition(definition_type, output, beef, beef_raw)
@@ -463,9 +507,12 @@ module BSV
463
507
  # @param definition_type [String]
464
508
  # @param create_result [Hash] result from wallet.create_action containing :signable_transaction
465
509
  # @return [BSV::Overlay::OverlayBroadcastResult]
510
+ # @raise [RuntimeError] if the transaction cannot be signed
511
+ # @raise [BSV::Overlay::OverlayError] (or a subclass) if the overlay broadcast fails —
512
+ # see {BSV::Overlay::OverlayBroadcastResult#raise_if_error!} for the mapping
466
513
  def sign_and_broadcast(definition_type, create_result)
467
514
  signable = create_result[:signable_transaction]
468
- partial_tx = BSV::Transaction::Transaction.from_beef(normalise_beef(signable[:tx]))
515
+ partial_tx = BSV::Transaction::Tx.from_beef(normalise_beef(signable[:tx]))
469
516
 
470
517
  template = BSV::Script::PushDropTemplate.new(wallet: @wallet, originator: @originator)
471
518
  unlocker = template.unlock(
@@ -488,8 +535,8 @@ module BSV
488
535
 
489
536
  raise 'Revoke failed: could not sign transaction' if sign_result[:tx].nil?
490
537
 
491
- signed_tx = BSV::Transaction::Transaction.from_beef(normalise_beef(sign_result[:tx]))
492
- broadcaster_for(definition_type).broadcast(signed_tx)
538
+ signed_tx = BSV::Transaction::Tx.from_beef(normalise_beef(sign_result[:tx]))
539
+ broadcaster_for(definition_type).broadcast(signed_tx).raise_if_error!
493
540
  end
494
541
 
495
542
  # Verifies that the registered definition belongs to the current wallet.
@@ -0,0 +1,143 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'bsv/primitives/digest'
4
+
5
+ module BSV
6
+ module Script
7
+ # BIP-276 text encoding for scripts and templates.
8
+ #
9
+ # Encodes and decodes typed bitcoin data using the scheme described in
10
+ # https://github.com/moneybutton/bips/blob/master/bip-0276.mediawiki
11
+ #
12
+ # Format: `<prefix>:<version-byte><network-byte><hex-payload><checksum>`
13
+ # where version and network are each one byte (two hex digits), and
14
+ # checksum is the first 4 bytes of double-SHA256 over the full preimage
15
+ # (the string up to and including the payload), hex-encoded.
16
+ #
17
+ # @note Field order is version-then-network, matching the BIP-276 spec
18
+ # and the Go SDK reference. The Python SDK has these reversed — a known
19
+ # py-sdk bug that is invisible at default v=1, n=1.
20
+ module BIP276
21
+ # Returned by {decode}; immutable value object.
22
+ Result = Data.define(:prefix, :version, :network, :data)
23
+
24
+ # Custom exception hierarchy.
25
+ class Error < StandardError; end
26
+ class InvalidFormat < Error; end
27
+ class InvalidChecksum < Error; end
28
+
29
+ PREFIX_SCRIPT = 'bitcoin-script'
30
+ PREFIX_TEMPLATE = 'bitcoin-template'
31
+ CURRENT_VERSION = 1
32
+ NETWORK_MAINNET = 1
33
+ NETWORK_TESTNET = 2
34
+
35
+ # Regex: prefix(:)(version 2 hex)(network 2 hex)(data hex*)(checksum 8 hex)
36
+ VALID_BIP276 = /\A(.+?):([0-9A-Fa-f]{2})([0-9A-Fa-f]{2})([0-9A-Fa-f]*)([0-9A-Fa-f]{8})\z/
37
+
38
+ module_function
39
+
40
+ # Encode a binary payload as a BIP-276 string.
41
+ #
42
+ # @param data [String] binary payload (e.g. a {Script::Script}'s binary form)
43
+ # @param prefix [String] {PREFIX_SCRIPT} or {PREFIX_TEMPLATE} (or any custom prefix)
44
+ # @param version [Integer] 1..255 — {CURRENT_VERSION} by default
45
+ # @param network [Integer] 1..255 — {NETWORK_MAINNET} by default
46
+ # @return [String] BIP-276 encoded string
47
+ # @raise [ArgumentError] if version or network is out of the range 1..255
48
+ def encode(data, prefix: PREFIX_SCRIPT, network: NETWORK_MAINNET, version: CURRENT_VERSION)
49
+ raise ArgumentError, "version must be 1..255, got #{version}" unless (1..255).cover?(version)
50
+ raise ArgumentError, "network must be 1..255, got #{network}" unless (1..255).cover?(network)
51
+
52
+ preimage = build_preimage(prefix, version, network, data)
53
+ preimage + checksum(preimage)
54
+ end
55
+
56
+ # Decode a BIP-276 string.
57
+ #
58
+ # @param str [String] BIP-276 encoded string
59
+ # @return [Result] value object with +:prefix+, +:version+, +:network+, +:data+
60
+ # @raise [InvalidFormat] if the string is structurally malformed
61
+ # @raise [InvalidChecksum] if the checksum does not match the payload
62
+ def decode(str)
63
+ match = VALID_BIP276.match(str)
64
+ raise InvalidFormat, 'not a valid BIP-276 string' unless match
65
+
66
+ prefix = match[1]
67
+ version = match[2].to_i(16)
68
+ network = match[3].to_i(16)
69
+
70
+ raise InvalidFormat, 'data payload has odd number of hex digits' if match[4].length.odd?
71
+
72
+ data = [match[4]].pack('H*')
73
+
74
+ # Compute the checksum over the EXACT input bytes (everything except
75
+ # the trailing 8-hex-digit checksum) rather than rebuilding the
76
+ # preimage from parsed fields. Rebuilding via build_preimage would
77
+ # lowercase the payload hex, causing valid mixed-case input to fail.
78
+ preimage = str[0..-9]
79
+ expected = checksum(preimage)
80
+ raise InvalidChecksum, 'checksum mismatch' unless match[5].downcase == expected
81
+
82
+ Result.new(prefix: prefix, version: version, network: network, data: data)
83
+ end
84
+
85
+ # Encode a script payload using the {PREFIX_SCRIPT} prefix.
86
+ #
87
+ # @param (see encode)
88
+ # @return [String] BIP-276 encoded string with +bitcoin-script+ prefix
89
+ def encode_script(data, network: NETWORK_MAINNET, version: CURRENT_VERSION)
90
+ encode(data, prefix: PREFIX_SCRIPT, network: network, version: version)
91
+ end
92
+
93
+ # Encode a template payload using the {PREFIX_TEMPLATE} prefix.
94
+ #
95
+ # @param (see encode)
96
+ # @return [String] BIP-276 encoded string with +bitcoin-template+ prefix
97
+ def encode_template(data, network: NETWORK_MAINNET, version: CURRENT_VERSION)
98
+ encode(data, prefix: PREFIX_TEMPLATE, network: network, version: version)
99
+ end
100
+
101
+ # Decode a BIP-276 string that must have the {PREFIX_SCRIPT} prefix.
102
+ #
103
+ # @param str [String] BIP-276 encoded string
104
+ # @return [Result]
105
+ # @raise [InvalidFormat] if prefix is not +bitcoin-script+
106
+ # @raise [InvalidChecksum] if the checksum does not match
107
+ def decode_script(str)
108
+ result = decode(str)
109
+ raise InvalidFormat, "expected prefix '#{PREFIX_SCRIPT}', got '#{result.prefix}'" \
110
+ unless result.prefix == PREFIX_SCRIPT
111
+
112
+ result
113
+ end
114
+
115
+ # Decode a BIP-276 string that must have the {PREFIX_TEMPLATE} prefix.
116
+ #
117
+ # @param str [String] BIP-276 encoded string
118
+ # @return [Result]
119
+ # @raise [InvalidFormat] if prefix is not +bitcoin-template+
120
+ # @raise [InvalidChecksum] if the checksum does not match
121
+ def decode_template(str)
122
+ result = decode(str)
123
+ raise InvalidFormat, "expected prefix '#{PREFIX_TEMPLATE}', got '#{result.prefix}'" \
124
+ unless result.prefix == PREFIX_TEMPLATE
125
+
126
+ result
127
+ end
128
+
129
+ # @api private
130
+ def build_preimage(prefix, version, network, data)
131
+ format('%<prefix>s:%<version>02x%<network>02x%<payload>s',
132
+ prefix: prefix, version: version, network: network, payload: data.unpack1('H*'))
133
+ end
134
+ private_class_method :build_preimage
135
+
136
+ # @api private
137
+ def checksum(preimage)
138
+ BSV::Primitives::Digest.sha256d(preimage)[0, 4].unpack1('H*')
139
+ end
140
+ private_class_method :checksum
141
+ end
142
+ end
143
+ end
@@ -70,7 +70,7 @@ module BSV
70
70
 
71
71
  # Verify a transaction input by evaluating its scripts.
72
72
  #
73
- # @param tx [Transaction::Transaction] the transaction being verified
73
+ # @param tx [Transaction::Tx] the transaction being verified
74
74
  # @param input_index [Integer] the input index within the transaction
75
75
  # @param unlock_script [Script] the input's unlocking script
76
76
  # @param lock_script [Script] the previous output's locking script
@@ -93,7 +93,7 @@ module BSV
93
93
  # the wallet's derived key, then returns a P2PKH unlock wrapped in a
94
94
  # PushDrop unlock (which is a pass-through).
95
95
  #
96
- # @param tx [BSV::Transaction::Transaction] the spending transaction
96
+ # @param tx [Transaction::Tx] the spending transaction
97
97
  # @param input_index [Integer] which input to sign
98
98
  # @return [BSV::Script::Script] the unlocking script
99
99
  def sign(tx, input_index)
@@ -125,7 +125,7 @@ module BSV
125
125
 
126
126
  # Estimated byte length of the unlocking script.
127
127
  #
128
- # @param _tx [BSV::Transaction::Transaction] unused
128
+ # @param _tx [Transaction::Tx] unused
129
129
  # @param _input_index [Integer] unused
130
130
  # @return [Integer]
131
131
  def estimated_length(_tx, _input_index)
data/lib/bsv/script.rb CHANGED
@@ -18,5 +18,6 @@ module BSV
18
18
  autoload :Stack, 'bsv/script/interpreter/stack'
19
19
 
20
20
  autoload :PushDropTemplate, 'bsv/script/push_drop_template'
21
+ autoload :BIP276, 'bsv/script/bip276'
21
22
  end
22
23
  end
@@ -0,0 +1,174 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'net/http'
4
+ require 'openssl'
5
+ require 'uri'
6
+
7
+ module BSV
8
+ module Storage
9
+ # Result returned by {Downloader#download}.
10
+ DownloadResult = Data.define(:data, :mime_type)
11
+
12
+ # Downloads UHRP-addressed content from distributed storage hosts.
13
+ #
14
+ # Resolution: queries the +ls_uhrp+ lookup service via a {BSV::Overlay::LookupResolver},
15
+ # decodes each PushDrop output to extract the host URL (field[2]) and expiry timestamp
16
+ # (field[3], varint). Expired entries are silently dropped.
17
+ #
18
+ # Download: fetches each resolved URL in turn. Any HTTP 4xx/5xx, empty body, or
19
+ # network exception is treated as a failed host and the next URL is tried. This
20
+ # matches the TS SDK contract — no special treatment of 401/402/403.
21
+ #
22
+ # HTTP redirects are not followed (Net::HTTP default). This is intentional in v1.
23
+ #
24
+ # Download URLs are taken directly from the overlay; no private-IP filter is applied
25
+ # here. (The LookupResolver applies SSRF filtering to SLAP-discovered *infrastructure*
26
+ # hosts — content URLs are end-user data and are not subject to the same filter.)
27
+ class Downloader
28
+ # @param network_preset [Symbol] :mainnet, :testnet, or :local
29
+ # @param lookup_resolver [BSV::Overlay::LookupResolver] injectable resolver (nil = default)
30
+ # @param http_client [#call, nil] injectable HTTP client for testing.
31
+ # Must respond to +.call(url_string)+ and return an object exposing +#code+ (Integer),
32
+ # +#body+ (binary String), and +#[](header_name)+ for header access.
33
+ # Default: a thin {Net::HTTP.get_response} wrapper.
34
+ # @param timeout [Integer] per-request timeout in seconds
35
+ def initialize(network_preset: :mainnet, lookup_resolver: nil, http_client: nil, timeout: 30)
36
+ @lookup_resolver = lookup_resolver || BSV::Overlay::LookupResolver.new(network_preset: network_preset)
37
+ @http_client = http_client
38
+ @timeout = timeout
39
+ end
40
+
41
+ # Resolve a UHRP URL to a list of HTTP(S) download URLs.
42
+ #
43
+ # Queries the +ls_uhrp+ lookup service, decodes each PushDrop output,
44
+ # drops expired entries, and returns the remaining URLs.
45
+ #
46
+ # @param uhrp_url [String] UHRP URL (with or without +uhrp://+ prefix)
47
+ # @return [Array<String>] resolved HTTP(S) download URLs
48
+ # @raise [BSV::Storage::DownloadError] if the lookup answer is not an output-list
49
+ def resolve(uhrp_url)
50
+ question = BSV::Overlay::LookupQuestion.new(
51
+ service: 'ls_uhrp',
52
+ query: { 'uhrpUrl' => BSV::Storage::Utils.normalize_url(uhrp_url) }
53
+ )
54
+ answer = @lookup_resolver.query(question)
55
+ raise DownloadError, 'Lookup answer must be an output list' unless answer.type == 'output-list'
56
+
57
+ current_time = Time.now.to_i
58
+ urls = []
59
+
60
+ answer.outputs.each do |output|
61
+ url = decode_output_url(output, current_time)
62
+ urls << url if url
63
+ end
64
+
65
+ urls
66
+ end
67
+
68
+ # Download the content addressed by +uhrp_url+.
69
+ #
70
+ # Validates the URL, resolves it to a list of hosts, then attempts each
71
+ # host in order. Verifies the SHA-256 hash of the downloaded body against
72
+ # the hash embedded in the UHRP URL. Returns on the first successful match.
73
+ #
74
+ # @param uhrp_url [String] UHRP URL
75
+ # @return [BSV::Storage::DownloadResult] downloaded data and MIME type
76
+ # @raise [ArgumentError] if the URL is not a valid UHRP URL
77
+ # @raise [BSV::Storage::DownloadError] if no host yields content matching the hash
78
+ def download(uhrp_url)
79
+ raise ArgumentError, 'Invalid parameter UHRP url' unless BSV::Storage::Utils.valid_url?(uhrp_url)
80
+
81
+ urls = resolve(uhrp_url)
82
+ raise DownloadError, 'No one currently hosts this file!' if urls.empty?
83
+
84
+ expected_hash = BSV::Storage::Utils.get_hash_from_url(uhrp_url)
85
+
86
+ urls.each do |url|
87
+ result = attempt_download(url, expected_hash)
88
+ return result if result
89
+ end
90
+
91
+ raise DownloadError, "Unable to download content from #{uhrp_url}"
92
+ end
93
+
94
+ private
95
+
96
+ def decode_output_url(output, current_time)
97
+ beef_data = output['beef'] || output[:beef]
98
+ output_index = (output['outputIndex'] || output[:output_index] || 0).to_i
99
+ return nil if beef_data.nil?
100
+ return nil if output_index.negative?
101
+
102
+ beef = parse_beef(beef_data)
103
+ return nil unless beef
104
+
105
+ beef_tx = beef.transactions.last
106
+ return nil if beef_tx.nil? || beef_tx.is_a?(BSV::Transaction::Beef::TxidOnlyEntry)
107
+
108
+ txout = beef_tx.transaction.outputs[output_index]
109
+ return nil unless txout
110
+
111
+ fields = txout.locking_script.pushdrop_fields
112
+ return nil if fields.nil? || fields.length < 4
113
+
114
+ expiry, = BSV::Transaction::VarInt.decode(fields[3])
115
+ return nil if expiry < current_time
116
+
117
+ url = fields[2].force_encoding('UTF-8')
118
+ return nil unless url.valid_encoding?
119
+
120
+ url
121
+ rescue StandardError
122
+ nil
123
+ end
124
+
125
+ def parse_beef(beef_data)
126
+ case beef_data
127
+ when String
128
+ BSV::Transaction::Beef.from_binary(beef_data)
129
+ when Array
130
+ BSV::Transaction::Beef.from_binary(beef_data.pack('C*'))
131
+ end
132
+ rescue StandardError
133
+ nil
134
+ end
135
+
136
+ def attempt_download(url, expected_hash)
137
+ response = fetch(url)
138
+ return nil if response.nil?
139
+
140
+ code = response.code.to_i
141
+ # 3xx is treated as a failed host: redirects are intentionally not followed in v1,
142
+ # so a 302 body is never the content we want.
143
+ return nil if code >= 300
144
+
145
+ body = response.body.to_s
146
+ return nil if body.empty?
147
+
148
+ actual_hash = OpenSSL::Digest::SHA256.digest(body)
149
+ return nil unless actual_hash == expected_hash
150
+
151
+ DownloadResult.new(data: body, mime_type: response['Content-Type'])
152
+ rescue StandardError
153
+ nil
154
+ end
155
+
156
+ def fetch(url)
157
+ if @http_client
158
+ @http_client.call(url)
159
+ else
160
+ uri = URI(url)
161
+ Net::HTTP.start(
162
+ uri.hostname,
163
+ uri.port,
164
+ use_ssl: uri.scheme == 'https',
165
+ open_timeout: @timeout,
166
+ read_timeout: @timeout
167
+ ) { |http| http.request(Net::HTTP::Get.new(uri)) }
168
+ end
169
+ rescue StandardError
170
+ nil
171
+ end
172
+ end
173
+ end
174
+ end
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BSV
4
+ module Storage
5
+ # Raised when a UHRP file cannot be downloaded from any available host.
6
+ class DownloadError < StandardError; end
7
+ end
8
+ end