bsv-sdk 0.17.0 → 0.18.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.
@@ -1,31 +1,133 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'json'
4
+
3
5
  module BSV
4
6
  module Network
5
7
  module Protocols
6
8
  # Ordinals implements the GorillaPool Ordinals API as a Protocol subclass.
7
9
  #
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
+ # Provides raw transaction lookup, Merkle path (proof) retrieval, UTXO
11
+ # queries, balance lookups, spend status, and chain tip via the GorillaPool
12
+ # Ordinals REST API.
13
+ #
14
+ # == get_tx vs get_tx_details
15
+ #
16
+ # +get_tx+ returns the raw transaction as a hex string (escape hatch
17
+ # converts binary response body to hex). +get_tx_details+ returns the full
18
+ # parsed transaction metadata as a JSON object.
19
+ #
20
+ # == get_balance
21
+ #
22
+ # The API returns the balance as a quoted integer string (e.g. +"1000000"+),
23
+ # not a bare JSON number. The lambda handler parses this to an Integer.
24
+ #
25
+ # == get_spend
26
+ #
27
+ # The API returns an empty quoted string +""+ for unspent outputs and a
28
+ # quoted txid string for spent outputs. The escape hatch normalises these
29
+ # to +{ spent: false }+ or +{ spent: true, spending_txid: "..." }+.
30
+ # The outpoint must be passed as +"txid_vout"+ (underscore-separated).
10
31
  #
11
32
  # == Usage
12
33
  #
13
34
  # ord = BSV::Network::Protocols::Ordinals.new(base_url: 'https://ordinals.gorillapool.io')
14
35
  # result = ord.call(:get_tx, 'abc123...')
15
- # result.data # => "01000000..." (raw hex string)
36
+ # result.data # => "01000000..." (hex string)
16
37
  #
17
38
  # result = ord.call(:get_merkle_path, 'abc123...')
18
- # result.data # => { 'index' => 0, 'path' => [...] }
39
+ # result.data # => binary TSC proof bytes
40
+ #
41
+ # result = ord.call(:get_spend, 'txid_0')
42
+ # result.data # => { spent: false } or { spent: true, spending_txid: "..." }
43
+ #
44
+ # @see https://ordinals.gorillapool.io/api/docs/ Ordinals API documentation
19
45
  class Ordinals < Protocol
20
- endpoint :get_tx, :get, '/api/tx/{txid}/hex'
21
- endpoint :get_merkle_path, :get, '/api/tx/{txid}/proof', response: :json
46
+ # Transaction — raw binary body; escape hatch converts to hex
47
+ endpoint :get_tx, :get, '/api/tx/{txid}/raw'
48
+
49
+ # Transaction — full metadata as JSON
50
+ endpoint :get_tx_details, :get, '/api/tx/{txid}', response: :json
51
+
52
+ # Transaction — confirmation status
53
+ endpoint :get_tx_status, :get, '/api/tx/{txid}/status', response: :json
54
+
55
+ # Merkle path — binary TSC proof body; no JSON parsing
56
+ endpoint :get_merkle_path, :get, '/api/tx/{txid}/proof'
57
+
58
+ # UTXOs for an address — JSON array
59
+ endpoint :get_utxos, :get, '/api/txos/address/{address}/unspent', response: :json_array
60
+
61
+ # Balance for an address — quoted integer string; lambda parses to Integer
62
+ endpoint :get_balance, :get, '/api/txos/address/{address}/balance',
63
+ response: ->(body) { JSON.parse(body).to_i }
64
+
65
+ # Spend status for an outpoint (format: "txid_vout") — escape hatch normalises
66
+ endpoint :get_spend, :get, '/api/spends/{outpoint}'
67
+
68
+ # Chain tip — latest block metadata as JSON
69
+ endpoint :get_chain_tip, :get, '/api/blocks/tip', response: :json
22
70
 
23
71
  # @param base_url [String] base URL for the Ordinals API
24
- # @param api_key [String, nil] optional Bearer API key
72
+ # @param api_key [String, nil] legacy Bearer API key shorthand — use +auth:+ for new code
73
+ # @param auth [Hash, Symbol, nil] auth config; takes precedence over +api_key:+
25
74
  # @param http_client [Object, nil] injectable HTTP client for testing
26
- def initialize(base_url:, api_key: nil, http_client: nil)
75
+ def initialize(base_url:, api_key: nil, auth: nil, http_client: nil)
27
76
  super
28
77
  end
78
+
79
+ private
80
+
81
+ # Fetches the raw transaction and converts the binary response body to hex.
82
+ #
83
+ # The Ordinals API serves +/api/tx/{txid}/raw+ as binary data, not hex text.
84
+ # This escape hatch unpacks the binary body to a lowercase hex string,
85
+ # matching the convention used by other protocol +get_tx+ implementations.
86
+ #
87
+ # Accepts +txid+ as a positional argument or as a +txid:+ keyword.
88
+ #
89
+ # @param pos_txid [String, nil] transaction ID (positional form)
90
+ # @param txid [String, nil] transaction ID (keyword form)
91
+ # @return [Result::Success<String>, Result::Error, Result::NotFound]
92
+ def call_get_tx(pos_txid = nil, txid: nil)
93
+ resolved = pos_txid || txid
94
+ raise ArgumentError, 'txid is required' if resolved.nil? || resolved.empty?
95
+
96
+ result = default_call(:get_tx, resolved)
97
+ return result unless result.success?
98
+ return Result::Error.new(message: 'empty response body') if result.data.nil? || result.data.empty?
99
+
100
+ Result::Success.new(data: result.data.unpack1('H*'))
101
+ end
102
+
103
+ # Normalises the spend status response from the Ordinals API.
104
+ #
105
+ # The API returns a quoted empty string +""+ when the output is unspent,
106
+ # and a quoted txid string +"abc123..."+ when it is spent. This escape
107
+ # hatch parses the JSON string body and returns a structured hash.
108
+ #
109
+ # The +outpoint+ argument must use underscore-separated format: +"txid_vout"+.
110
+ # Accepts +outpoint+ as a positional argument or as an +outpoint:+ keyword.
111
+ #
112
+ # @param pos_outpoint [String, nil] outpoint in +"txid_vout"+ format (positional form)
113
+ # @param outpoint [String, nil] outpoint in +"txid_vout"+ format (keyword form)
114
+ # @return [Result::Success<Hash>, Result::Error, Result::NotFound]
115
+ def call_get_spend(pos_outpoint = nil, outpoint: nil)
116
+ resolved = pos_outpoint || outpoint
117
+ raise ArgumentError, 'outpoint is required' if resolved.nil? || resolved.empty?
118
+
119
+ result = default_call(:get_spend, resolved)
120
+ return result unless result.success?
121
+
122
+ spending_txid = JSON.parse(result.data).to_s.strip
123
+ if spending_txid.empty?
124
+ Result::Success.new(data: { spent: false })
125
+ else
126
+ Result::Success.new(data: { spent: true, spending_txid: spending_txid })
127
+ end
128
+ rescue JSON::ParserError => e
129
+ Result::Error.new(message: "spend response parse error: #{e.message}")
130
+ end
29
131
  end
30
132
  end
31
133
  end
@@ -8,7 +8,7 @@ module BSV
8
8
  #
9
9
  # TAAL quirks handled here:
10
10
  # - Content-Type is +application/octet-stream+ (not JSON)
11
- # - Authorization header uses the API key directly with no "Bearer" prefix
11
+ # - Authorization header is applied via the standard +apply_auth+ mechanism
12
12
  # - A response containing +txn-already-known+ in the error field is treated
13
13
  # as success (the transaction is already in the mempool — idempotent)
14
14
  #
@@ -16,13 +16,26 @@ module BSV
16
16
  #
17
17
  # protocol = BSV::Network::Protocols::TAALBinary.new(
18
18
  # base_url: 'https://api.taal.com',
19
- # api_key: 'mainnet_your_key_here'
19
+ # auth: { api_key: 'mainnet_your_key_here' }
20
20
  # )
21
21
  # result = protocol.call(:broadcast, tx)
22
22
  # puts result.data[:txid] if result.success?
23
+ #
24
+ # @see https://docs.taal.com TAAL API documentation
23
25
  class TAALBinary < BSV::Network::Protocol
24
26
  endpoint :broadcast, :post, '/api/v1/broadcast', response: :json
25
27
 
28
+ # @param base_url [String] base URL for the TAAL binary API
29
+ # @param api_key [String, nil] legacy API key shorthand (no Bearer prefix) — use +auth:+ for new code
30
+ # @param auth [Hash, Symbol, nil] auth config; takes precedence over +api_key:+
31
+ # @param http_client [Object, nil] injectable HTTP client for testing
32
+ def initialize(base_url:, api_key: nil, auth: nil, http_client: nil)
33
+ # Translate legacy api_key: to auth: { api_key: } so the base class sends
34
+ # the raw key without a Bearer prefix, matching TAAL's expected auth format.
35
+ resolved_auth = auth || (api_key ? { api_key: api_key } : nil)
36
+ super(base_url: base_url, auth: resolved_auth, http_client: http_client)
37
+ end
38
+
26
39
  private
27
40
 
28
41
  # Escape hatch for broadcast: sends raw binary transaction bytes with
@@ -35,8 +48,8 @@ module BSV
35
48
 
36
49
  uri = URI("#{@base_url}/api/v1/broadcast")
37
50
  request = Net::HTTP::Post.new(uri)
38
- request['Content-Type'] = 'application/octet-stream'
39
- request['Authorization'] = @api_key if @api_key
51
+ request['Content-Type'] = 'application/octet-stream'
52
+ apply_auth(request)
40
53
 
41
54
  request.body = body
42
55
 
@@ -7,9 +7,9 @@ module BSV
7
7
  module Protocols
8
8
  # WoCREST implements the WhatsOnChain REST API as a Protocol subclass.
9
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.
10
+ # Provides endpoints covering chain info, block headers, transactions,
11
+ # UTXOs, scripts, address queries, broadcast, search, stats, and health.
12
+ # Escape hatches handle WoC-specific body formats and field remapping.
13
13
  #
14
14
  # == Network resolution
15
15
  #
@@ -22,6 +22,8 @@ module BSV
22
22
  # woc = BSV::Network::Protocols::WoCREST.new(network: :main)
23
23
  # result = woc.call(:get_tx, 'abc123...')
24
24
  # puts result.data if result.success?
25
+ #
26
+ # @see https://developers.whatsonchain.com/ WhatsOnChain API documentation
25
27
  class WoCREST < Protocol
26
28
  NETWORKS = {
27
29
  'main' => 'main',
@@ -40,6 +42,9 @@ module BSV
40
42
  endpoint :get_chain_info, :get, '/chain/info', response: :json
41
43
  endpoint :get_block_header, :get, '/block/{height}/header', response: :json
42
44
  endpoint :get_block_headers, :get, '/block/headers', response: :json_array
45
+ endpoint :get_circulating_supply, :get, '/circulatingsupply'
46
+ endpoint :get_chain_tips, :get, '/chain/tips', response: :json_array
47
+ endpoint :get_peer_info, :get, '/peer/info', response: :json_array
43
48
 
44
49
  # Transaction
45
50
  endpoint :get_tx, :get, '/tx/{txid}/hex'
@@ -51,15 +56,26 @@ module BSV
51
56
  endpoint :decode_tx, :post, '/tx/decode', response: :json
52
57
  endpoint :get_tx_status, :post, '/txs/status', response: :json
53
58
  endpoint :get_tx_hex_bulk, :post, '/txs/hex', response: :json
59
+ endpoint :get_tx_binary, :get, '/tx/{txid}/bin'
60
+ endpoint :get_tx_by_block_index, :get, '/block/height/{height}/txindex/{txindex}', response: :json
61
+ endpoint :get_tx_propagation, :get, '/tx/hash/{txid}/propagation', response: :json
62
+ endpoint :get_bulk_tx_details, :post, '/txs', response: :json
63
+ endpoint :get_bulk_output_scripts, :post, '/txs/vouts/hex', response: :json
54
64
 
55
65
  # UTXO / spent status
56
66
  endpoint :get_utxos, :get, '/address/{address}/confirmed/unspent',
57
67
  response: :json_array
58
- endpoint :get_utxos_all, :get, '/address/{address}/unspent',
68
+ endpoint :get_utxos_all, :get, '/address/{address}/unspent/all',
59
69
  response: :json_array
60
70
  endpoint :is_utxo, :get, '/tx/{txid}/{vout}/spent', response: :json
61
71
  endpoint :is_utxo_bulk, :post, '/utxos/spent', response: :json_array
62
72
  endpoint :valid_root, :get, '/block/{height}/header', response: :json
73
+ endpoint :get_unconfirmed_utxos, :get, '/address/{address}/unconfirmed/unspent',
74
+ response: :json_array
75
+ endpoint :get_confirmed_spent, :get, '/tx/{txid}/{vout}/confirmed/spent', response: :json
76
+ endpoint :get_unconfirmed_spent, :get, '/tx/{txid}/{vout}/unconfirmed/spent', response: :json
77
+ endpoint :get_bulk_address_utxos, :post, '/addresses/confirmed/unspent', response: :json
78
+ endpoint :get_bulk_address_unconfirmed_utxos, :post, '/addresses/unconfirmed/unspent', response: :json
63
79
 
64
80
  # Script
65
81
  endpoint :get_script_unspent, :get, '/script/{script_hash}/confirmed/unspent',
@@ -69,6 +85,9 @@ module BSV
69
85
  endpoint :get_script_all_unspent, :get, '/script/{script_hash}/unspent/all',
70
86
  response: :json_array
71
87
  endpoint :get_script_unspent_bulk, :post, '/scripts/confirmed/unspent', response: :json
88
+ endpoint :get_script_unconfirmed_unspent, :get, '/script/{script_hash}/unconfirmed/unspent',
89
+ response: :json_array
90
+ endpoint :get_bulk_script_unconfirmed_unspent, :post, '/scripts/unconfirmed/unspent', response: :json
72
91
 
73
92
  # Address balance / history
74
93
  endpoint :get_balance, :get, '/address/{address}/confirmed/balance',
@@ -83,35 +102,46 @@ module BSV
83
102
  endpoint :get_exchange_rate, :get, '/exchangerate', response: :json
84
103
  endpoint :get_fee_recommendation, :get, '/feerecommendation', response: :json
85
104
  endpoint :get_mempool_info, :get, '/mempool/info', response: :json
105
+ endpoint :get_exchange_rate_historical, :get, '/exchangerate/historical', response: :json_array
106
+ endpoint :get_mempool_raw, :get, '/mempool/raw', response: :json_array
107
+
108
+ # Search
109
+ endpoint :search_links, :post, '/search/links', response: :json
110
+
111
+ # Stats
112
+ endpoint :get_block_stats, :get, '/block/height/{height}/stats', response: :json
113
+ endpoint :get_block_stats_by_hash, :get, '/block/hash/{hash}/stats', response: :json
114
+ endpoint :get_miner_block_stats, :get, '/miner/blocks/stats', response: :json
115
+ endpoint :get_miner_fees, :get, '/miner/fees', response: :json
116
+ endpoint :get_miner_summary, :get, '/miner/summary/stats', response: :json
117
+ endpoint :get_block_tag_count, :get, '/block/tagcount/height/{height}/stats', response: :json
86
118
 
87
119
  # Health
88
- endpoint :health, :get, '/health'
120
+ endpoint :health, :get, '/woc'
89
121
 
90
122
  attr_reader :network_name
91
123
 
92
124
  # @param base_url [String] base URL for the WoC API; may contain
93
125
  # +{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
126
+ # @param network [Symbol, String] :main, :mainnet, :test, :testnet, :stn
127
+ # @param api_key [String, nil] legacy API key — sends +Authorization: key+
128
+ # (no Bearer prefix, matching WoC's expected auth format)
129
+ # @param auth [Hash, Symbol, nil] auth config hash forwarded to Protocol;
130
+ # when provided, takes precedence over +api_key:+
96
131
  # @param http_client [Object, nil] injectable HTTP client for testing
97
- def initialize(base_url:, network: :main, api_key: nil, http_client: nil)
132
+ def initialize(base_url:, network: :main, api_key: nil, auth: nil, http_client: nil)
98
133
  @network_name = resolve_network(network)
134
+ # Translate legacy api_key: to auth: { api_key: } so the base class sends
135
+ # the raw key without a Bearer prefix, matching WoC's expected auth format.
136
+ resolved_auth = auth || (api_key ? { api_key: api_key } : nil)
99
137
  super(
100
138
  base_url: base_url,
101
- api_key: api_key,
139
+ auth: resolved_auth,
102
140
  network: @network_name,
103
141
  http_client: http_client
104
142
  )
105
143
  end
106
144
 
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
145
  private
116
146
 
117
147
  # Resolves network aliases to the WoC URL segment string.
@@ -125,77 +155,40 @@ module BSV
125
155
  end
126
156
  end
127
157
 
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
158
  # Checks whether a specific output is unspent by querying the WoC
172
159
  # spent-status endpoint.
173
160
  #
174
- # WoC returns a JSON object indicating whether the output has been spent.
161
+ # WoC returns 200 with spending transaction details when an output
162
+ # has been spent, or 404 when the output is unspent (no spending
163
+ # transaction found). This escape hatch maps both cases to a
164
+ # boolean: +true+ = unspent, +false+ = spent.
165
+ #
175
166
  # The +script_hash:+ keyword is accepted for future fallback support
176
167
  # but not used in this implementation.
177
168
  #
178
169
  # @param txid [String] WoC API boundary: display-order hex transaction ID
179
170
  # @param vout [Integer] output index
180
171
  # @param script_hash [String, nil] ignored
181
- # @return [Result::Success<Boolean>, Result::Error, Result::NotFound]
172
+ # @return [Result::Success<Boolean>, Result::Error]
182
173
  def call_is_utxo(txid, vout, script_hash: nil) # rubocop:disable Lint/UnusedMethodArgument
183
174
  result = default_call(:is_utxo, txid, vout)
184
- return result unless result.success?
185
175
 
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
176
+ # 404 = no spending tx found = output is unspent
177
+ return Result::Success.new(data: true) if result.not_found?
178
+
179
+ # Non-success, non-404 = genuine error
180
+ return result unless result.success?
190
181
 
191
- spent = result.data['spent']
192
- Result::Success.new(data: !spent)
182
+ # 200 with spending tx details = output is spent
183
+ Result::Success.new(data: false)
193
184
  end
194
185
 
195
186
  # Bulk-checks whether a set of outputs are unspent.
196
187
  #
197
- # WoC expects a JSON array of +{ txid, vout }+ objects. It returns an
198
- # array of entries, each containing +txid+, +vout+, and +spent+ fields.
188
+ # WoC expects +{ "utxos": [{ "txid": "...", "vout": N }, ...] }+ as the
189
+ # request body. It returns an array of entries, each with:
190
+ # +{ "utxo": { "txid": "...", "vout": N }, "spentIn": {...} | nil, "error": "" }+
191
+ # When +spentIn+ is present and non-empty the output is spent.
199
192
  # Entries absent from the response (unknown outputs) are treated as spent.
200
193
  #
201
194
  # @param outpoints [Array<Hash>] array of +{ txid:, vout: }+ hashes
@@ -205,22 +198,25 @@ module BSV
205
198
  def call_is_utxo_bulk(outpoints)
206
199
  return Result::Success.new(data: {}) if outpoints.empty?
207
200
 
208
- body = JSON.generate(outpoints.map { |op| { 'txid' => op[:txid].to_s, 'vout' => op[:vout].to_i } })
201
+ body = JSON.generate(utxos: outpoints.map { |op| { 'txid' => op[:txid].to_s, 'vout' => op[:vout].to_i } })
209
202
  result = default_call(:is_utxo_bulk, body: body)
210
203
  return result unless result.success?
211
204
 
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')
205
+ # Build a lookup from the response entries
206
+ # spentIn present and non-empty → spent (false); absent or empty → unspent (true)
207
+ normalised = result.data.each_with_object({}) do |entry, h|
208
+ next unless entry.is_a?(Hash) && entry.key?('utxo')
216
209
 
217
- key = "#{entry['txid']}.#{entry['vout']}"
218
- spent_map[key] = entry['spent']
210
+ utxo = entry['utxo']
211
+ key = "#{utxo['txid']}.#{utxo['vout']}"
212
+ spent_in = entry['spentIn']
213
+ h[key] = spent_in.nil? || !spent_in.is_a?(Hash) || spent_in.empty?
219
214
  end
220
215
 
221
- normalised = outpoints.each_with_object({}) do |op, h|
216
+ # Unknown outpoints (absent from response) default to spent (false)
217
+ outpoints.each do |op|
222
218
  key = "#{op[:txid]}.#{op[:vout]}"
223
- h[key] = spent_map.key?(key) ? !spent_map[key] : false
219
+ normalised[key] = false unless normalised.key?(key)
224
220
  end
225
221
 
226
222
  Result::Success.new(data: normalised)
@@ -260,26 +256,110 @@ module BSV
260
256
 
261
257
  # Fetches raw hex for multiple transactions in a single request.
262
258
  #
263
- # WoC expects a bare JSON array of txid strings as the request body.
259
+ # WoC expects +{ "txids": [...] }+ as the request body.
264
260
  #
265
261
  # @param txids [Array<String>] list of transaction IDs
266
262
  # @return [Result::Success<Array>, Result::Error]
267
263
  def call_get_tx_hex_bulk(txids)
268
- body = JSON.generate(txids)
264
+ body = JSON.generate(txids: txids)
269
265
  default_call(:get_tx_hex_bulk, body: body)
270
266
  end
271
267
 
272
268
  # Fetches confirmed UTXOs for multiple script hashes in a single request.
273
269
  #
274
- # WoC expects a bare JSON array of script hash strings as the request body.
270
+ # WoC expects +{ "scripts": [...] }+ as the request body.
275
271
  #
276
272
  # @param script_hashes [Array<String>] list of script hashes
277
273
  # @return [Result::Success<Hash>, Result::Error]
278
274
  def call_get_script_unspent_bulk(script_hashes)
279
- body = JSON.generate(script_hashes)
275
+ body = JSON.generate(scripts: script_hashes)
280
276
  default_call(:get_script_unspent_bulk, body: body)
281
277
  end
282
278
 
279
+ # Fetches full transaction details for multiple transactions.
280
+ #
281
+ # WoC expects +{ "txids": [...] }+ as the request body.
282
+ #
283
+ # @param txids [Array<String>] list of transaction IDs
284
+ # @return [Result::Success<Array>, Result::Error]
285
+ def call_get_bulk_tx_details(txids)
286
+ body = JSON.generate(txids: txids)
287
+ default_call(:get_bulk_tx_details, body: body)
288
+ end
289
+
290
+ # Fetches output scripts for specific vouts across multiple transactions.
291
+ #
292
+ # WoC expects +{ "txids": [{ "txid": "...", "vouts": [0, 1] }, ...] }+ as the request body.
293
+ #
294
+ # @param tx_vouts [Array<Hash>] array of +{ txid:, vouts: [Integer] }+ hashes
295
+ # @return [Result::Success<Array>, Result::Error]
296
+ def call_get_bulk_output_scripts(tx_vouts)
297
+ body = JSON.generate(txids: tx_vouts.map { |tv| { 'txid' => tv[:txid].to_s, 'vouts' => tv[:vouts] } })
298
+ default_call(:get_bulk_output_scripts, body: body)
299
+ end
300
+
301
+ # Fetches confirmed UTXOs for multiple addresses in a single request.
302
+ #
303
+ # WoC expects +{ "addresses": [...] }+ as the request body.
304
+ #
305
+ # @param addresses [Array<String>] list of addresses
306
+ # @return [Result::Success<Array>, Result::Error]
307
+ def call_get_bulk_address_utxos(addresses)
308
+ body = JSON.generate(addresses: addresses)
309
+ default_call(:get_bulk_address_utxos, body: body)
310
+ end
311
+
312
+ # Fetches unconfirmed UTXOs for multiple addresses in a single request.
313
+ #
314
+ # WoC expects +{ "addresses": [...] }+ as the request body.
315
+ #
316
+ # @param addresses [Array<String>] list of addresses
317
+ # @return [Result::Success<Array>, Result::Error]
318
+ def call_get_bulk_address_unconfirmed_utxos(addresses)
319
+ body = JSON.generate(addresses: addresses)
320
+ default_call(:get_bulk_address_unconfirmed_utxos, body: body)
321
+ end
322
+
323
+ # Fetches unconfirmed UTXOs for multiple script hashes in a single request.
324
+ #
325
+ # WoC expects +{ "scripts": [...] }+ as the request body.
326
+ #
327
+ # @param script_hashes [Array<String>] list of script hashes
328
+ # @return [Result::Success<Hash>, Result::Error]
329
+ def call_get_bulk_script_unconfirmed_unspent(script_hashes)
330
+ body = JSON.generate(scripts: script_hashes)
331
+ default_call(:get_bulk_script_unconfirmed_unspent, body: body)
332
+ end
333
+
334
+ # Searches WhatsOnChain for links matching a query.
335
+ #
336
+ # WoC expects +{ "query": "..." }+ as the request body.
337
+ #
338
+ # @param query [String] search term
339
+ # @return [Result::Success<Hash>, Result::Error]
340
+ def call_search_links(query)
341
+ body = JSON.generate(query: query)
342
+ default_call(:search_links, body: body)
343
+ end
344
+
345
+ # Fetches status for multiple transactions.
346
+ #
347
+ # WoC expects +{ "txids": [...] }+ as the request body. When called
348
+ # with a raw +body:+ keyword the body is forwarded as-is, which
349
+ # preserves backwards-compatibility with callers that build the body
350
+ # themselves.
351
+ #
352
+ # @param txids [Array<String>, nil] list of transaction IDs (positional)
353
+ # @param body [String, nil] pre-serialised request body (keyword)
354
+ # @return [Result::Success<Array>, Result::Error]
355
+ # @raise [ArgumentError] when neither txids nor body is provided
356
+ def call_get_tx_status(txids = nil, body: nil)
357
+ raise ArgumentError, 'provide txids array or body: keyword' if txids.nil? && body.nil?
358
+
359
+ raw_body = body || JSON.generate(txids: txids)
360
+ default_call(:get_tx_status, body: raw_body)
361
+ end
362
+
283
363
  # Verifies that a merkle root matches the one recorded for a given
284
364
  # block height. Uses the get_block_header endpoint internally.
285
365
  #
@@ -9,6 +9,7 @@ module BSV
9
9
  module Protocols
10
10
  autoload :ARC, 'bsv/network/protocols/arc'
11
11
  autoload :Chaintracks, 'bsv/network/protocols/chaintracks'
12
+ autoload :JungleBus, 'bsv/network/protocols/jungle_bus'
12
13
  autoload :Ordinals, 'bsv/network/protocols/ordinals'
13
14
  autoload :TAALBinary, 'bsv/network/protocols/taal_binary'
14
15
  autoload :WoCREST, 'bsv/network/protocols/woc_rest'
@@ -22,17 +22,30 @@ module BSV
22
22
  # result = gorillapool.call(:broadcast, tx)
23
23
  # result.success? # => true
24
24
  class Provider
25
- attr_reader :name
25
+ attr_reader :name, :auth, :rate_limit
26
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)
27
+ # @param name [String] human-readable provider name (e.g. 'GorillaPool')
28
+ # @param auth [Hash, Symbol] authentication config or +:none+ (default: +:none+).
29
+ # An empty hash or +nil+ is treated as +:none+.
30
+ # @param rate_limit [Numeric, nil] maximum requests per second (+nil+ = unlimited)
31
+ # @param block [Proc] optional configuration block — yields +self+
32
+ def initialize(name, auth: :none, rate_limit: nil, &block)
30
33
  @name = name
34
+ @auth = normalise_auth(auth)
35
+ @rate_limit = rate_limit
31
36
  @protocols = []
32
37
  @command_index = {}
33
38
  block&.call(self)
34
39
  end
35
40
 
41
+ # Returns +true+ when the provider is configured with authentication
42
+ # credentials (i.e. +auth+ is not +:none+ and not an empty hash).
43
+ #
44
+ # @return [Boolean]
45
+ def authenticated?
46
+ @auth != :none
47
+ end
48
+
36
49
  # Registers a protocol class with the provider.
37
50
  #
38
51
  # The class is instantiated with the supplied +kwargs+. Its commands are
@@ -100,7 +113,9 @@ module BSV
100
113
  # @return [String]
101
114
  def to_s
102
115
  protocol_summary = @protocols.map { |p| p.class.name&.split('::')&.last || p.class.to_s }.join(', ')
103
- "#<#{self.class} name=#{@name.inspect} protocols=[#{protocol_summary}]>"
116
+ auth_status = authenticated? ? 'authenticated' : 'unauthenticated'
117
+ rate_part = @rate_limit.nil? ? '' : " rate_limit=#{@rate_limit}"
118
+ "#<#{self.class} name=#{@name.inspect} auth=#{auth_status}#{rate_part} protocols=[#{protocol_summary}]>"
104
119
  end
105
120
  alias inspect to_s
106
121
 
@@ -118,6 +133,22 @@ module BSV
118
133
 
119
134
  instance.call(sym, *args, **kwargs)
120
135
  end
136
+
137
+ private
138
+
139
+ # Normalises the +auth+ argument so that +nil+ and empty hashes are
140
+ # stored as +:none+, giving a single canonical sentinel value for
141
+ # "no authentication".
142
+ #
143
+ # @param auth [Hash, Symbol, nil]
144
+ # @return [Hash, Symbol]
145
+ def normalise_auth(auth)
146
+ return :none if auth.nil?
147
+ return :none if auth == :none
148
+ return :none if auth.is_a?(Hash) && (auth.empty? || (auth[:bearer].nil? && auth[:api_key].nil?))
149
+
150
+ auth
151
+ end
121
152
  end
122
153
  end
123
154
  end