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,321 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'set'
4
+ require 'net/http'
5
+ require 'json'
6
+ require 'uri'
7
+
8
+ module BSV
9
+ module Network
10
+ # Protocol is the base class for all BSV network protocol definitions.
11
+ #
12
+ # Subclasses declare their commands via the +endpoint+ DSL macro. Each
13
+ # endpoint maps a command name (Symbol) to an HTTP method, a path template,
14
+ # and a response handler. The +subscription+ macro is a placeholder for future
15
+ # WebSocket support.
16
+ #
17
+ # Subclass isolation is enforced via an +inherited+ hook — each subclass
18
+ # receives its own empty +@endpoints+ and +@subscriptions+ hashes. Adding
19
+ # endpoints to a subclass never affects the parent.
20
+ #
21
+ # HTTP dispatch routes through +call+: if a +call_<name>+ escape hatch
22
+ # method exists on the instance, it is called; otherwise +default_call+
23
+ # interpolates the URL template, makes the HTTP request, and maps the
24
+ # response to a +Result+.
25
+ #
26
+ # == Example
27
+ #
28
+ # class MyProtocol < BSV::Network::Protocol
29
+ # endpoint :get_tx, :get, '/v1/tx/{txid}'
30
+ # endpoint :broadcast, :post, '/v1/tx', response: :json
31
+ # end
32
+ #
33
+ # p = MyProtocol.new(base_url: 'https://api.example.com', network: 'main')
34
+ # MyProtocol.commands #=> #<Set: {:get_tx, :broadcast}>
35
+ class Protocol
36
+ class << self
37
+ # Registers an endpoint definition for this protocol class.
38
+ #
39
+ # @param command_name [Symbol] the command name (e.g. +:broadcast+)
40
+ # @param http_method [Symbol] +:get+ or +:post+
41
+ # @param path_template [String] path with +{param}+ placeholders
42
+ # @param response [Symbol, #call] response handler — +:raw+, +:json+,
43
+ # +:json_array+, or a callable (lambda/proc)
44
+ def endpoint(command_name, http_method, path_template, response: :raw)
45
+ @endpoints[command_name] = {
46
+ method: http_method,
47
+ path: path_template,
48
+ response: response
49
+ }
50
+ end
51
+
52
+ # Registers a subscription definition. Placeholder for Phase C WebSocket
53
+ # support. Stored but not callable at runtime.
54
+ #
55
+ # @param event_name [Symbol] the event name
56
+ # @param path [String] WebSocket path
57
+ # @param opts [Hash] additional options (reserved)
58
+ def subscription(event_name, path, **opts)
59
+ @subscriptions[event_name] = { path: path }.merge(opts)
60
+ end
61
+
62
+ # Returns a +Set+ of command names declared on this protocol class.
63
+ #
64
+ # @return [Set<Symbol>]
65
+ def commands
66
+ Set.new(@endpoints.keys)
67
+ end
68
+
69
+ # Returns a frozen copy of the endpoints hash for introspection.
70
+ #
71
+ # @return [Hash]
72
+ def endpoints
73
+ @endpoints.dup.freeze
74
+ end
75
+
76
+ # Returns a frozen copy of the subscriptions hash for introspection.
77
+ #
78
+ # @return [Hash]
79
+ def subscriptions
80
+ @subscriptions.dup.freeze
81
+ end
82
+
83
+ # Give each subclass its own isolated +@endpoints+ and +@subscriptions+
84
+ # hashes. Deep-copies the parent's endpoints so that existing declarations
85
+ # are inherited but mutations on the subclass do not affect the parent.
86
+ def inherited(subclass)
87
+ super
88
+ # Deep copy: each endpoint value is a plain hash of scalar values,
89
+ # so a one-level transform_values dup is sufficient.
90
+ parent_endpoints = @endpoints.each_with_object({}) do |(k, v), h|
91
+ h[k] = v.dup
92
+ end
93
+ parent_subscriptions = @subscriptions.each_with_object({}) do |(k, v), h|
94
+ h[k] = v.dup
95
+ end
96
+ subclass.instance_variable_set(:@endpoints, parent_endpoints)
97
+ subclass.instance_variable_set(:@subscriptions, parent_subscriptions)
98
+ end
99
+ end
100
+
101
+ # Initialise the class-level hashes on Protocol itself so that the
102
+ # inherited hook works correctly for direct subclasses.
103
+ @endpoints = {}
104
+ @subscriptions = {}
105
+
106
+ attr_reader :base_url, :api_key, :network, :http_client
107
+
108
+ # @param base_url [String] base URL, may contain +{network}+ placeholder
109
+ # @param api_key [String, nil] API key for authenticated requests
110
+ # @param network [String, Symbol, nil] network name (e.g. 'main', 'test')
111
+ # @param http_client [Object, nil] injectable HTTP client (used in Task 3)
112
+ def initialize(base_url:, api_key: nil, network: nil, http_client: nil)
113
+ @api_key = api_key
114
+ @network = network
115
+ @http_client = http_client
116
+ @base_url = build_base_url(base_url, network)
117
+ end
118
+
119
+ # Dispatches a command by name.
120
+ #
121
+ # If a method named +call_<command_name>+ exists on the instance it is
122
+ # used as an escape hatch — that method receives +args+ and +kwargs+
123
+ # and MUST return a +Result+. Otherwise +default_call+ is invoked.
124
+ #
125
+ # Subscriptions are not callable; calling one raises +NotImplementedError+.
126
+ #
127
+ # @param command_name [Symbol, String] command to invoke
128
+ # @param args [Array] positional arguments forwarded to path interpolation
129
+ # @param kwargs [Hash] keyword arguments forwarded to path interpolation
130
+ # @return [Result::Success, Result::Error, Result::NotFound]
131
+ # @raise [ArgumentError] when command_name is not registered
132
+ def call(command_name, *args, **kwargs)
133
+ name = command_name.to_sym
134
+
135
+ if self.class.subscriptions.key?(name)
136
+ raise NotImplementedError,
137
+ "#{name} is a subscription — WebSocket dispatch is not yet implemented"
138
+ end
139
+
140
+ escape = :"call_#{name}"
141
+ if respond_to?(escape, true)
142
+ return kwargs.empty? ? send(escape, *args) : send(escape, *args, **kwargs)
143
+ end
144
+
145
+ default_call(name, *args, **kwargs)
146
+ end
147
+
148
+ # Dispatches a command directly via HTTP, bypassing any escape hatch.
149
+ #
150
+ # Path placeholders (+{param}+) are filled from +kwargs+ first; any
151
+ # remaining placeholders are filled positionally from +args+. Named
152
+ # kwargs take precedence over positional args for the same placeholder.
153
+ #
154
+ # POST body is taken from +kwargs.delete(:body)+ (removed before path
155
+ # interpolation).
156
+ #
157
+ # @param command_name [Symbol] registered command name
158
+ # @param args [Array] positional path parameters
159
+ # @param kwargs [Hash] named path parameters (and optional +:body+)
160
+ # @return [Result::Success, Result::Error, Result::NotFound]
161
+ # @raise [ArgumentError] when command_name is not registered or a
162
+ # required path parameter is missing
163
+ def default_call(command_name, *args, **kwargs)
164
+ name = command_name.to_sym
165
+ defn = self.class.endpoints[name]
166
+ raise ArgumentError, "unknown command: #{name}" unless defn
167
+
168
+ body = kwargs.delete(:body)
169
+ path = interpolate_path(defn[:path], args, kwargs)
170
+ uri = URI("#{@base_url}#{path}")
171
+ request = build_request(defn[:method], uri, body)
172
+ response = execute(uri, request)
173
+
174
+ map_response(response, defn[:response])
175
+ end
176
+
177
+ private
178
+
179
+ # Resolves the final base URL by interpolating +{network}+ and stripping
180
+ # any trailing slash.
181
+ #
182
+ # @param url [String]
183
+ # @param network [String, Symbol, nil]
184
+ # @return [String]
185
+ # @raise [ArgumentError] when +{network}+ placeholder is present but
186
+ # +network+ was not provided
187
+ def build_base_url(url, network)
188
+ if url.include?('{network}')
189
+ raise ArgumentError, 'base_url contains {network} placeholder but no network: was provided' if network.nil?
190
+
191
+ url = url.gsub('{network}', network.to_s)
192
+ end
193
+
194
+ url.chomp('/')
195
+ end
196
+
197
+ # Interpolates +{placeholder}+ tokens in a path template.
198
+ #
199
+ # Named kwargs are matched first (removing matched keys from the hash).
200
+ # Remaining positional args fill placeholders in template order.
201
+ #
202
+ # @param template [String] path template with +{name}+ tokens
203
+ # @param args [Array] positional substitution values
204
+ # @param kwargs [Hash] named substitution values (modified in place)
205
+ # @return [String]
206
+ # @raise [ArgumentError] when a placeholder cannot be filled
207
+ def interpolate_path(template, args, kwargs)
208
+ pos_args = args.dup
209
+ remaining = kwargs.dup
210
+
211
+ # Extract ordered placeholder names from the template
212
+ names = template.scan(/\{(\w+)\}/).flatten.map(&:to_sym)
213
+
214
+ result = template.dup
215
+ names.each do |name|
216
+ value =
217
+ if remaining.key?(name)
218
+ remaining.delete(name)
219
+ elsif !pos_args.empty?
220
+ pos_args.shift
221
+ else
222
+ raise ArgumentError, "missing path parameter: #{name}"
223
+ end
224
+ result = result.sub("{#{name}}") { value.to_s }
225
+ end
226
+ result
227
+ end
228
+
229
+ # Builds a Net::HTTP request for the given method, URI, and optional body.
230
+ #
231
+ # @param http_method [Symbol] +:get+ or +:post+
232
+ # @param uri [URI]
233
+ # @param body [String, nil] raw body for POST requests
234
+ # @return [Net::HTTPRequest]
235
+ def build_request(http_method, uri, body)
236
+ request =
237
+ case http_method
238
+ when :get then Net::HTTP::Get.new(uri)
239
+ when :post then Net::HTTP::Post.new(uri)
240
+ else raise ArgumentError, "unsupported HTTP method: #{http_method}"
241
+ end
242
+
243
+ request['Authorization'] = "Bearer #{@api_key}" if @api_key
244
+ if body && request.respond_to?(:body=)
245
+ request.body = body
246
+ request.content_type = 'application/json' unless request.content_type
247
+ end
248
+ request
249
+ end
250
+
251
+ # Executes the request via the injectable client or +Net::HTTP.start+.
252
+ #
253
+ # @param uri [URI]
254
+ # @param request [Net::HTTPRequest]
255
+ # @return [Net::HTTPResponse]
256
+ def execute(uri, request)
257
+ if @http_client
258
+ @http_client.request(uri, request)
259
+ else
260
+ Net::HTTP.start(uri.hostname, uri.port, use_ssl: uri.scheme == 'https') do |http|
261
+ http.request(request)
262
+ end
263
+ end
264
+ end
265
+
266
+ # Maps an HTTP response to a Result type, applying the response handler
267
+ # on 2xx bodies.
268
+ #
269
+ # All non-2xx results carry +status_code:+ in their +metadata+ hash so
270
+ # that facades can construct domain exceptions with the original HTTP code.
271
+ #
272
+ # @param response [Net::HTTPResponse]
273
+ # @param handler [Symbol, #call]
274
+ # @return [Result::Success, Result::Error, Result::NotFound]
275
+ def map_response(response, handler)
276
+ code = response.code.to_i
277
+
278
+ case code
279
+ when 200..299
280
+ data = apply_handler(response.body, handler)
281
+ return data if data.is_a?(Result::Error)
282
+
283
+ Result::Success.new(data: data)
284
+ when 404
285
+ Result::NotFound.new(message: response.body, metadata: { status_code: code })
286
+ when 429, 500..599
287
+ Result::Error.new(message: response.body, retryable: true, metadata: { status_code: code })
288
+ else
289
+ Result::Error.new(message: response.body, retryable: false, metadata: { status_code: code })
290
+ end
291
+ end
292
+
293
+ # Applies the response handler to a raw body string.
294
+ #
295
+ # @param body [String, nil]
296
+ # @param handler [Symbol, #call]
297
+ # @return [Object, Result::Error]
298
+ def apply_handler(body, handler)
299
+ return body if body.nil?
300
+
301
+ case handler
302
+ when :raw
303
+ body
304
+ when :json
305
+ JSON.parse(body)
306
+ when :json_array
307
+ parsed = JSON.parse(body)
308
+ raise TypeError, "expected Array, got #{parsed.class}" unless parsed.is_a?(Array)
309
+
310
+ parsed
311
+ else
312
+ raise ArgumentError, "unsupported response handler: #{handler.inspect}" unless handler.respond_to?(:call)
313
+
314
+ handler.call(body)
315
+ end
316
+ rescue JSON::ParserError, TypeError => e
317
+ Result::Error.new(message: "JSON/response error: #{e.message}", retryable: false)
318
+ end
319
+ end
320
+ end
321
+ end
@@ -0,0 +1,351 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require 'securerandom'
5
+
6
+ module BSV
7
+ module Network
8
+ module Protocols
9
+ # ARC protocol implementation for submitting transactions to the BSV network.
10
+ #
11
+ # Extends Protocol with five endpoints and two escape hatches for broadcast
12
+ # logic: EF format preference, rejection detection, and custom headers.
13
+ #
14
+ # The protocol returns Result objects (never raises). The facade layer
15
+ # (Phase D) is responsible for translating Results to BroadcastResponse /
16
+ # BroadcastError as needed by consumer code.
17
+ #
18
+ # == Example
19
+ #
20
+ # arc = BSV::Network::Protocols::ARC.new(
21
+ # base_url: 'https://arc.taal.com',
22
+ # api_key: 'my-api-key'
23
+ # )
24
+ # result = arc.call(:broadcast, tx)
25
+ # result.success? # => true
26
+ # result.data[:txid] # => "abc123..."
27
+ class ARC < Protocol
28
+ # ARC response statuses that indicate a transaction was NOT accepted.
29
+ # Matches the TypeScript SDK's ARC broadcaster failure set.
30
+ REJECTED_STATUSES = %w[
31
+ REJECTED
32
+ DOUBLE_SPEND_ATTEMPTED
33
+ INVALID
34
+ MALFORMED
35
+ MINED_IN_STALE_BLOCK
36
+ ].freeze
37
+
38
+ # Substring marker for orphan detection in txStatus or extraInfo fields.
39
+ ORPHAN_MARKER = 'ORPHAN'
40
+
41
+ endpoint :broadcast, :post, '/v1/tx', response: :json
42
+ endpoint :broadcast_many, :post, '/v1/txs', response: :json_array
43
+ endpoint :get_tx_status, :get, '/v1/tx/{txid}', response: :json
44
+ endpoint :get_policy, :get, '/v1/policy', response: :json
45
+ endpoint :health, :get, '/v1/health', response: :json
46
+
47
+ # @param base_url [String] ARC base URL (may contain {network})
48
+ # @param api_key [String, nil] optional bearer token
49
+ # @param network [String, nil] network name for base URL interpolation
50
+ # @param deployment_id [String, nil] deployment identifier for the
51
+ # XDeployment-ID header; defaults to a per-instance random hex value
52
+ # @param callback_url [String, nil] optional X-CallbackUrl header value
53
+ # @param callback_token [String, nil] optional X-CallbackToken header value
54
+ # @param http_client [#request, nil] injectable HTTP client for testing
55
+ def initialize(base_url:, api_key: nil, network: nil, deployment_id: nil,
56
+ callback_url: nil, callback_token: nil, http_client: nil)
57
+ super(base_url: base_url, api_key: api_key, network: network, http_client: http_client)
58
+ @deployment_id = deployment_id || "bsv-ruby-sdk-#{SecureRandom.hex(8)}"
59
+ @callback_url = callback_url
60
+ @callback_token = callback_token
61
+ end
62
+
63
+ private
64
+
65
+ # Broadcast escape hatch: EF format preference, custom headers, rejection
66
+ # detection, and malformed 2xx detection.
67
+ #
68
+ # @param tx [Transaction] the transaction to broadcast
69
+ # @param wait_for [String, nil] ARC wait condition
70
+ # @param skip_fee_validation [Boolean, nil]
71
+ # @param skip_script_validation [Boolean, nil]
72
+ # @param skip_tx_validation [Boolean, nil]
73
+ # @param callback_url [String, nil] per-call callback URL override
74
+ # @param callback_token [String, nil] per-call callback token override
75
+ # @param callback_batch [Boolean, nil] when truthy, sends X-CallbackBatch header
76
+ # @return [Result::Success, Result::Error]
77
+ def call_broadcast(tx, wait_for: nil, skip_fee_validation: nil,
78
+ skip_script_validation: nil, skip_tx_validation: nil,
79
+ callback_url: nil, callback_token: nil, callback_batch: nil, **)
80
+ hex = ef_hex_with_fallback(tx)
81
+ body = JSON.generate(rawTx: hex)
82
+
83
+ extra_headers = build_broadcast_headers(
84
+ wait_for: wait_for,
85
+ skip_fee_validation: skip_fee_validation,
86
+ skip_script_validation: skip_script_validation,
87
+ skip_tx_validation: skip_tx_validation,
88
+ callback_url: callback_url || @callback_url,
89
+ callback_token: callback_token || @callback_token,
90
+ callback_batch: callback_batch
91
+ )
92
+
93
+ response = post_with_headers('/v1/tx', body, extra_headers)
94
+ parse_single_broadcast_response(response)
95
+ end
96
+
97
+ # Broadcast-many escape hatch: batch broadcast with per-item rejection detection.
98
+ #
99
+ # @param txs [Array<Transaction>]
100
+ # @param wait_for [String, nil]
101
+ # @param skip_fee_validation [Boolean, nil]
102
+ # @param skip_script_validation [Boolean, nil]
103
+ # @param skip_tx_validation [Boolean, nil]
104
+ # @param callback_url [String, nil]
105
+ # @param callback_token [String, nil]
106
+ # @param callback_batch [Boolean, nil]
107
+ # @return [Result::Success, Result::Error]
108
+ def call_broadcast_many(txs, wait_for: nil, skip_fee_validation: nil,
109
+ skip_script_validation: nil, skip_tx_validation: nil,
110
+ callback_url: nil, callback_token: nil, callback_batch: nil, **)
111
+ return Result::Success.new(data: []) if txs.empty?
112
+
113
+ body = JSON.generate(txs.map { |tx| { rawTx: ef_hex_with_fallback(tx) } })
114
+
115
+ extra_headers = build_broadcast_headers(
116
+ wait_for: wait_for,
117
+ skip_fee_validation: skip_fee_validation,
118
+ skip_script_validation: skip_script_validation,
119
+ skip_tx_validation: skip_tx_validation,
120
+ callback_url: callback_url || @callback_url,
121
+ callback_token: callback_token || @callback_token,
122
+ callback_batch: callback_batch
123
+ )
124
+
125
+ response = post_with_headers('/v1/txs', body, extra_headers)
126
+ parse_batch_broadcast_response(response)
127
+ end
128
+
129
+ # Override to always include XDeployment-ID on every ARC request.
130
+ def build_request(http_method, uri, body)
131
+ request = super
132
+ request['XDeployment-ID'] = @deployment_id
133
+ request
134
+ end
135
+
136
+ # Prefer Extended Format hex (BRC-30) so ARC can validate sighashes without
137
+ # fetching parent transactions. Falls back to plain raw-tx hex when any input
138
+ # lacks source_satoshis / source_locking_script.
139
+ def ef_hex_with_fallback(tx)
140
+ tx.to_ef_hex
141
+ rescue ArgumentError
142
+ tx.to_hex
143
+ end
144
+
145
+ # Build the hash of ARC-specific extra headers.
146
+ def build_broadcast_headers(wait_for:, skip_fee_validation:, skip_script_validation:,
147
+ skip_tx_validation:, callback_url:, callback_token:,
148
+ callback_batch:)
149
+ headers = { 'XDeployment-ID' => @deployment_id }
150
+ headers['X-WaitFor'] = wait_for if wait_for
151
+ headers['X-CallbackUrl'] = callback_url if callback_url
152
+ headers['X-CallbackToken'] = callback_token if callback_token
153
+ headers['X-SkipFeeValidation'] = 'true' if skip_fee_validation
154
+ headers['X-SkipScriptValidation'] = 'true' if skip_script_validation
155
+ headers['X-SkipTxValidation'] = 'true' if skip_tx_validation
156
+ headers['X-CallbackBatch'] = 'true' if callback_batch
157
+ headers
158
+ end
159
+
160
+ # Perform a POST to the given path with a JSON body plus extra headers.
161
+ # Returns a Net::HTTPResponse-like object.
162
+ def post_with_headers(path, body, extra_headers)
163
+ uri = URI("#{@base_url}#{path}")
164
+ request = build_request(:post, uri, body)
165
+ extra_headers.each { |k, v| request[k] = v }
166
+ execute(uri, request)
167
+ end
168
+
169
+ # Parse and validate a single-transaction ARC response.
170
+ def parse_single_broadcast_response(response)
171
+ code = response.code.to_i
172
+ body = safe_parse_json(response.body)
173
+
174
+ unless body.is_a?(Hash)
175
+ return Result::Error.new(
176
+ message: "HTTP #{code}",
177
+ retryable: retryable_code?(code),
178
+ metadata: { status_code: code }
179
+ )
180
+ end
181
+
182
+ unless (200..299).cover?(code)
183
+ return Result::Error.new(
184
+ message: body['detail'] || body['title'] || "HTTP #{code}",
185
+ retryable: retryable_code?(code),
186
+ metadata: { status_code: code, arc_status: body['txStatus'].to_s.upcase, txid: body['txid'] }
187
+ )
188
+ end
189
+
190
+ if rejected_status?(body)
191
+ return Result::Error.new(
192
+ message: body['detail'] || body['title'] || body['txStatus'],
193
+ retryable: false,
194
+ metadata: { status_code: code, arc_status: body['txStatus'].to_s.upcase, txid: body['txid'] }
195
+ )
196
+ end
197
+
198
+ unless body['txid']
199
+ return Result::Error.new(
200
+ message: 'ARC returned a malformed 2xx response',
201
+ retryable: false,
202
+ metadata: { status_code: code }
203
+ )
204
+ end
205
+
206
+ Result::Success.new(
207
+ data: arc_data_from(body),
208
+ metadata: { arc_status: body['txStatus'].to_s.upcase }
209
+ )
210
+ end
211
+
212
+ # Parse and validate a batch ARC response. HTTP-level errors return a
213
+ # single Result::Error; per-item rejections are embedded in the data array.
214
+ def parse_batch_broadcast_response(response)
215
+ code = response.code.to_i
216
+ body = safe_parse_json(response.body)
217
+
218
+ unless (200..299).cover?(code)
219
+ return Result::Error.new(
220
+ message: body.is_a?(Hash) ? (body['detail'] || body['title'] || "HTTP #{code}") : "HTTP #{code}",
221
+ retryable: retryable_code?(code),
222
+ metadata: { status_code: code }
223
+ )
224
+ end
225
+
226
+ unless body.is_a?(Array)
227
+ return Result::Error.new(
228
+ message: 'ARC returned a malformed batch response',
229
+ retryable: false,
230
+ metadata: { status_code: code }
231
+ )
232
+ end
233
+
234
+ items = body.map { |item| build_item_result(item) }
235
+ Result::Success.new(data: items, metadata: {})
236
+ end
237
+
238
+ # Build a per-item result for a batch response entry.
239
+ def build_item_result(item)
240
+ unless item.is_a?(Hash)
241
+ return Result::Error.new(
242
+ message: 'malformed batch item',
243
+ retryable: false,
244
+ metadata: {}
245
+ )
246
+ end
247
+
248
+ if rejected_status?(item)
249
+ Result::Error.new(
250
+ message: item['detail'] || item['title'] || item['txStatus'],
251
+ retryable: false,
252
+ metadata: { status_code: 200, arc_status: item['txStatus'].to_s.upcase, txid: item['txid'] }
253
+ )
254
+ elsif !item['txid']
255
+ Result::Error.new(
256
+ message: 'ARC returned a malformed 2xx response',
257
+ retryable: false,
258
+ metadata: { status_code: 200 }
259
+ )
260
+ else
261
+ Result::Success.new(
262
+ data: arc_data_from(item),
263
+ metadata: { arc_status: item['txStatus'].to_s.upcase }
264
+ )
265
+ end
266
+ end
267
+
268
+ # Escape hatch for get_tx_status: returns a normalised data hash using the
269
+ # same field set as broadcast responses rather than the raw parsed JSON.
270
+ # Also checks for rejection status and missing txid (malformed 2xx).
271
+ #
272
+ # @param txid [String] the transaction ID to query
273
+ # @return [Result::Success, Result::Error, Result::NotFound]
274
+ def call_get_tx_status(txid, **)
275
+ response = default_call(:get_tx_status, txid)
276
+ return response unless response.is_a?(Result::Success)
277
+
278
+ body = response.data
279
+
280
+ if rejected_status?(body)
281
+ return Result::Error.new(
282
+ message: body['detail'] || body['title'] || body['txStatus'],
283
+ retryable: false,
284
+ metadata: { arc_status: body['txStatus'].to_s.upcase, txid: body['txid'], status_code: 200 }
285
+ )
286
+ end
287
+
288
+ unless body['txid']
289
+ return Result::Error.new(
290
+ message: 'ARC returned a malformed 2xx response',
291
+ retryable: false,
292
+ metadata: { status_code: 200 }
293
+ )
294
+ end
295
+
296
+ Result::Success.new(
297
+ data: arc_data_from(body),
298
+ metadata: { arc_status: body['txStatus'].to_s.upcase }
299
+ )
300
+ end
301
+
302
+ # Build the normalised ARC data hash from a parsed JSON response body.
303
+ # Includes all 8 fields that BroadcastResponse expects.
304
+ #
305
+ # @param body [Hash] parsed ARC JSON response
306
+ # @return [Hash]
307
+ def arc_data_from(body)
308
+ {
309
+ txid: body['txid'],
310
+ tx_status: body['txStatus'],
311
+ message: body['title'],
312
+ extra_info: body['extraInfo'],
313
+ block_hash: body['blockHash'],
314
+ block_height: body['blockHeight'],
315
+ timestamp: body['timestamp'],
316
+ competing_txs: body['competingTxs']
317
+ }
318
+ end
319
+
320
+ # Determine whether a status code indicates a retryable failure.
321
+ def retryable_code?(code)
322
+ code == 429 || (500..599).cover?(code)
323
+ end
324
+
325
+ # Determine whether an ARC response body represents a rejected transaction.
326
+ # Case-insensitive match — the TypeScript reference SDK explicitly uppercases
327
+ # both fields before membership / substring checks. ARC has a documented
328
+ # history of emitting values outside its own OpenAPI enum, so case
329
+ # normalisation is the defensive choice.
330
+ def rejected_status?(body)
331
+ tx_status = body['txStatus'].to_s.upcase
332
+ return true if REJECTED_STATUSES.include?(tx_status)
333
+ return true if tx_status.include?(ORPHAN_MARKER)
334
+
335
+ extra_info = body['extraInfo'].to_s.upcase
336
+ return true if extra_info.include?(ORPHAN_MARKER)
337
+
338
+ false
339
+ end
340
+
341
+ # Parse JSON, returning a hash with a 'detail' key on parse failure.
342
+ # When the raw input is nil or empty the detail is nil (not an empty string).
343
+ def safe_parse_json(raw)
344
+ JSON.parse(raw.to_s)
345
+ rescue JSON::ParserError
346
+ { 'detail' => (raw.to_s.empty? ? nil : raw.to_s) }
347
+ end
348
+ end
349
+ end
350
+ end
351
+ end