bsv-sdk 0.24.0 → 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.
@@ -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
@@ -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::Tx] 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::Tx] 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
@@ -0,0 +1,90 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'openssl'
4
+
5
+ module BSV
6
+ module Storage
7
+ # Helpers for encoding and decoding UHRP (Unified Hash Resource Protocol) URLs.
8
+ #
9
+ # A UHRP URL is a Base58Check string with a two-byte `\xCE\x00` prefix prepended
10
+ # to the SHA-256 hash of the content. This makes each URL self-verifying and
11
+ # stable across storage providers.
12
+ #
13
+ # == URL normalisation
14
+ #
15
+ # `normalize_url` strips the `uhrp:` scheme (case-insensitive) and the optional
16
+ # `//` authority prefix, matching the TS SDK contract exactly. The `web+uhrp://`
17
+ # variant used by some browser extension manifests is *not* normalised here — that
18
+ # diverges from the TS reference (Python normalises it; we follow TS).
19
+ module Utils
20
+ # Two-byte UHRP version prefix: 0xCE 0x00.
21
+ UHRP_PREFIX = "\xCE\x00".b.freeze
22
+
23
+ module_function
24
+
25
+ # Encode a 32-byte binary SHA-256 hash as a UHRP URL.
26
+ #
27
+ # @param hash [String] 32-byte binary string (SHA-256 hash)
28
+ # @return [String] Base58Check-encoded UHRP URL
29
+ # @raise [ArgumentError] if hash is not exactly 32 bytes
30
+ def get_url_for_hash(hash)
31
+ raise ArgumentError, 'Hash length must be 32 bytes (sha256)' unless hash.bytesize == 32
32
+
33
+ BSV::Primitives::Base58.check_encode(hash.b, prefix: UHRP_PREFIX)
34
+ end
35
+
36
+ # Compute the SHA-256 hash of +data+ and encode it as a UHRP URL.
37
+ #
38
+ # @param data [String] binary file content
39
+ # @return [String] Base58Check-encoded UHRP URL
40
+ def get_url_for_file(data)
41
+ hash = OpenSSL::Digest::SHA256.digest(data.b)
42
+ get_url_for_hash(hash)
43
+ end
44
+
45
+ # Decode a UHRP URL and return the 32-byte binary SHA-256 hash.
46
+ #
47
+ # Accepts URLs with or without the `uhrp:` scheme and optional `//` authority.
48
+ #
49
+ # @param url [String] UHRP URL (with or without `uhrp:` prefix)
50
+ # @return [String] 32-byte binary SHA-256 hash
51
+ # @raise [ArgumentError] if the URL is not a String, empty, has a bad prefix, or has wrong length
52
+ # @raise [BSV::Primitives::Base58::ChecksumError] if the Base58Check checksum fails
53
+ def get_hash_from_url(url)
54
+ raise ArgumentError, 'URL must be a String' unless url.is_a?(String)
55
+ raise ArgumentError, 'URL must not be empty' if url.empty?
56
+
57
+ normalised = normalize_url(url)
58
+ result = BSV::Primitives::Base58.check_decode(normalised, prefix_length: 2)
59
+ raise ArgumentError, 'Bad prefix' unless result[:prefix] == UHRP_PREFIX
60
+ raise ArgumentError, 'Invalid length!' unless result[:data].bytesize == 32
61
+
62
+ result[:data]
63
+ end
64
+
65
+ # Strip the `uhrp:` scheme (case-insensitive) and optional `//` from a URL.
66
+ #
67
+ # Follows the TS SDK contract: only `uhrp:` and `uhrp://` are normalised.
68
+ # `web+uhrp://` is deliberately not stripped (TS does not strip it; Python does).
69
+ #
70
+ # @param url [String] URL to normalise
71
+ # @return [String] normalised URL
72
+ def normalize_url(url)
73
+ url = url.slice(5..) if url.downcase.start_with?('uhrp:')
74
+ url = url.slice(2..) if url.start_with?('//')
75
+ url
76
+ end
77
+
78
+ # Return +true+ if +url+ is a syntactically valid UHRP URL.
79
+ #
80
+ # @param url [String] URL to validate
81
+ # @return [Boolean]
82
+ def valid_url?(url)
83
+ get_hash_from_url(url)
84
+ true
85
+ rescue ArgumentError, BSV::Primitives::Base58::ChecksumError
86
+ false
87
+ end
88
+ end
89
+ end
90
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BSV
4
+ # Storage module for UHRP (Unified Hash Resource Protocol) URL encoding,
5
+ # decoding, and validation helpers.
6
+ #
7
+ # UHRP enables content-addressed storage on BSV: a SHA-256 hash of a file
8
+ # is Base58Check-encoded with the `\xCE\x00` prefix to produce a stable,
9
+ # self-verifying URL.
10
+ module Storage
11
+ autoload :Utils, 'bsv/storage/utils'
12
+ autoload :DownloadResult, 'bsv/storage/downloader'
13
+ autoload :Downloader, 'bsv/storage/downloader'
14
+ autoload :DownloadError, 'bsv/storage/errors'
15
+ end
16
+ end