bsv-sdk 0.18.0 → 0.19.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 6bb4b97822f38d793a308e9beddc94bd90e64457bda8f1782875ddcd8a1ff1f3
4
- data.tar.gz: 85009a67a04ee1264acd883224ea418ed46558629a18db3d1757486cc544852d
3
+ metadata.gz: ade93923526aab66c548ffc2326e09e439e73a8403748afe3bcd2c1e6e04d58d
4
+ data.tar.gz: aff5ede00eb02991dca9daa171f0f21d04d975f6ad3f012a817cfc5b46710ceb
5
5
  SHA512:
6
- metadata.gz: 85e2958f6bb6af774b2ca06e5187bbc1c756546e322430375108617b4675e6e5d5373aa8d93fd4504327d3c48e29507a2caea566d0b9af5e16c3fdd089bc2fbc
7
- data.tar.gz: 56797e445e8996c2a41e85484e49f53b3568eaebeaf01de39a1e919c6949f40b172236ad5bd89fbc77eb9405a532653c9ab1972e696e699d6410e503318360d7
6
+ metadata.gz: 32271e4c61f304555853790682f9008f4789e34059d6d868207f0ef07cf9744cf87ae67e5589ad451d22bebc7a435ae4989deaa48f9254b900691b303b961a7d
7
+ data.tar.gz: f51f7b37da7a8388606763df33bf71900592f37f7cb2ca6690337a7dfd04821eca14d78153f8dc470ad01c377b1b0e8fa300a661686e54255dac54d9a90ae190
data/CHANGELOG.md CHANGED
@@ -5,6 +5,43 @@ All notable changes to the `bsv-sdk` gem are documented here.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/)
6
6
  and this gem adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## 0.19.0 — 2026-05-11
9
+
10
+ ### Breaking Changes
11
+ - `Result::Success/Error/NotFound` replaced by single `ProtocolResponse` class composing `Net::HTTPResponse`
12
+ - Predicates renamed: `success?` → `http_success?`, `not_found?` → `http_not_found?`, `error?` removed (use `!http_success?`)
13
+ - Constructor keyword `ok:` → `http_success:`
14
+ - ARC `arc_data_from` key remapping removed — `.data` returns raw API JSON with string keys (`'txid'` not `:txid`)
15
+ - Batch broadcast `.data` returns raw JSON array (no per-item `Result` wrapping)
16
+ - `BroadcastError`, `BroadcastResponse`, `ChainProviderError`, deprecated `BSV::Network::ARC` and `BSV::Network::WhatsOnChain` facades removed
17
+
18
+ ### Added
19
+ - `ProtocolResponse` class with progressive enhancement: `.body`/`.code` (raw HTTP) → `.data` (parsed) → `.canonical` (placeholder)
20
+ - `ProtocolResponse#with(**overrides)` for escape hatch post-processing
21
+ - `fake_http_response` shared test helper for building real `Net::HTTPResponse` subclass instances
22
+ - Debug logging in Protocol (`call`, `default_call`, `build_response`) and `ProtocolResponse#with`
23
+
24
+ ### Changed
25
+ - `Protocol#map_response` renamed to `build_response`
26
+ - Chain trackers raise `RuntimeError` instead of deleted `ChainProviderError`
27
+ - `ChainTracker` doc rewritten to explain the declarative/imperative split
28
+ - MCP tools updated: `.metadata[:status_code]` → `.code`, symbol keys → string keys
29
+
30
+ ### Fixed
31
+ - `http_success?` returns `false` (not `nil`) when `http_response` is nil
32
+ - `Ordinals#call_get_spend` rescues `TypeError` alongside `JSON::ParserError`
33
+ - ARC malformed-2xx error messages include diagnostic detail
34
+ - WoC `call_is_utxo` clears stale `error_message` when overriding 404 to success
35
+
36
+ ## 0.18.1 — 2026-05-09
37
+
38
+ ### Added
39
+ - ARC broadcast accepts hex and binary strings directly, in addition to transaction objects
40
+
41
+ ### Fixed
42
+ - Hex string detection uses content inspection rather than string encoding
43
+ - Stale "Phase B" text removed from Protocols namespace module doc
44
+
8
45
  ## 0.18.0 — 2026-05-09
9
46
 
10
47
  ### Breaking Changes
@@ -93,7 +93,7 @@ module BSV
93
93
 
94
94
  woc = BSV::Network::Providers::WhatsOnChain.default(network: net_sym)
95
95
  utxo_result = woc.call(:get_utxos_all, sender_address)
96
- return Helpers.error_response("UTXO fetch failed: #{utxo_result.message}") unless utxo_result.success?
96
+ return Helpers.error_response("UTXO fetch failed: #{utxo_result.message}") unless utxo_result.http_success?
97
97
 
98
98
  all_utxos = utxo_result.data.map do |entry|
99
99
  BSV::Network::UTXO.new(
@@ -113,11 +113,11 @@ module BSV
113
113
 
114
114
  arc = build_arc(net_sym, server_context)
115
115
  arc_result = arc.call(:broadcast, tx)
116
- return Helpers.error_response("Broadcast failed: #{arc_result.message}") unless arc_result.success?
116
+ return Helpers.error_response("Broadcast failed: #{arc_result.message}") unless arc_result.http_success?
117
117
 
118
118
  result = {
119
- txid: arc_result.data[:txid], # MCP tool boundary: display-order hex from ARC response
120
- tx_status: arc_result.data[:tx_status],
119
+ txid: arc_result.data['txid'], # MCP tool boundary: display-order hex from ARC response
120
+ tx_status: arc_result.data['txStatus'],
121
121
  hex: tx.to_hex
122
122
  }
123
123
 
@@ -62,8 +62,8 @@ module BSV
62
62
  provider = BSV::Network::Providers::WhatsOnChain.default(network: net_sym)
63
63
  utxo_result = provider.call(:get_utxos_all, address)
64
64
 
65
- unless utxo_result.success?
66
- code = utxo_result.metadata[:status_code]
65
+ unless utxo_result.http_success?
66
+ code = utxo_result.code
67
67
  msg = utxo_result.message
68
68
  msg = "#{msg} (HTTP #{code})" if code
69
69
  return Helpers.error_response(msg)
@@ -52,8 +52,8 @@ module BSV
52
52
  provider = BSV::Network::Providers::WhatsOnChain.default(network: net_sym)
53
53
  fetch_result = provider.call(:get_tx, txid)
54
54
 
55
- unless fetch_result.success?
56
- code = fetch_result.metadata[:status_code]
55
+ unless fetch_result.http_success?
56
+ code = fetch_result.code
57
57
  msg = fetch_result.message
58
58
  msg = "#{msg} (HTTP #{code})" if code
59
59
  return Helpers.error_response(msg)
@@ -53,8 +53,8 @@ module BSV
53
53
  provider = BSV::Network::Providers::WhatsOnChain.default(network: net_sym)
54
54
  utxo_result = provider.call(:get_utxos_all, address)
55
55
 
56
- unless utxo_result.success?
57
- code = utxo_result.metadata[:status_code]
56
+ unless utxo_result.http_success?
57
+ code = utxo_result.code
58
58
  msg = utxo_result.message
59
59
  msg = "#{msg} (HTTP #{code})" if code
60
60
  return Helpers.error_response(msg)
@@ -20,8 +20,8 @@ module BSV
20
20
  #
21
21
  # HTTP dispatch routes through +call+: if a +call_<name>+ escape hatch
22
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+.
23
+ # interpolates the URL template, makes the HTTP request, and wraps the
24
+ # response in a +ProtocolResponse+.
25
25
  #
26
26
  # == Example
27
27
  #
@@ -127,14 +127,14 @@ module BSV
127
127
  #
128
128
  # If a method named +call_<command_name>+ exists on the instance it is
129
129
  # used as an escape hatch — that method receives +args+ and +kwargs+
130
- # and MUST return a +Result+. Otherwise +default_call+ is invoked.
130
+ # and MUST return a +ProtocolResponse+. Otherwise +default_call+ is invoked.
131
131
  #
132
132
  # Subscriptions are not callable; calling one raises +NotImplementedError+.
133
133
  #
134
134
  # @param command_name [Symbol, String] command to invoke
135
135
  # @param args [Array] positional arguments forwarded to path interpolation
136
136
  # @param kwargs [Hash] keyword arguments forwarded to path interpolation
137
- # @return [Result::Success, Result::Error, Result::NotFound]
137
+ # @return [ProtocolResponse]
138
138
  # @raise [ArgumentError] when command_name is not registered
139
139
  def call(command_name, *args, **kwargs)
140
140
  name = command_name.to_sym
@@ -146,6 +146,7 @@ module BSV
146
146
 
147
147
  escape = :"call_#{name}"
148
148
  if respond_to?(escape, true)
149
+ BSV.logger&.debug { "[Protocol] #{self.class.name} :#{name} → escape hatch" }
149
150
  return kwargs.empty? ? send(escape, *args) : send(escape, *args, **kwargs)
150
151
  end
151
152
 
@@ -164,7 +165,7 @@ module BSV
164
165
  # @param command_name [Symbol] registered command name
165
166
  # @param args [Array] positional path parameters
166
167
  # @param kwargs [Hash] named path parameters (and optional +:body+)
167
- # @return [Result::Success, Result::Error, Result::NotFound]
168
+ # @return [ProtocolResponse]
168
169
  # @raise [ArgumentError] when command_name is not registered or a
169
170
  # required path parameter is missing
170
171
  def default_call(command_name, *args, **kwargs)
@@ -176,9 +177,12 @@ module BSV
176
177
  path = interpolate_path(defn[:path], args, kwargs)
177
178
  uri = URI("#{@base_url}#{path}")
178
179
  request = build_request(defn[:method], uri, body)
179
- response = execute(uri, request)
180
180
 
181
- map_response(response, defn[:response])
181
+ BSV.logger&.debug { "[Protocol] #{defn[:method].upcase} #{uri}" }
182
+
183
+ response = execute(uri, request)
184
+
185
+ build_response(response, defn[:response])
182
186
  end
183
187
 
184
188
  private
@@ -313,38 +317,47 @@ module BSV
313
317
  end
314
318
  end
315
319
 
316
- # Maps an HTTP response to a Result type, applying the response handler
317
- # on 2xx bodies.
320
+ # Wraps an HTTP response in a +ProtocolResponse+, applying the response
321
+ # handler on 2xx bodies.
318
322
  #
319
- # All non-2xx results carry +status_code:+ in their +metadata+ hash so
320
- # that facades can construct domain exceptions with the original HTTP code.
323
+ # On 2xx responses, the handler is applied and the result stored in +data+.
324
+ # If the handler raises +JSON::ParserError+ or +TypeError+, the response
325
+ # is marked as an error with the exception message.
326
+ #
327
+ # On non-2xx responses, the raw body is stored as +error_message+.
321
328
  #
322
329
  # @param response [Net::HTTPResponse]
323
330
  # @param handler [Symbol, #call]
324
- # @return [Result::Success, Result::Error, Result::NotFound]
325
- def map_response(response, handler)
326
- code = response.code.to_i
327
-
328
- case code
329
- when 200..299
330
- data = apply_handler(response.body, handler)
331
- return data if data.is_a?(Result::Error)
332
-
333
- Result::Success.new(data: data)
334
- when 404
335
- Result::NotFound.new(message: response.body, metadata: { status_code: code })
336
- when 429, 500..599
337
- Result::Error.new(message: response.body, retryable: true, metadata: { status_code: code })
338
- else
339
- Result::Error.new(message: response.body, retryable: false, metadata: { status_code: code })
331
+ # @return [ProtocolResponse]
332
+ def build_response(response, handler)
333
+ result = if response.is_a?(Net::HTTPSuccess)
334
+ begin
335
+ data = apply_handler(response.body, handler)
336
+ ProtocolResponse.new(response, data: data)
337
+ rescue JSON::ParserError, TypeError => e
338
+ ProtocolResponse.new(response, http_success: false,
339
+ error_message: "JSON/response error: #{e.message}")
340
+ end
341
+ else
342
+ ProtocolResponse.new(response, error_message: response.body)
343
+ end
344
+
345
+ BSV.logger&.debug do
346
+ "[Protocol] HTTP #{response.code} http_success=#{result.http_success?}" \
347
+ "#{" error=#{result.error_message[0, 80]}" if result.error_message}"
340
348
  end
349
+
350
+ result
341
351
  end
342
352
 
343
353
  # Applies the response handler to a raw body string.
344
354
  #
355
+ # Exceptions from JSON parsing or type mismatches propagate to the caller
356
+ # (+build_response+) which handles them uniformly.
357
+ #
345
358
  # @param body [String, nil]
346
359
  # @param handler [Symbol, #call]
347
- # @return [Object, Result::Error]
360
+ # @return [Object]
348
361
  def apply_handler(body, handler)
349
362
  return body if body.nil?
350
363
 
@@ -365,8 +378,6 @@ module BSV
365
378
 
366
379
  handler.call(body)
367
380
  end
368
- rescue JSON::ParserError, TypeError => e
369
- Result::Error.new(message: "JSON/response error: #{e.message}", retryable: false)
370
381
  end
371
382
  end
372
383
  end
@@ -0,0 +1,86 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'net/http'
4
+
5
+ module BSV
6
+ module Network
7
+ class ProtocolResponse
8
+ attr_reader :data, :error_message
9
+
10
+ def initialize(http_response, data: nil, http_success: nil, error_message: nil)
11
+ @http_response = http_response
12
+ @data = data
13
+ @http_success = http_success.nil? ? http_response.is_a?(Net::HTTPSuccess) : http_success
14
+ @error_message = error_message
15
+ freeze
16
+ end
17
+
18
+ # Delegated from Net::HTTPResponse (nil-safe)
19
+ def body
20
+ @http_response&.body
21
+ end
22
+
23
+ def code
24
+ @http_response&.code
25
+ end
26
+
27
+ def content_type
28
+ @http_response&.content_type
29
+ end
30
+
31
+ # Two layers of status predicates:
32
+ #
33
+ # HTTP logical status — did the operation succeed? Driven by the +http_success:+
34
+ # parameter, which defaults to the RFC 9110 success class check
35
+ # (+Net::HTTPSuccess+, i.e. 2xx) but can be overridden by escape
36
+ # hatches that reinterpret HTTP status (e.g. ARC REJECTED on 2xx
37
+ # sets http_success: false; WoC is_utxo 404 sets http_success: true).
38
+ #
39
+ # Transport status — what did the HTTP layer actually return?
40
+ # These always reflect the +Net::HTTPResponse+ class hierarchy
41
+ # regardless of the +http_success:+ override. +http_not_found?+ maps
42
+ # directly to +Net::HTTPNotFound+ (404). +retryable?+ is a
43
+ # domain composite: 429 (+Net::HTTPTooManyRequests+) or 5xx
44
+ # (+Net::HTTPServerError+) — not an RFC 9110 category itself,
45
+ # but a useful signal for retry logic.
46
+
47
+ def http_success?
48
+ @http_success
49
+ end
50
+
51
+ def http_not_found?
52
+ @http_response.is_a?(Net::HTTPNotFound)
53
+ end
54
+
55
+ def retryable?
56
+ @http_response.is_a?(Net::HTTPTooManyRequests) ||
57
+ @http_response.is_a?(Net::HTTPServerError)
58
+ end
59
+
60
+ # Canonical form (placeholder — delegates to data until shapes are defined)
61
+ def canonical
62
+ data
63
+ end
64
+
65
+ # Compatibility: chain trackers + MCP tools call .message
66
+ alias message error_message
67
+
68
+ # Derive new response with overrides (same HTTP response, different interpretation)
69
+ def with(**overrides)
70
+ derived = self.class.new(
71
+ @http_response,
72
+ data: overrides.fetch(:data, @data),
73
+ http_success: overrides.fetch(:http_success, @http_success),
74
+ error_message: overrides.fetch(:error_message, @error_message)
75
+ )
76
+
77
+ BSV.logger&.debug do
78
+ changes = overrides.keys.map { |k| "#{k}=#{overrides[k].inspect[0, 40]}" }.join(' ')
79
+ "[ProtocolResponse] with(#{changes})"
80
+ end
81
+
82
+ derived
83
+ end
84
+ end
85
+ end
86
+ end
@@ -11,9 +11,7 @@ module BSV
11
11
  # Extends Protocol with five endpoints and two escape hatches for broadcast
12
12
  # logic: EF format preference, rejection detection, and custom headers.
13
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.
14
+ # The protocol returns ProtocolResponse objects (never raises).
17
15
  #
18
16
  # == Example
19
17
  #
@@ -22,8 +20,8 @@ module BSV
22
20
  # api_key: 'my-api-key'
23
21
  # )
24
22
  # result = arc.call(:broadcast, tx)
25
- # result.success? # => true
26
- # result.data[:txid] # => "abc123..."
23
+ # result.http_success? # => true
24
+ # result.data['txid'] # => "abc123..."
27
25
  #
28
26
  # @see https://docs.gorillapool.io/arc/api.html ARC API v1 documentation
29
27
  class ARC < Protocol
@@ -68,7 +66,8 @@ module BSV
68
66
  # Broadcast escape hatch: EF format preference, custom headers, rejection
69
67
  # detection, and malformed 2xx detection.
70
68
  #
71
- # @param tx [Transaction] the transaction to broadcast
69
+ # @param tx [#to_ef_hex, #to_hex, String] transaction object, hex string,
70
+ # or binary string
72
71
  # @param wait_for [String, nil] ARC wait condition
73
72
  # @param skip_fee_validation [Boolean, nil]
74
73
  # @param skip_script_validation [Boolean, nil]
@@ -76,11 +75,11 @@ module BSV
76
75
  # @param callback_url [String, nil] per-call callback URL override
77
76
  # @param callback_token [String, nil] per-call callback token override
78
77
  # @param callback_batch [Boolean, nil] when truthy, sends X-CallbackBatch header
79
- # @return [Result::Success, Result::Error]
78
+ # @return [ProtocolResponse]
80
79
  def call_broadcast(tx, wait_for: nil, skip_fee_validation: nil,
81
80
  skip_script_validation: nil, skip_tx_validation: nil,
82
81
  callback_url: nil, callback_token: nil, callback_batch: nil, **)
83
- hex = ef_hex_with_fallback(tx)
82
+ hex = resolve_tx_hex(tx)
84
83
  body = JSON.generate(rawTx: hex)
85
84
 
86
85
  extra_headers = build_broadcast_headers(
@@ -97,9 +96,9 @@ module BSV
97
96
  parse_single_broadcast_response(response)
98
97
  end
99
98
 
100
- # Broadcast-many escape hatch: batch broadcast with per-item rejection detection.
99
+ # Broadcast-many escape hatch: batch broadcast with raw JSON array result.
101
100
  #
102
- # @param txs [Array<Transaction>]
101
+ # @param txs [Array<#to_ef_hex, #to_hex, String>]
103
102
  # @param wait_for [String, nil]
104
103
  # @param skip_fee_validation [Boolean, nil]
105
104
  # @param skip_script_validation [Boolean, nil]
@@ -107,13 +106,13 @@ module BSV
107
106
  # @param callback_url [String, nil]
108
107
  # @param callback_token [String, nil]
109
108
  # @param callback_batch [Boolean, nil]
110
- # @return [Result::Success, Result::Error]
109
+ # @return [ProtocolResponse]
111
110
  def call_broadcast_many(txs, wait_for: nil, skip_fee_validation: nil,
112
111
  skip_script_validation: nil, skip_tx_validation: nil,
113
112
  callback_url: nil, callback_token: nil, callback_batch: nil, **)
114
- return Result::Success.new(data: []) if txs.empty?
113
+ return ProtocolResponse.new(nil, data: [], http_success: true) if txs.empty?
115
114
 
116
- body = JSON.generate(txs.map { |tx| { rawTx: ef_hex_with_fallback(tx) } })
115
+ body = JSON.generate(txs.map { |tx| { rawTx: resolve_tx_hex(tx) } })
117
116
 
118
117
  extra_headers = build_broadcast_headers(
119
118
  wait_for: wait_for,
@@ -136,10 +135,26 @@ module BSV
136
135
  request
137
136
  end
138
137
 
139
- # Prefer Extended Format hex (BRC-30) so ARC can validate sighashes without
140
- # fetching parent transactions. Falls back to plain raw-tx hex when any input
141
- # lacks source_satoshis / source_locking_script.
142
- def ef_hex_with_fallback(tx)
138
+ # Coerce a transaction input to hex for the ARC JSON body.
139
+ #
140
+ # Accepts (in order of preference):
141
+ # 1. Hex string — pass-through, zero conversion
142
+ # 2. Binary string — convert to hex
143
+ # 3. Transaction object — prefer EF hex (BRC-30), fall back to raw hex
144
+ #
145
+ # Detection uses content, not encoding: a string is hex if it has even
146
+ # length and contains only hex characters. This handles hex strings
147
+ # tagged as ASCII-8BIT (e.g. read from IO in binary mode).
148
+ #
149
+ # @param tx [String, #to_ef_hex, #to_hex] transaction in any supported form
150
+ # @return [String] hex-encoded transaction
151
+ def resolve_tx_hex(tx)
152
+ if tx.is_a?(String)
153
+ return tx if tx.match?(/\A[0-9a-fA-F]*\z/) && tx.length.even?
154
+
155
+ return tx.unpack1('H*')
156
+ end
157
+
143
158
  tx.to_ef_hex
144
159
  rescue ArgumentError => e
145
160
  BSV.logger&.debug { "[ARC] EF serialisation failed: #{e.message} — falling back to raw hex" }
@@ -171,159 +186,98 @@ module BSV
171
186
  end
172
187
 
173
188
  # Parse and validate a single-transaction ARC response.
189
+ #
190
+ # @return [ProtocolResponse]
174
191
  def parse_single_broadcast_response(response)
175
192
  code = response.code.to_i
176
193
  body = safe_parse_json(response.body)
177
194
 
178
195
  unless body.is_a?(Hash)
179
- return Result::Error.new(
180
- message: "HTTP #{code}",
181
- retryable: retryable_code?(code),
182
- metadata: { status_code: code }
183
- )
196
+ return ProtocolResponse.new(response, http_success: false,
197
+ error_message: "ARC returned #{body.class}, expected Hash")
184
198
  end
185
199
 
186
200
  unless (200..299).cover?(code)
187
- return Result::Error.new(
188
- message: body['detail'] || body['title'] || "HTTP #{code}",
189
- retryable: retryable_code?(code),
190
- metadata: { status_code: code, arc_status: body['txStatus'].to_s.upcase, txid: body['txid'] }
201
+ return ProtocolResponse.new(
202
+ response,
203
+ http_success: false,
204
+ error_message: body['detail'] || body['title'] || "HTTP #{code}"
191
205
  )
192
206
  end
193
207
 
194
208
  if rejected_status?(body)
195
- return Result::Error.new(
196
- message: body['detail'] || body['title'] || body['txStatus'],
197
- retryable: false,
198
- metadata: { status_code: code, arc_status: body['txStatus'].to_s.upcase, txid: body['txid'] }
209
+ return ProtocolResponse.new(
210
+ response,
211
+ http_success: false,
212
+ error_message: body['detail'] || body['title'] || body['txStatus'],
213
+ data: body
199
214
  )
200
215
  end
201
216
 
202
217
  unless body['txid']
203
- return Result::Error.new(
204
- message: 'ARC returned a malformed 2xx response',
205
- retryable: false,
206
- metadata: { status_code: code }
218
+ return ProtocolResponse.new(
219
+ response,
220
+ http_success: false,
221
+ error_message: body['detail'] || 'ARC returned a malformed 2xx response'
207
222
  )
208
223
  end
209
224
 
210
- Result::Success.new(
211
- data: arc_data_from(body),
212
- metadata: { arc_status: body['txStatus'].to_s.upcase }
213
- )
225
+ ProtocolResponse.new(response, data: body)
214
226
  end
215
227
 
216
228
  # Parse and validate a batch ARC response. HTTP-level errors return a
217
- # single Result::Error; per-item rejections are embedded in the data array.
229
+ # single error ProtocolResponse; success data is a raw JSON array of hashes.
230
+ #
231
+ # @return [ProtocolResponse]
218
232
  def parse_batch_broadcast_response(response)
219
233
  code = response.code.to_i
220
234
  body = safe_parse_json(response.body)
221
235
 
222
236
  unless (200..299).cover?(code)
223
- return Result::Error.new(
224
- message: body.is_a?(Hash) ? (body['detail'] || body['title'] || "HTTP #{code}") : "HTTP #{code}",
225
- retryable: retryable_code?(code),
226
- metadata: { status_code: code }
237
+ return ProtocolResponse.new(
238
+ response,
239
+ http_success: false,
240
+ error_message: body.is_a?(Hash) ? (body['detail'] || body['title'] || "HTTP #{code}") : "HTTP #{code}"
227
241
  )
228
242
  end
229
243
 
230
244
  unless body.is_a?(Array)
231
- return Result::Error.new(
232
- message: 'ARC returned a malformed batch response',
233
- retryable: false,
234
- metadata: { status_code: code }
245
+ return ProtocolResponse.new(
246
+ response,
247
+ http_success: false,
248
+ error_message: 'ARC returned a malformed batch response'
235
249
  )
236
250
  end
237
251
 
238
- items = body.map { |item| build_item_result(item) }
239
- Result::Success.new(data: items, metadata: {})
252
+ ProtocolResponse.new(response, data: body)
240
253
  end
241
254
 
242
- # Build a per-item result for a batch response entry.
243
- def build_item_result(item)
244
- unless item.is_a?(Hash)
245
- return Result::Error.new(
246
- message: 'malformed batch item',
247
- retryable: false,
248
- metadata: {}
249
- )
250
- end
251
-
252
- if rejected_status?(item)
253
- Result::Error.new(
254
- message: item['detail'] || item['title'] || item['txStatus'],
255
- retryable: false,
256
- metadata: { status_code: 200, arc_status: item['txStatus'].to_s.upcase, txid: item['txid'] }
257
- )
258
- elsif !item['txid']
259
- Result::Error.new(
260
- message: 'ARC returned a malformed 2xx response',
261
- retryable: false,
262
- metadata: { status_code: 200 }
263
- )
264
- else
265
- Result::Success.new(
266
- data: arc_data_from(item),
267
- metadata: { arc_status: item['txStatus'].to_s.upcase }
268
- )
269
- end
270
- end
271
-
272
- # Escape hatch for get_tx_status: returns a normalised data hash using the
273
- # same field set as broadcast responses rather than the raw parsed JSON.
274
- # Also checks for rejection status and missing txid (malformed 2xx).
255
+ # Escape hatch for get_tx_status: checks for rejection status and missing
256
+ # txid (malformed 2xx). Returns raw JSON data (string keys).
275
257
  #
276
258
  # @param txid [String] ARC API boundary: display-order hex transaction ID to query
277
- # @return [Result::Success, Result::Error, Result::NotFound]
259
+ # @return [ProtocolResponse]
278
260
  def call_get_tx_status(txid, **)
279
261
  response = default_call(:get_tx_status, txid)
280
- return response unless response.is_a?(Result::Success)
262
+ return response unless response.http_success?
281
263
 
282
264
  body = response.data
283
265
 
284
266
  if rejected_status?(body)
285
- return Result::Error.new(
286
- message: body['detail'] || body['title'] || body['txStatus'],
287
- retryable: false,
288
- metadata: { arc_status: body['txStatus'].to_s.upcase, txid: body['txid'], status_code: 200 }
267
+ return response.with(
268
+ http_success: false,
269
+ error_message: body['detail'] || body['title'] || body['txStatus']
289
270
  )
290
271
  end
291
272
 
292
273
  unless body['txid']
293
- return Result::Error.new(
294
- message: 'ARC returned a malformed 2xx response',
295
- retryable: false,
296
- metadata: { status_code: 200 }
274
+ return response.with(
275
+ http_success: false,
276
+ error_message: 'ARC returned a malformed 2xx response'
297
277
  )
298
278
  end
299
279
 
300
- Result::Success.new(
301
- data: arc_data_from(body),
302
- metadata: { arc_status: body['txStatus'].to_s.upcase }
303
- )
304
- end
305
-
306
- # Build the normalised ARC data hash from a parsed JSON response body.
307
- # Includes all 8 fields that BroadcastResponse expects.
308
- #
309
- # @param body [Hash] parsed ARC JSON response
310
- # @return [Hash]
311
- def arc_data_from(body)
312
- {
313
- txid: body['txid'], # ARC API boundary: display-order hex from the ARC JSON response
314
- tx_status: body['txStatus'],
315
- message: body['title'],
316
- extra_info: body['extraInfo'],
317
- block_hash: body['blockHash'],
318
- block_height: body['blockHeight'],
319
- timestamp: body['timestamp'],
320
- competing_txs: body['competingTxs']
321
- }
322
- end
323
-
324
- # Determine whether a status code indicates a retryable failure.
325
- def retryable_code?(code)
326
- code == 429 || (500..599).cover?(code)
280
+ response
327
281
  end
328
282
 
329
283
  # Determine whether an ARC response body represents a rejected transaction.