bsv-sdk 0.12.1 → 0.14.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 (35) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +49 -0
  3. data/lib/bsv/auth/certificate.rb +4 -4
  4. data/lib/bsv/auth/verifiable_certificate.rb +1 -1
  5. data/lib/bsv/network/arc.rb +95 -224
  6. data/lib/bsv/network/protocol.rb +321 -0
  7. data/lib/bsv/network/protocols/arc.rb +351 -0
  8. data/lib/bsv/network/protocols/chaintracks.rb +39 -0
  9. data/lib/bsv/network/protocols/ordinals.rb +32 -0
  10. data/lib/bsv/network/protocols/taal_binary.rb +99 -0
  11. data/lib/bsv/network/protocols/woc_rest.rb +301 -0
  12. data/lib/bsv/network/protocols.rb +17 -0
  13. data/lib/bsv/network/provider.rb +123 -0
  14. data/lib/bsv/network/providers/gorilla_pool.rb +61 -0
  15. data/lib/bsv/network/providers/taal.rb +57 -0
  16. data/lib/bsv/network/providers/whats_on_chain.rb +72 -0
  17. data/lib/bsv/network/providers.rb +25 -0
  18. data/lib/bsv/network/result.rb +119 -0
  19. data/lib/bsv/network/whats_on_chain.rb +78 -40
  20. data/lib/bsv/network.rb +5 -0
  21. data/lib/bsv/overlay/admin_token_template.rb +2 -2
  22. data/lib/bsv/script/push_drop_template.rb +1 -1
  23. data/lib/bsv/transaction/chain_trackers/chaintracks.rb +45 -49
  24. data/lib/bsv/transaction/chain_trackers/whats_on_chain.rb +57 -50
  25. data/lib/bsv/transaction/chain_trackers.rb +3 -4
  26. data/lib/bsv/transaction/fee_models/live_policy.rb +3 -2
  27. data/lib/bsv/transaction/transaction.rb +52 -7
  28. data/lib/bsv/transaction/verification_error.rb +11 -0
  29. data/lib/bsv/version.rb +1 -1
  30. data/lib/bsv-sdk.rb +1 -5
  31. metadata +14 -5
  32. data/lib/bsv/messages.rb +0 -16
  33. data/lib/bsv/wallet/insufficient_funds_error.rb +0 -15
  34. data/lib/bsv/wallet/wallet.rb +0 -120
  35. data/lib/bsv/wallet.rb +0 -8
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+
5
+ module BSV
6
+ module Network
7
+ module Protocols
8
+ # Chaintracks implements the GorillaPool Chaintracks API as a Protocol subclass.
9
+ #
10
+ # Provides block header lookup and current chain tip height via the
11
+ # GorillaPool Chaintracks v2 REST API. Pure DSL — no escape hatches needed.
12
+ #
13
+ # Chaintracks does not use a +{network}+ placeholder in the URL. Mainnet
14
+ # and testnet are served from separate base URLs; the provider defaults
15
+ # supply the correct URL for each network.
16
+ #
17
+ # == Usage
18
+ #
19
+ # ct = BSV::Network::Protocols::Chaintracks.new(base_url: 'https://arcade.gorillapool.io')
20
+ # result = ct.call(:current_height)
21
+ # result.data # => 800000
22
+ #
23
+ # result = ct.call(:get_block_header, 800_000)
24
+ # result.data # => { 'hash' => '...', 'height' => 800000, 'merkleRoot' => '...' }
25
+ class Chaintracks < Protocol
26
+ endpoint :get_block_header, :get, '/chaintracks/v2/header/height/{height}', response: :json
27
+ endpoint :current_height, :get, '/chaintracks/v2/tip',
28
+ response: ->(body) { JSON.parse(body)['height'] }
29
+
30
+ # @param base_url [String] base URL for the Chaintracks API
31
+ # @param api_key [String, nil] optional Bearer API key
32
+ # @param http_client [Object, nil] injectable HTTP client for testing
33
+ def initialize(base_url:, api_key: nil, http_client: nil)
34
+ super
35
+ end
36
+ end
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BSV
4
+ module Network
5
+ module Protocols
6
+ # Ordinals implements the GorillaPool Ordinals API as a Protocol subclass.
7
+ #
8
+ # Provides raw transaction hex lookup and Merkle path (proof) retrieval
9
+ # via the GorillaPool Ordinals REST API. Pure DSL — no escape hatches needed.
10
+ #
11
+ # == Usage
12
+ #
13
+ # ord = BSV::Network::Protocols::Ordinals.new(base_url: 'https://ordinals.gorillapool.io')
14
+ # result = ord.call(:get_tx, 'abc123...')
15
+ # result.data # => "01000000..." (raw hex string)
16
+ #
17
+ # result = ord.call(:get_merkle_path, 'abc123...')
18
+ # result.data # => { 'index' => 0, 'path' => [...] }
19
+ class Ordinals < Protocol
20
+ endpoint :get_tx, :get, '/api/tx/{txid}/hex'
21
+ endpoint :get_merkle_path, :get, '/api/tx/{txid}/proof', response: :json
22
+
23
+ # @param base_url [String] base URL for the Ordinals API
24
+ # @param api_key [String, nil] optional Bearer API key
25
+ # @param http_client [Object, nil] injectable HTTP client for testing
26
+ def initialize(base_url:, api_key: nil, http_client: nil)
27
+ super
28
+ end
29
+ end
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,99 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BSV
4
+ module Network
5
+ module Protocols
6
+ # TAALBinary implements the TAAL broadcast API using raw binary transaction
7
+ # submission over HTTP.
8
+ #
9
+ # TAAL quirks handled here:
10
+ # - Content-Type is +application/octet-stream+ (not JSON)
11
+ # - Authorization header uses the API key directly with no "Bearer" prefix
12
+ # - A response containing +txn-already-known+ in the error field is treated
13
+ # as success (the transaction is already in the mempool — idempotent)
14
+ #
15
+ # == Example
16
+ #
17
+ # protocol = BSV::Network::Protocols::TAALBinary.new(
18
+ # base_url: 'https://api.taal.com',
19
+ # api_key: 'mainnet_your_key_here'
20
+ # )
21
+ # result = protocol.call(:broadcast, tx)
22
+ # puts result.data[:txid] if result.success?
23
+ class TAALBinary < BSV::Network::Protocol
24
+ endpoint :broadcast, :post, '/api/v1/broadcast', response: :json
25
+
26
+ private
27
+
28
+ # Escape hatch for broadcast: sends raw binary transaction bytes with
29
+ # TAAL-specific headers and applies TAAL-specific response quirk handling.
30
+ #
31
+ # @param tx [#to_binary, String] transaction object or raw binary string
32
+ # @return [Result::Success, Result::Error]
33
+ def call_broadcast(tx)
34
+ body = tx.respond_to?(:to_binary) ? tx.to_binary : tx
35
+
36
+ uri = URI("#{@base_url}/api/v1/broadcast")
37
+ request = Net::HTTP::Post.new(uri)
38
+ request['Content-Type'] = 'application/octet-stream'
39
+ request['Authorization'] = @api_key if @api_key
40
+
41
+ request.body = body
42
+
43
+ response = execute(uri, request)
44
+ parse_broadcast_response(response)
45
+ end
46
+
47
+ # Maps the HTTP response from TAAL broadcast to a Result, applying the
48
+ # +txn-already-known+ idempotency quirk.
49
+ #
50
+ # @param response [Net::HTTPResponse]
51
+ # @return [Result::Success, Result::Error]
52
+ def parse_broadcast_response(response)
53
+ code = response.code.to_i
54
+ body = parse_json_body(response.body)
55
+
56
+ return Result::Success.new(data: { txid: body['txid'] }) if already_known?(body) && body['txid']
57
+
58
+ retryable = code == 429 || (500..599).cover?(code)
59
+
60
+ if (200..299).cover?(code)
61
+ return Result::Error.new(message: 'TAAL returned a malformed 2xx response', retryable: false) unless body['txid']
62
+
63
+ Result::Success.new(data: { txid: body['txid'] })
64
+ else
65
+ message = (body.is_a?(Hash) && body['error']) || "HTTP #{code}"
66
+ Result::Error.new(message: message, retryable: retryable)
67
+ end
68
+ end
69
+
70
+ # Returns true when the response body indicates the transaction is already
71
+ # known to the network — TAAL treats this as a non-error.
72
+ #
73
+ # @param body [Hash, nil]
74
+ # @return [Boolean]
75
+ def already_known?(body)
76
+ body.is_a?(Hash) &&
77
+ body['error'].is_a?(String) &&
78
+ body['error'].include?('txn-already-known')
79
+ end
80
+
81
+ # Parses a JSON response body, returning an empty Hash on failure or nil input.
82
+ #
83
+ # Always returns a Hash so callers can safely index the result without
84
+ # a nil-guard.
85
+ #
86
+ # @param raw [String, nil]
87
+ # @return [Hash]
88
+ def parse_json_body(raw)
89
+ return {} if raw.nil? || raw.empty?
90
+
91
+ parsed = JSON.parse(raw)
92
+ parsed.is_a?(Hash) ? parsed : {}
93
+ rescue JSON::ParserError
94
+ {}
95
+ end
96
+ end
97
+ end
98
+ end
99
+ end
@@ -0,0 +1,301 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+
5
+ module BSV
6
+ module Network
7
+ module Protocols
8
+ # WoCREST implements the WhatsOnChain REST API as a Protocol subclass.
9
+ #
10
+ # Provides 30 endpoints covering chain info, block headers, transactions,
11
+ # UTXOs, scripts, address queries, broadcast, and health. Seven escape hatches
12
+ # handle WoC-specific body formats and field remapping.
13
+ #
14
+ # == Network resolution
15
+ #
16
+ # WoC uses +main+ and +test+ in its URL paths. The constructor accepts
17
+ # symbolic aliases (:mainnet, :testnet, :stn) and resolves them to the
18
+ # correct string.
19
+ #
20
+ # == Usage
21
+ #
22
+ # woc = BSV::Network::Protocols::WoCREST.new(network: :main)
23
+ # result = woc.call(:get_tx, 'abc123...')
24
+ # puts result.data if result.success?
25
+ class WoCREST < Protocol
26
+ NETWORKS = {
27
+ 'main' => 'main',
28
+ 'test' => 'test',
29
+ 'stn' => 'stn',
30
+ main: 'main',
31
+ test: 'test',
32
+ stn: 'stn',
33
+ mainnet: 'main',
34
+ testnet: 'test'
35
+ }.freeze
36
+
37
+ # Chain
38
+ endpoint :current_height, :get, '/chain/info',
39
+ response: ->(body) { JSON.parse(body)['blocks'] }
40
+ endpoint :get_chain_info, :get, '/chain/info', response: :json
41
+ endpoint :get_block_header, :get, '/block/{height}/header', response: :json
42
+ endpoint :get_block_headers, :get, '/block/headers', response: :json_array
43
+
44
+ # Transaction
45
+ endpoint :get_tx, :get, '/tx/{txid}/hex'
46
+ endpoint :get_tx_details, :get, '/tx/hash/{txid}', response: :json
47
+ endpoint :get_output_script, :get, '/tx/{txid}/out/{index}/hex'
48
+ endpoint :get_opreturn, :get, '/tx/{txid}/opreturn', response: :json
49
+ endpoint :get_merkle_path, :get, '/tx/{txid}/proof/tsc', response: :json
50
+ endpoint :broadcast, :post, '/tx/raw'
51
+ endpoint :decode_tx, :post, '/tx/decode', response: :json
52
+ endpoint :get_tx_status, :post, '/txs/status', response: :json
53
+ endpoint :get_tx_hex_bulk, :post, '/txs/hex', response: :json
54
+
55
+ # UTXO / spent status
56
+ endpoint :get_utxos, :get, '/address/{address}/confirmed/unspent',
57
+ response: :json_array
58
+ endpoint :get_utxos_all, :get, '/address/{address}/unspent',
59
+ response: :json_array
60
+ endpoint :is_utxo, :get, '/tx/{txid}/{vout}/spent', response: :json
61
+ endpoint :is_utxo_bulk, :post, '/utxos/spent', response: :json_array
62
+ endpoint :valid_root, :get, '/block/{height}/header', response: :json
63
+
64
+ # Script
65
+ endpoint :get_script_unspent, :get, '/script/{script_hash}/confirmed/unspent',
66
+ response: :json_array
67
+ endpoint :get_script_history, :get, '/script/{script_hash}/confirmed/history',
68
+ response: :json_array
69
+ endpoint :get_script_all_unspent, :get, '/script/{script_hash}/unspent/all',
70
+ response: :json_array
71
+ endpoint :get_script_unspent_bulk, :post, '/scripts/confirmed/unspent', response: :json
72
+
73
+ # Address balance / history
74
+ endpoint :get_balance, :get, '/address/{address}/confirmed/balance',
75
+ response: :json
76
+ endpoint :get_unconfirmed_balance, :get, '/address/{address}/unconfirmed/balance',
77
+ response: :json
78
+ endpoint :get_history, :get, '/address/{address}/confirmed/history',
79
+ response: :json_array
80
+ endpoint :is_address_used, :get, '/address/{address}/used', response: :json
81
+
82
+ # Exchange rate / fees / mempool
83
+ endpoint :get_exchange_rate, :get, '/exchangerate', response: :json
84
+ endpoint :get_fee_recommendation, :get, '/feerecommendation', response: :json
85
+ endpoint :get_mempool_info, :get, '/mempool/info', response: :json
86
+
87
+ # Health
88
+ endpoint :health, :get, '/health'
89
+
90
+ attr_reader :network_name
91
+
92
+ # @param base_url [String] base URL for the WoC API; may contain
93
+ # +{network}+ which will be interpolated with the resolved network name
94
+ # @param network [Symbol, String] :main, :mainnet, :test, :testnet, :stn
95
+ # @param api_key [String, nil] optional Bearer API key
96
+ # @param http_client [Object, nil] injectable HTTP client for testing
97
+ def initialize(base_url:, network: :main, api_key: nil, http_client: nil)
98
+ @network_name = resolve_network(network)
99
+ super(
100
+ base_url: base_url,
101
+ api_key: api_key,
102
+ network: @network_name,
103
+ http_client: http_client
104
+ )
105
+ end
106
+
107
+ # WoC expects a raw Authorization header (no Bearer prefix).
108
+ # Override the base class which adds "Bearer ".
109
+ def build_request(http_method, uri, body)
110
+ request = super
111
+ request['Authorization'] = @api_key if @api_key
112
+ request
113
+ end
114
+
115
+ private
116
+
117
+ # Resolves network aliases to the WoC URL segment string.
118
+ #
119
+ # @param network [Symbol, String]
120
+ # @return [String] 'main', 'test', or 'stn'
121
+ # @raise [ArgumentError] when the network value is not recognised
122
+ def resolve_network(network)
123
+ NETWORKS.fetch(network) do
124
+ raise ArgumentError, "unknown network: #{network.inspect}"
125
+ end
126
+ end
127
+
128
+ # Fetches confirmed UTXOs for an address and remaps the +value+ field
129
+ # to +satoshis+ to match the SDK's UTXO convention.
130
+ #
131
+ # WoC returns entries with +{ tx_hash, tx_pos, value, height }+;
132
+ # callers and facades expect +satoshis+ in place of +value+.
133
+ #
134
+ # @param address [String] BSV address
135
+ # @return [Result::Success, Result::Error, Result::NotFound]
136
+ def call_get_utxos(address)
137
+ result = default_call(:get_utxos, address)
138
+ return result unless result.success?
139
+
140
+ Result::Success.new(data: remap_utxo_entries(result.data))
141
+ end
142
+
143
+ # Fetches all UTXOs (confirmed and unconfirmed) for an address and
144
+ # remaps the +value+ field to +satoshis+. Uses the legacy +/unspent+
145
+ # endpoint rather than +/confirmed/unspent+.
146
+ #
147
+ # @param address [String] BSV address
148
+ # @return [Result::Success, Result::Error, Result::NotFound]
149
+ def call_get_utxos_all(address)
150
+ result = default_call(:get_utxos_all, address)
151
+ return result unless result.success?
152
+
153
+ Result::Success.new(data: remap_utxo_entries(result.data))
154
+ end
155
+
156
+ # Remaps WoC UTXO entries from +{ 'value' => n }+ to +{ satoshis: n }+.
157
+ #
158
+ # @param entries [Array<Hash>]
159
+ # @return [Array<Hash>]
160
+ def remap_utxo_entries(entries)
161
+ entries.map do |entry|
162
+ {
163
+ tx_hash: entry['tx_hash'],
164
+ tx_pos: entry['tx_pos'],
165
+ satoshis: entry['value'],
166
+ height: entry['height']
167
+ }
168
+ end
169
+ end
170
+
171
+ # Checks whether a specific output is unspent by querying the WoC
172
+ # spent-status endpoint.
173
+ #
174
+ # WoC returns a JSON object indicating whether the output has been spent.
175
+ # The +script_hash:+ keyword is accepted for future fallback support
176
+ # but not used in this implementation.
177
+ #
178
+ # @param txid [String] transaction ID
179
+ # @param vout [Integer] output index
180
+ # @param script_hash [String, nil] ignored
181
+ # @return [Result::Success<Boolean>, Result::Error, Result::NotFound]
182
+ def call_is_utxo(txid, vout, script_hash: nil) # rubocop:disable Lint/UnusedMethodArgument
183
+ result = default_call(:is_utxo, txid, vout)
184
+ return result unless result.success?
185
+
186
+ # WoC returns { "spent": true/false, ... } — unspent means NOT spent
187
+ unless result.data.is_a?(Hash) && result.data.key?('spent')
188
+ return Result::Error.new(message: 'missing spent field in response', retryable: false)
189
+ end
190
+
191
+ spent = result.data['spent']
192
+ Result::Success.new(data: !spent)
193
+ end
194
+
195
+ # Bulk-checks whether a set of outputs are unspent.
196
+ #
197
+ # WoC expects a JSON array of +{ txid, vout }+ objects. It returns an
198
+ # array of entries, each containing +txid+, +vout+, and +spent+ fields.
199
+ # Entries absent from the response (unknown outputs) are treated as spent.
200
+ #
201
+ # @param outpoints [Array<Hash>] array of +{ txid:, vout: }+ hashes
202
+ # @return [Result::Success<Hash{String => Boolean}>, Result::Error]
203
+ # On success, data is a hash mapping +"txid.vout"+ keys to booleans
204
+ # (+true+ = unspent, +false+ = spent).
205
+ def call_is_utxo_bulk(outpoints)
206
+ return Result::Success.new(data: {}) if outpoints.empty?
207
+
208
+ body = JSON.generate(outpoints.map { |op| { 'txid' => op[:txid].to_s, 'vout' => op[:vout].to_i } })
209
+ result = default_call(:is_utxo_bulk, body: body)
210
+ return result unless result.success?
211
+
212
+ # Build a lookup from the response — unknown outpoints default to spent
213
+ spent_map = {}
214
+ result.data.each do |entry|
215
+ next unless entry.is_a?(Hash) && entry.key?('txid') && entry.key?('vout')
216
+
217
+ key = "#{entry['txid']}.#{entry['vout']}"
218
+ spent_map[key] = entry['spent']
219
+ end
220
+
221
+ normalised = outpoints.each_with_object({}) do |op, h|
222
+ key = "#{op[:txid]}.#{op[:vout]}"
223
+ h[key] = spent_map.key?(key) ? !spent_map[key] : false
224
+ end
225
+
226
+ Result::Success.new(data: normalised)
227
+ end
228
+
229
+ # Broadcasts a raw transaction to WhatsOnChain.
230
+ #
231
+ # WoC expects the body as +{ txhex: "..." }+ (JSON). On success it
232
+ # returns the txid as a plain-text string (not JSON). The response is
233
+ # stripped and wrapped in a Hash for caller convenience.
234
+ #
235
+ # @param tx [#to_hex, String] transaction object or raw hex string
236
+ # @return [Result::Success<{ txid: String }>, Result::Error]
237
+ def call_broadcast(tx)
238
+ hex = tx.respond_to?(:to_hex) ? tx.to_hex : tx.to_s
239
+ body = JSON.generate(txhex: hex)
240
+
241
+ result = default_call(:broadcast, body: body)
242
+ return result unless result.success?
243
+
244
+ # WoC returns plain-text txid — result.data is the raw body string
245
+ Result::Success.new(data: { txid: result.data.to_s.strip })
246
+ end
247
+
248
+ # Decodes a raw transaction hex by posting to the WoC decode endpoint.
249
+ #
250
+ # WoC expects the body as +{ txhex: "..." }+ (JSON) and returns a
251
+ # full decoded transaction object.
252
+ #
253
+ # @param txhex [String] raw transaction hex string
254
+ # @return [Result::Success<Hash>, Result::Error]
255
+ def call_decode_tx(txhex)
256
+ body = JSON.generate(txhex: txhex.to_s)
257
+ default_call(:decode_tx, body: body)
258
+ end
259
+
260
+ # Fetches raw hex for multiple transactions in a single request.
261
+ #
262
+ # WoC expects a bare JSON array of txid strings as the request body.
263
+ #
264
+ # @param txids [Array<String>] list of transaction IDs
265
+ # @return [Result::Success<Array>, Result::Error]
266
+ def call_get_tx_hex_bulk(txids)
267
+ body = JSON.generate(txids)
268
+ default_call(:get_tx_hex_bulk, body: body)
269
+ end
270
+
271
+ # Fetches confirmed UTXOs for multiple script hashes in a single request.
272
+ #
273
+ # WoC expects a bare JSON array of script hash strings as the request body.
274
+ #
275
+ # @param script_hashes [Array<String>] list of script hashes
276
+ # @return [Result::Success<Hash>, Result::Error]
277
+ def call_get_script_unspent_bulk(script_hashes)
278
+ body = JSON.generate(script_hashes)
279
+ default_call(:get_script_unspent_bulk, body: body)
280
+ end
281
+
282
+ # Verifies that a merkle root matches the one recorded for a given
283
+ # block height. Uses the get_block_header endpoint internally.
284
+ #
285
+ # Comparison is case-insensitive to tolerate mixed-case hex from
286
+ # different providers.
287
+ #
288
+ # @param root [String] expected merkle root as hex
289
+ # @param height [Integer] block height
290
+ # @return [Result::Success<Boolean>, Result::Error, Result::NotFound]
291
+ def call_valid_root(root, height)
292
+ result = default_call(:valid_root, height)
293
+ return result unless result.success?
294
+
295
+ actual = result.data['merkleroot']
296
+ Result::Success.new(data: actual.to_s.downcase == root.to_s.downcase)
297
+ end
298
+ end
299
+ end
300
+ end
301
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BSV
4
+ module Network
5
+ # Protocols is a namespace module for concrete Protocol subclasses.
6
+ #
7
+ # Protocol implementations (e.g. ARC, WhatsOnChain adapters built on
8
+ # the Protocol base class) will be autoloaded here in Phase B.
9
+ module Protocols
10
+ autoload :ARC, 'bsv/network/protocols/arc'
11
+ autoload :Chaintracks, 'bsv/network/protocols/chaintracks'
12
+ autoload :Ordinals, 'bsv/network/protocols/ordinals'
13
+ autoload :TAALBinary, 'bsv/network/protocols/taal_binary'
14
+ autoload :WoCREST, 'bsv/network/protocols/woc_rest'
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,123 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'set'
4
+
5
+ module BSV
6
+ module Network
7
+ # Provider is a named configuration container that hosts one or more Protocol
8
+ # instances and dispatches commands to the appropriate protocol.
9
+ #
10
+ # Protocols are registered via a block DSL or by calling +#protocol+ directly
11
+ # after construction. For each command symbol, the first-registered protocol
12
+ # that serves it wins (first-registered-wins, no warning on duplicates).
13
+ #
14
+ # == Example
15
+ #
16
+ # gorillapool = BSV::Network::Provider.new('GorillaPool') do |p|
17
+ # p.protocol Protocols::ARC, base_url: 'https://arcade.gorillapool.io'
18
+ # p.protocol Protocols::Chaintracks, base_url: 'https://arcade.gorillapool.io'
19
+ # p.protocol Protocols::Ordinals, base_url: 'https://ordinals.gorillapool.io'
20
+ # end
21
+ #
22
+ # result = gorillapool.call(:broadcast, tx)
23
+ # result.success? # => true
24
+ class Provider
25
+ attr_reader :name
26
+
27
+ # @param name [String] human-readable provider name (e.g. 'GorillaPool')
28
+ # @param block [Proc] optional configuration block — yields +self+
29
+ def initialize(name, &block)
30
+ @name = name
31
+ @protocols = []
32
+ @command_index = {}
33
+ block&.call(self)
34
+ end
35
+
36
+ # Registers a protocol class with the provider.
37
+ #
38
+ # The class is instantiated with the supplied +kwargs+. Its commands are
39
+ # indexed: each command not yet in the index is mapped to this instance.
40
+ # Commands already in the index are left unchanged (first-registered wins).
41
+ #
42
+ # The provider remains mutable — +protocol+ may be called after block
43
+ # execution.
44
+ #
45
+ # @param klass [Class] a Protocol subclass
46
+ # @param kwargs [Hash] keyword arguments forwarded to +klass.new+
47
+ # @return [Protocol] the newly created protocol instance
48
+ def protocol(klass, **kwargs)
49
+ instance = klass.new(**kwargs)
50
+ @protocols << instance
51
+ klass.commands.each do |cmd|
52
+ @command_index[cmd] ||= instance
53
+ end
54
+ instance
55
+ end
56
+
57
+ # Returns a frozen copy of the registered protocol instances, in
58
+ # registration order.
59
+ #
60
+ # @return [Array<Protocol>]
61
+ def protocols
62
+ @protocols.dup.freeze
63
+ end
64
+
65
+ # Returns the set of all command symbols available on this provider.
66
+ #
67
+ # @return [Set<Symbol>]
68
+ def commands
69
+ Set.new(@command_index.keys)
70
+ end
71
+
72
+ # Returns the protocol instance that serves a given command, or nil if no
73
+ # registered protocol handles it.
74
+ #
75
+ # @param command_name [Symbol, String]
76
+ # @return [Protocol, nil]
77
+ def protocol_for(command_name)
78
+ @command_index[command_name.to_sym]
79
+ end
80
+
81
+ # Returns a hash mapping each protocol instance to the sorted list of
82
+ # commands it actually serves within this provider (respecting
83
+ # first-registered-wins — a protocol that lost a command to an earlier
84
+ # registration is not listed for that command).
85
+ #
86
+ # Protocols that serve no commands in this provider are omitted.
87
+ #
88
+ # @return [Hash{Protocol => Array<Symbol>}]
89
+ def capability_matrix
90
+ matrix = {}
91
+ @protocols.each do |proto|
92
+ served = proto.class.commands.select { |cmd| @command_index[cmd] == proto }
93
+ matrix[proto] = served.sort unless served.empty?
94
+ end
95
+ matrix
96
+ end
97
+
98
+ # Returns a human-readable representation of the provider.
99
+ #
100
+ # @return [String]
101
+ def to_s
102
+ protocol_summary = @protocols.map { |p| p.class.name&.split('::')&.last || p.class.to_s }.join(', ')
103
+ "#<#{self.class} name=#{@name.inspect} protocols=[#{protocol_summary}]>"
104
+ end
105
+ alias inspect to_s
106
+
107
+ # Dispatches a command to the first-registered protocol that serves it.
108
+ #
109
+ # @param command_name [Symbol, String] command to invoke
110
+ # @param args [Array] positional arguments forwarded to the protocol
111
+ # @param kwargs [Hash] keyword arguments forwarded to the protocol
112
+ # @return [Result::Success, Result::Error, Result::NotFound]
113
+ # @raise [ArgumentError] when no registered protocol serves the command
114
+ def call(command_name, *args, **kwargs)
115
+ sym = command_name.to_sym
116
+ instance = @command_index[sym]
117
+ raise ArgumentError, "#{@name} does not provide command :#{sym}" unless instance
118
+
119
+ instance.call(sym, *args, **kwargs)
120
+ end
121
+ end
122
+ end
123
+ end
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BSV
4
+ module Network
5
+ module Providers
6
+ # GorillaPool returns pre-configured Provider instances using the GorillaPool
7
+ # ARCADE infrastructure for ARC and Chaintracks, and the GorillaPool Ordinals
8
+ # API for transaction and merkle path lookups.
9
+ #
10
+ # Mainnet composes three protocols:
11
+ # - ARC at +https://arcade.gorillapool.io+
12
+ # - Chaintracks at +https://arcade.gorillapool.io+
13
+ # - Ordinals at +https://ordinals.gorillapool.io+
14
+ #
15
+ # Testnet provides ARC only at +https://testnet.arcade.gorillapool.io+.
16
+ #
17
+ # == Example
18
+ #
19
+ # provider = BSV::Network::Providers::GorillaPool.mainnet
20
+ # provider.call(:broadcast, tx)
21
+ #
22
+ # provider = BSV::Network::Providers::GorillaPool.testnet(api_key: 'my-key')
23
+ # provider.call(:broadcast, tx)
24
+ class GorillaPool
25
+ # Returns a mainnet Provider configured with ARC, Chaintracks, and Ordinals.
26
+ #
27
+ # @param opts [Hash] keyword arguments forwarded to each protocol constructor
28
+ # @return [Provider]
29
+ def self.mainnet(**opts)
30
+ common = opts.slice(:api_key, :http_client)
31
+ Provider.new('GorillaPool') do |p|
32
+ p.protocol Protocols::ARC, base_url: 'https://arcade.gorillapool.io', **opts
33
+ p.protocol Protocols::Chaintracks, base_url: 'https://arcade.gorillapool.io', **common
34
+ p.protocol Protocols::Ordinals, base_url: 'https://ordinals.gorillapool.io', **common
35
+ end
36
+ end
37
+
38
+ # Returns a testnet Provider configured with ARC and Chaintracks.
39
+ #
40
+ # @param opts [Hash] keyword arguments forwarded to each protocol constructor
41
+ # @return [Provider]
42
+ def self.testnet(**opts)
43
+ common = opts.slice(:api_key, :http_client)
44
+ Provider.new('GorillaPool') do |p|
45
+ p.protocol Protocols::ARC, base_url: 'https://testnet.arcade.gorillapool.io', **opts
46
+ p.protocol Protocols::Chaintracks, base_url: 'https://testnet.arcade.gorillapool.io', **common
47
+ end
48
+ end
49
+
50
+ # Returns a mainnet or testnet Provider depending on the +testnet:+ flag.
51
+ #
52
+ # @param testnet [Boolean] when true, returns the testnet Provider
53
+ # @param opts [Hash] keyword arguments forwarded to each protocol constructor
54
+ # @return [Provider]
55
+ def self.default(testnet: false, **opts)
56
+ testnet ? testnet(**opts) : mainnet(**opts)
57
+ end
58
+ end
59
+ end
60
+ end
61
+ end