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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +49 -0
- data/lib/bsv/auth/certificate.rb +4 -4
- data/lib/bsv/auth/verifiable_certificate.rb +1 -1
- data/lib/bsv/network/arc.rb +95 -224
- data/lib/bsv/network/protocol.rb +321 -0
- data/lib/bsv/network/protocols/arc.rb +351 -0
- data/lib/bsv/network/protocols/chaintracks.rb +39 -0
- data/lib/bsv/network/protocols/ordinals.rb +32 -0
- data/lib/bsv/network/protocols/taal_binary.rb +99 -0
- data/lib/bsv/network/protocols/woc_rest.rb +301 -0
- data/lib/bsv/network/protocols.rb +17 -0
- data/lib/bsv/network/provider.rb +123 -0
- data/lib/bsv/network/providers/gorilla_pool.rb +61 -0
- data/lib/bsv/network/providers/taal.rb +57 -0
- data/lib/bsv/network/providers/whats_on_chain.rb +72 -0
- data/lib/bsv/network/providers.rb +25 -0
- data/lib/bsv/network/result.rb +119 -0
- data/lib/bsv/network/whats_on_chain.rb +78 -40
- data/lib/bsv/network.rb +5 -0
- data/lib/bsv/overlay/admin_token_template.rb +2 -2
- data/lib/bsv/script/push_drop_template.rb +1 -1
- data/lib/bsv/transaction/chain_trackers/chaintracks.rb +45 -49
- data/lib/bsv/transaction/chain_trackers/whats_on_chain.rb +57 -50
- data/lib/bsv/transaction/chain_trackers.rb +3 -4
- data/lib/bsv/transaction/fee_models/live_policy.rb +3 -2
- data/lib/bsv/transaction/transaction.rb +52 -7
- data/lib/bsv/transaction/verification_error.rb +11 -0
- data/lib/bsv/version.rb +1 -1
- data/lib/bsv-sdk.rb +1 -5
- metadata +14 -5
- data/lib/bsv/messages.rb +0 -16
- data/lib/bsv/wallet/insufficient_funds_error.rb +0 -15
- data/lib/bsv/wallet/wallet.rb +0 -120
- 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
|