bsv-sdk 0.4.0 → 0.5.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,402 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'set'
4
+ require 'json'
5
+ require 'uri'
6
+
7
+ module BSV
8
+ module Overlay
9
+ # Broadcasts transactions to overlay topics via SHIP (Service Host Interconnect Protocol).
10
+ #
11
+ # Discovers interested overlay hosts by querying the +ls_ship+ SLAP service,
12
+ # then dispatches a TaggedBEEF to each host in parallel and verifies
13
+ # acknowledgements according to the configured requirements.
14
+ #
15
+ # == Topic validation
16
+ #
17
+ # All topics must be non-empty strings beginning with +tm_+. An empty
18
+ # topics array or a topic without the +tm_+ prefix raises ArgumentError.
19
+ #
20
+ # == Acknowledgement modes
21
+ #
22
+ # Three independent ack modes may be combined:
23
+ #
24
+ # - +require_ack_from_any_host+ (default: +'all'+) — at least one successful
25
+ # host must satisfy the requirement.
26
+ # - +require_ack_from_all_hosts+ (default: +[]+) — every successful host must
27
+ # satisfy the requirement. An empty array disables this check.
28
+ # - +require_ack_from_specific_hosts+ (default: +{}+) — named hosts must
29
+ # each satisfy their individual requirement.
30
+ #
31
+ # Requirement values:
32
+ # - +'all'+ — the host must have acknowledged every one of the broadcaster's topics.
33
+ # - +'any'+ — the host must have acknowledged at least one topic.
34
+ # - +Array<String>+ — the host must have acknowledged all topics in the array.
35
+ #
36
+ # == Host caching
37
+ #
38
+ # SHIP host discovery results are cached for +SHIP_CACHE_TTL+ seconds (5 minutes)
39
+ # to avoid redundant SLAP queries on repeated broadcasts.
40
+ class TopicBroadcaster
41
+ # Seconds before the SHIP host cache expires.
42
+ SHIP_CACHE_TTL = 300
43
+
44
+ # @param topics [Array<String>] overlay topic names (must start with +tm_+)
45
+ # @param network_preset [Symbol] +:mainnet+, +:testnet+, or +:local+
46
+ # @param facilitator [BroadcastFacilitator, nil] injectable facilitator
47
+ # @param resolver [LookupResolver, nil] injectable resolver
48
+ # @param require_ack_from_all_hosts [Array, String] requirement all hosts must satisfy
49
+ # @param require_ack_from_any_host [String] requirement at least one host must satisfy
50
+ # @param require_ack_from_specific_hosts [Hash] per-host requirements
51
+ def initialize(
52
+ topics,
53
+ network_preset: :mainnet,
54
+ facilitator: nil,
55
+ resolver: nil,
56
+ require_ack_from_all_hosts: [],
57
+ require_ack_from_any_host: 'all',
58
+ require_ack_from_specific_hosts: {}
59
+ )
60
+ validate_topics!(topics)
61
+
62
+ @topics = topics.dup.freeze
63
+ @network_preset = network_preset
64
+ @facilitator = facilitator || default_facilitator
65
+ @resolver = resolver || default_resolver
66
+
67
+ @require_ack_from_all_hosts = require_ack_from_all_hosts
68
+ @require_ack_from_any_host = require_ack_from_any_host
69
+ @require_ack_from_specific_hosts = require_ack_from_specific_hosts
70
+
71
+ @ship_cache = nil
72
+ @ship_cache_at = nil
73
+ @ship_cache_mutex = Mutex.new
74
+ end
75
+
76
+ # Broadcast a transaction to all interested overlay hosts.
77
+ #
78
+ # @param tx [BSV::Transaction::Transaction] the transaction to broadcast
79
+ # @return [OverlayBroadcastResult]
80
+ def broadcast(tx)
81
+ beef = serialise_beef(tx)
82
+ return beef if beef.is_a?(OverlayBroadcastResult)
83
+
84
+ if local?
85
+ host_topics = { 'http://localhost:8080' => Set.new(@topics) }
86
+ else
87
+ host_topics = find_interested_hosts
88
+ if host_topics.empty?
89
+ return error_result(
90
+ 'ERR_NO_HOSTS_INTERESTED',
91
+ "No #{@network_preset} hosts are interested in receiving this transaction."
92
+ )
93
+ end
94
+ end
95
+
96
+ results = dispatch(beef, host_topics)
97
+
98
+ successful = results.compact
99
+ if successful.empty?
100
+ return error_result(
101
+ 'ERR_ALL_HOSTS_REJECTED',
102
+ "All #{@network_preset} topical hosts have rejected the transaction."
103
+ )
104
+ end
105
+
106
+ host_acks = build_host_acks(successful)
107
+
108
+ ack_check = check_ack_requirements(host_acks)
109
+ return ack_check if ack_check.is_a?(OverlayBroadcastResult)
110
+
111
+ OverlayBroadcastResult.new(
112
+ status: 'success',
113
+ txid: tx.txid_hex,
114
+ message: "Sent to #{successful.size} Overlay Service host(s)."
115
+ )
116
+ end
117
+
118
+ # Discover overlay hosts interested in the broadcaster's topics via SHIP.
119
+ #
120
+ # Results are cached for +SHIP_CACHE_TTL+ seconds.
121
+ #
122
+ # @return [Hash{String => Set<String>}] map of host URL to set of interested topics
123
+ def find_interested_hosts
124
+ @ship_cache_mutex.synchronize do
125
+ if @ship_cache && (Time.now.to_f - @ship_cache_at) < SHIP_CACHE_TTL
126
+ return @ship_cache.transform_values(&:dup)
127
+ end
128
+
129
+ hosts = fetch_ship_hosts
130
+ @ship_cache = hosts
131
+ @ship_cache_at = Time.now.to_f
132
+ hosts.transform_values(&:dup)
133
+ end
134
+ end
135
+
136
+ private
137
+
138
+ # ---- Topic validation ----
139
+
140
+ def validate_topics!(topics)
141
+ raise ArgumentError, 'topics must be a non-empty array' if topics.nil? || topics.empty?
142
+
143
+ topics.each do |topic|
144
+ raise ArgumentError, "topic #{topic.inspect} must start with 'tm_'" unless topic.is_a?(String) && topic.start_with?('tm_')
145
+ end
146
+ end
147
+
148
+ # ---- BEEF serialisation ----
149
+
150
+ def serialise_beef(tx)
151
+ tx.to_beef
152
+ rescue StandardError => e
153
+ error_result('ERR_BEEF_SERIALIZATION_FAILED', "Failed to serialise transaction to BEEF: #{e.message}")
154
+ end
155
+
156
+ # ---- SHIP host discovery ----
157
+
158
+ def fetch_ship_hosts
159
+ question = LookupQuestion.new(
160
+ service: 'ls_ship',
161
+ query: { 'topics' => @topics }
162
+ )
163
+
164
+ answer = @resolver.query(question)
165
+ return {} unless answer.type == 'output-list'
166
+
167
+ parse_ship_answer(answer)
168
+ rescue StandardError
169
+ {}
170
+ end
171
+
172
+ def parse_ship_answer(answer)
173
+ results = {}
174
+
175
+ answer.outputs.each do |output|
176
+ beef_data = output['beef'] || output[:beef]
177
+ output_index = (output['outputIndex'] || output[:output_index] || 0).to_i
178
+ next if output_index.negative?
179
+
180
+ next unless beef_data
181
+
182
+ advert = decode_ship_advert(beef_data, output_index)
183
+ next unless advert
184
+ next unless advert.protocol == Constants::PROTOCOL_SHIP
185
+ next unless @topics.include?(advert.topic_or_service)
186
+
187
+ domain = normalise_domain(advert.domain)
188
+ next if domain.nil? || domain.empty?
189
+
190
+ results[domain] ||= Set.new
191
+ results[domain] << advert.topic_or_service
192
+ rescue StandardError
193
+ next
194
+ end
195
+
196
+ results
197
+ end
198
+
199
+ def decode_ship_advert(beef_data, output_index)
200
+ beef = parse_beef(beef_data)
201
+ return nil unless beef
202
+
203
+ beef_tx = beef.transactions.last
204
+ return nil unless beef_tx&.transaction
205
+
206
+ tx = beef_tx.transaction
207
+ txout = tx.outputs[output_index]
208
+ return nil unless txout
209
+
210
+ AdminTokenTemplate.decode(txout.locking_script)
211
+ rescue StandardError
212
+ nil
213
+ end
214
+
215
+ def parse_beef(beef_data)
216
+ case beef_data
217
+ when String
218
+ BSV::Transaction::Beef.from_binary(beef_data)
219
+ when Array
220
+ BSV::Transaction::Beef.from_binary(beef_data.pack('C*'))
221
+ end
222
+ rescue StandardError
223
+ nil
224
+ end
225
+
226
+ def normalise_domain(domain)
227
+ url = domain.start_with?('https://', 'http://') ? domain : "https://#{domain}"
228
+ return nil if private_url?(url)
229
+
230
+ url
231
+ end
232
+
233
+ # Reject URLs whose hostname is a private/loopback IP literal (SSRF protection).
234
+ def private_url?(url)
235
+ require 'ipaddr'
236
+ host = URI(url).hostname
237
+ return true if host.nil? || host.empty?
238
+
239
+ addr = IPAddr.new(host)
240
+ addr.loopback? || addr.private? || addr.link_local?
241
+ rescue IPAddr::InvalidAddressError
242
+ false # Not an IP literal — domain names pass through
243
+ end
244
+
245
+ # ---- Parallel dispatch ----
246
+
247
+ def dispatch(beef, host_topics)
248
+ results = {}
249
+ mutex = Mutex.new
250
+
251
+ threads = host_topics.map do |host, host_topic_set|
252
+ Thread.new do
253
+ topics_for_host = @topics.select { |t| host_topic_set.include?(t) }
254
+ tagged = TaggedBEEF.new(beef: beef, topics: topics_for_host)
255
+ steak = @facilitator.send_beef(host, tagged)
256
+ mutex.synchronize { results[host] = steak }
257
+ rescue StandardError
258
+ mutex.synchronize { results[host] = nil }
259
+ end
260
+ end
261
+
262
+ threads.each(&:join)
263
+ results
264
+ end
265
+
266
+ # ---- Acknowledgement tracking ----
267
+
268
+ # Build a map of host → Set of acknowledged topic names.
269
+ #
270
+ # A topic is considered acknowledged if the host's AdmittanceInstructions
271
+ # for that topic has at least one entry in +outputs_to_admit+,
272
+ # +coins_to_retain+, or +coins_removed+.
273
+ def build_host_acks(successful_results)
274
+ successful_results.each_with_object({}) do |(host, steak), acks|
275
+ next unless steak.is_a?(Hash)
276
+
277
+ acked = Set.new
278
+ steak.each do |topic, instructions|
279
+ next unless instructions.is_a?(AdmittanceInstructions)
280
+
281
+ admit = instructions.outputs_to_admit
282
+ retain = instructions.coins_to_retain
283
+ removed = instructions.coins_removed
284
+
285
+ next unless (admit && !admit.empty?) ||
286
+ (retain && !retain.empty?) ||
287
+ (removed && !removed.empty?)
288
+
289
+ acked << topic
290
+ end
291
+ acks[host] = acked
292
+ end
293
+ end
294
+
295
+ # Check all three ack requirement modes and return an error result if
296
+ # any check fails, or nil on success.
297
+ def check_ack_requirements(host_acks)
298
+ if any_host_requirement_active? && !any_host_satisfies?(host_acks, @require_ack_from_any_host)
299
+ return error_result(
300
+ 'ERR_REQUIRE_ACK_FROM_ANY_HOST_FAILED',
301
+ 'No host acknowledged the required topics.'
302
+ )
303
+ end
304
+
305
+ if all_hosts_requirement_active? && !all_hosts_satisfy?(host_acks, @require_ack_from_all_hosts)
306
+ return error_result(
307
+ 'ERR_REQUIRE_ACK_FROM_ALL_HOSTS_FAILED',
308
+ 'Not all hosts acknowledged the required topics.'
309
+ )
310
+ end
311
+
312
+ if @require_ack_from_specific_hosts.any? && !specific_hosts_satisfy?(host_acks)
313
+ return error_result(
314
+ 'ERR_REQUIRE_ACK_FROM_SPECIFIC_HOSTS_FAILED',
315
+ 'Specific hosts did not acknowledge the required topics.'
316
+ )
317
+ end
318
+
319
+ nil
320
+ end
321
+
322
+ def any_host_requirement_active?
323
+ req = @require_ack_from_any_host
324
+ req && req != []
325
+ end
326
+
327
+ def all_hosts_requirement_active?
328
+ req = @require_ack_from_all_hosts
329
+ req && req != [] && !req.empty?
330
+ end
331
+
332
+ # Returns true if at least one host in +host_acks+ satisfies +requirement+.
333
+ def any_host_satisfies?(host_acks, requirement)
334
+ host_acks.any? { |_host, acked| host_meets_requirement?(acked, requirement) }
335
+ end
336
+
337
+ # Returns true if every host in +host_acks+ satisfies +requirement+.
338
+ def all_hosts_satisfy?(host_acks, requirement)
339
+ host_acks.all? { |_host, acked| host_meets_requirement?(acked, requirement) }
340
+ end
341
+
342
+ # Returns true if every named host in +require_ack_from_specific_hosts+
343
+ # responded and met its individual requirement.
344
+ def specific_hosts_satisfy?(host_acks)
345
+ @require_ack_from_specific_hosts.all? do |host, requirement|
346
+ acked = host_acks[host]
347
+ next false unless acked
348
+
349
+ host_meets_requirement?(acked, requirement)
350
+ end
351
+ end
352
+
353
+ # Evaluate whether a host's acknowledged topic set satisfies a requirement.
354
+ #
355
+ # @param acked [Set<String>] topics the host acknowledged
356
+ # @param requirement [String, Array<String>] +'all'+, +'any'+, or topic list
357
+ def host_meets_requirement?(acked, requirement)
358
+ case requirement
359
+ when 'all'
360
+ @topics.all? { |t| acked.include?(t) }
361
+ when 'any'
362
+ @topics.any? { |t| acked.include?(t) }
363
+ when Array
364
+ requirement.all? { |t| acked.include?(t) }
365
+ else
366
+ true
367
+ end
368
+ end
369
+
370
+ # ---- Result helpers ----
371
+
372
+ def error_result(code, description)
373
+ OverlayBroadcastResult.new(status: 'error', code: code, description: description)
374
+ end
375
+
376
+ # ---- Defaults ----
377
+
378
+ def local?
379
+ @network_preset == :local
380
+ end
381
+
382
+ def default_facilitator
383
+ if local?
384
+ HTTPSBroadcastFacilitator.new(allow_http: true)
385
+ else
386
+ HTTPSBroadcastFacilitator.new
387
+ end
388
+ end
389
+
390
+ def default_resolver
391
+ LookupResolver.new(network_preset: @network_preset)
392
+ end
393
+ end
394
+
395
+ # Alias for TopicBroadcaster.
396
+ #
397
+ # SHIPBroadcaster and TopicBroadcaster are the same class; the alias exists
398
+ # for compatibility with the Go and TypeScript SDKs where the class is known
399
+ # by both names.
400
+ SHIPBroadcaster = TopicBroadcaster
401
+ end
402
+ end
@@ -0,0 +1,111 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BSV
4
+ module Overlay
5
+ # Tagged BEEF (Background Evaluation Extended Format) structure.
6
+ #
7
+ # Comprises a transaction, its SPV information, and the overlay topics
8
+ # where its inclusion is requested.
9
+ class TaggedBEEF
10
+ # @return [String] raw binary BEEF-encoded transaction data
11
+ attr_reader :beef
12
+
13
+ # @return [Array<String>] overlay topic names where the transaction is to be submitted
14
+ attr_reader :topics
15
+
16
+ # @param beef [String] raw binary BEEF-encoded transaction data
17
+ # @param topics [Array<String>] overlay topic names
18
+ def initialize(beef:, topics:)
19
+ @beef = beef
20
+ @topics = topics
21
+ end
22
+ end
23
+
24
+ # Instructs the Overlay Services Engine about which outputs to admit and
25
+ # which previous outputs to retain. Returned by a Topic Manager.
26
+ class AdmittanceInstructions
27
+ # @return [Array<Integer>] indices of admissible outputs in the managed topic
28
+ attr_reader :outputs_to_admit
29
+
30
+ # @return [Array<Integer>] indices of inputs spending previously-admitted outputs to retain
31
+ attr_reader :coins_to_retain
32
+
33
+ # @return [Array<Integer>, nil] indices of inputs spending previously-admitted outputs
34
+ # that are now considered spent and removed from the topic (optional)
35
+ attr_reader :coins_removed
36
+
37
+ # @param outputs_to_admit [Array<Integer>]
38
+ # @param coins_to_retain [Array<Integer>]
39
+ # @param coins_removed [Array<Integer>, nil]
40
+ def initialize(outputs_to_admit:, coins_to_retain:, coins_removed: nil)
41
+ @outputs_to_admit = outputs_to_admit
42
+ @coins_to_retain = coins_to_retain
43
+ @coins_removed = coins_removed
44
+ end
45
+ end
46
+
47
+ # The question asked to the Overlay Services Engine when a consumer of state
48
+ # wishes to look up information.
49
+ class LookupQuestion
50
+ # @return [String] identifier of the Lookup Service to query
51
+ attr_reader :service
52
+
53
+ # @return [Hash] query forwarded to the Lookup Service; structure depends on the service
54
+ attr_reader :query
55
+
56
+ # @param service [String]
57
+ # @param query [Hash]
58
+ def initialize(service:, query:)
59
+ @service = service
60
+ @query = query
61
+ end
62
+ end
63
+
64
+ # How the Overlay Services Engine responds to a LookupQuestion.
65
+ class LookupAnswer
66
+ # @return [String] response type (e.g. 'output-list')
67
+ attr_reader :type
68
+
69
+ # @return [Array] outputs or freeform response data from the Lookup Service
70
+ attr_reader :outputs
71
+
72
+ # @param type [String]
73
+ # @param outputs [Array]
74
+ def initialize(type:, outputs:)
75
+ @type = type
76
+ @outputs = outputs
77
+ end
78
+ end
79
+
80
+ # Result of broadcasting a transaction to an Overlay Services host.
81
+ class OverlayBroadcastResult
82
+ # @return [String] result status ('success' or 'error')
83
+ attr_reader :status
84
+
85
+ # @return [String, nil] transaction identifier (present on success)
86
+ attr_reader :txid
87
+
88
+ # @return [String, nil] human-readable result message
89
+ attr_reader :message
90
+
91
+ # @return [String, nil] machine-readable error code
92
+ attr_reader :code
93
+
94
+ # @return [String, nil] human-readable error description
95
+ attr_reader :description
96
+
97
+ # @param status [String]
98
+ # @param txid [String, nil]
99
+ # @param message [String, nil]
100
+ # @param code [String, nil]
101
+ # @param description [String, nil]
102
+ def initialize(status:, txid: nil, message: nil, code: nil, description: nil)
103
+ @status = status
104
+ @txid = txid
105
+ @message = message
106
+ @code = code
107
+ @description = description
108
+ end
109
+ end
110
+ end
111
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BSV
4
+ # Overlay Services module for BSV blockchain.
5
+ #
6
+ # Provides foundation types, protocol constants, and error classes for
7
+ # interacting with BSV Overlay Services via the SHIP and SLAP protocols.
8
+ module Overlay
9
+ autoload :Constants, 'bsv/overlay/constants'
10
+ autoload :TaggedBEEF, 'bsv/overlay/types'
11
+ autoload :AdmittanceInstructions, 'bsv/overlay/types'
12
+ autoload :LookupQuestion, 'bsv/overlay/types'
13
+ autoload :LookupAnswer, 'bsv/overlay/types'
14
+ autoload :OverlayBroadcastResult, 'bsv/overlay/types'
15
+ autoload :OverlayError, 'bsv/overlay/errors'
16
+ autoload :NoCompetentHostsError, 'bsv/overlay/errors'
17
+ autoload :AllHostsRejectedError, 'bsv/overlay/errors'
18
+ autoload :AcknowledgementError, 'bsv/overlay/errors'
19
+ autoload :HostReputationTracker, 'bsv/overlay/host_reputation_tracker'
20
+ autoload :AdminTokenTemplate, 'bsv/overlay/admin_token_template'
21
+ autoload :LookupFacilitator, 'bsv/overlay/lookup_facilitator'
22
+ autoload :HTTPSLookupFacilitator, 'bsv/overlay/lookup_facilitator'
23
+ autoload :LookupResolver, 'bsv/overlay/lookup_resolver'
24
+ autoload :BroadcastFacilitator, 'bsv/overlay/broadcast_facilitator'
25
+ autoload :HTTPSBroadcastFacilitator, 'bsv/overlay/broadcast_facilitator'
26
+ autoload :TopicBroadcaster, 'bsv/overlay/topic_broadcaster'
27
+ autoload :SHIPBroadcaster, 'bsv/overlay/topic_broadcaster'
28
+ end
29
+ end