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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +79 -0
- data/README.md +1 -1
- data/lib/bsv/auth/auth_payload.rb +5 -0
- data/lib/bsv/identity/client.rb +9 -5
- 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/broadcast_p2pkh.rb +5 -2
- data/lib/bsv/mcp/tools/decode_tx.rb +1 -1
- data/lib/bsv/mcp/tools/fetch_tx.rb +1 -1
- data/lib/bsv/mcp/tools/helpers.rb +3 -3
- data/lib/bsv/network/protocol.rb +12 -1
- data/lib/bsv/network/util.rb +13 -5
- 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/types.rb +37 -0
- data/lib/bsv/overlay.rb +1 -0
- data/lib/bsv/primitives/ecies.rb +12 -3
- data/lib/bsv/registry/client.rb +54 -7
- data/lib/bsv/script/bip276.rb +143 -0
- data/lib/bsv/script/interpreter/interpreter.rb +1 -1
- 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 +173 -19
- data/lib/bsv/transaction/beef_party.rb +119 -0
- data/lib/bsv/transaction/chain_tracker.rb +2 -2
- 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 +2 -2
- data/lib/bsv/transaction/p2pkh.rb +1 -1
- data/lib/bsv/transaction/transaction_input.rb +1 -1
- data/lib/bsv/transaction/{transaction.rb → tx.rb} +18 -18
- data/lib/bsv/transaction/unlocking_script_template.rb +2 -2
- data/lib/bsv/transaction.rb +3 -2
- data/lib/bsv/version.rb +1 -1
- data/lib/bsv/wallet/proto_wallet/key_deriver.rb +13 -0
- data/lib/bsv/wallet/proto_wallet.rb +12 -2
- data/lib/bsv/wallet/serializer/create_signature.rb +7 -0
- data/lib/bsv/wallet/serializer/get_public_key.rb +5 -1
- data/lib/bsv/wallet/serializer/reveal_counterparty_key_linkage.rb +6 -3
- data/lib/bsv/wallet/serializer/reveal_specific_key_linkage.rb +6 -3
- data/lib/bsv/wallet/serializer/verify_signature.rb +7 -0
- data/lib/bsv-sdk.rb +2 -0
- 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 [
|
|
77
|
+
# @param tx [Transaction::Tx] the transaction to broadcast
|
|
78
78
|
# @return [OverlayBroadcastResult]
|
|
79
79
|
def broadcast(tx)
|
|
80
80
|
beef = serialise_beef(tx)
|
data/lib/bsv/overlay/types.rb
CHANGED
|
@@ -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
|
data/lib/bsv/primitives/ecies.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
data/lib/bsv/registry/client.rb
CHANGED
|
@@ -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
|
|
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::
|
|
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 [
|
|
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::
|
|
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::
|
|
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::
|
|
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 [
|
|
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
|