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