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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +28 -0
- data/lib/bsv/kv_store/entry.rb +15 -0
- data/lib/bsv/kv_store/global.rb +210 -0
- data/lib/bsv/kv_store/interpreter.rb +109 -0
- data/lib/bsv/kv_store/token.rb +10 -0
- data/lib/bsv/kv_store.rb +10 -0
- data/lib/bsv/mcp/tools/helpers.rb +3 -3
- data/lib/bsv/overlay/admin_token_template.rb +2 -2
- data/lib/bsv/overlay/historian.rb +118 -0
- data/lib/bsv/overlay/topic_broadcaster.rb +1 -1
- data/lib/bsv/overlay.rb +1 -0
- data/lib/bsv/primitives/ecies.rb +12 -3
- data/lib/bsv/registry/client.rb +43 -1
- data/lib/bsv/script/bip276.rb +143 -0
- data/lib/bsv/script/push_drop_template.rb +2 -2
- data/lib/bsv/script.rb +1 -0
- data/lib/bsv/storage/downloader.rb +174 -0
- data/lib/bsv/storage/errors.rb +8 -0
- data/lib/bsv/storage/utils.rb +90 -0
- data/lib/bsv/storage.rb +16 -0
- data/lib/bsv/transaction/beef.rb +168 -14
- data/lib/bsv/transaction/beef_party.rb +119 -0
- data/lib/bsv/transaction/chain_tracker.rb +1 -1
- data/lib/bsv/transaction/fee_model.rb +1 -1
- data/lib/bsv/transaction/fee_models/live_policy.rb +1 -1
- data/lib/bsv/transaction/fee_models/satoshis_per_kilobyte.rb +1 -1
- data/lib/bsv/transaction/merkle_path.rb +1 -1
- data/lib/bsv/transaction/p2pkh.rb +1 -1
- data/lib/bsv/transaction/transaction_input.rb +1 -1
- data/lib/bsv/transaction/tx.rb +11 -11
- data/lib/bsv/transaction/unlocking_script_template.rb +2 -2
- data/lib/bsv/transaction.rb +1 -0
- data/lib/bsv/version.rb +1 -1
- data/lib/bsv-sdk.rb +2 -0
- metadata +13 -1
|
@@ -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 [
|
|
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 [
|
|
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
|
@@ -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,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
|
data/lib/bsv/storage.rb
ADDED
|
@@ -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
|