bsv-sdk 0.16.0 → 0.18.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +53 -0
  3. data/lib/bsv/auth/certificate.rb +6 -2
  4. data/lib/bsv/auth/get_verifiable_certificates.rb +6 -6
  5. data/lib/bsv/auth/peer.rb +10 -4
  6. data/lib/bsv/auth/session_manager.rb +81 -5
  7. data/lib/bsv/identity/client.rb +5 -2
  8. data/lib/bsv/mcp/tools/broadcast_p2pkh.rb +4 -4
  9. data/lib/bsv/mcp/tools/check_balance.rb +2 -2
  10. data/lib/bsv/mcp/tools/fetch_utxos.rb +2 -2
  11. data/lib/bsv/mcp/tools/helpers.rb +2 -2
  12. data/lib/bsv/network/broadcast_error.rb +2 -0
  13. data/lib/bsv/network/broadcast_response.rb +4 -1
  14. data/lib/bsv/network/protocol.rb +56 -4
  15. data/lib/bsv/network/protocols/arc.rb +10 -6
  16. data/lib/bsv/network/protocols/chaintracks.rb +6 -2
  17. data/lib/bsv/network/protocols/jungle_bus.rb +52 -0
  18. data/lib/bsv/network/protocols/ordinals.rb +110 -8
  19. data/lib/bsv/network/protocols/taal_binary.rb +18 -4
  20. data/lib/bsv/network/protocols/woc_rest.rb +166 -85
  21. data/lib/bsv/network/protocols.rb +1 -0
  22. data/lib/bsv/network/provider.rb +36 -5
  23. data/lib/bsv/network/providers/gorilla_pool.rb +42 -20
  24. data/lib/bsv/network/providers/taal.rb +38 -15
  25. data/lib/bsv/network/providers/whats_on_chain.rb +42 -21
  26. data/lib/bsv/network/utxo.rb +8 -2
  27. data/lib/bsv/overlay/lookup_resolver.rb +5 -4
  28. data/lib/bsv/overlay/topic_broadcaster.rb +2 -2
  29. data/lib/bsv/overlay/types.rb +2 -0
  30. data/lib/bsv/primitives/hex.rb +64 -0
  31. data/lib/bsv/registry/client.rb +10 -8
  32. data/lib/bsv/registry/types.rb +2 -0
  33. data/lib/bsv/script/interpreter/interpreter.rb +7 -0
  34. data/lib/bsv/script/interpreter/operations/crypto.rb +7 -1
  35. data/lib/bsv/transaction/beef.rb +223 -147
  36. data/lib/bsv/transaction/merkle_path.rb +54 -38
  37. data/lib/bsv/transaction/transaction.rb +103 -40
  38. data/lib/bsv/transaction/transaction_input.rb +23 -18
  39. data/lib/bsv/version.rb +1 -1
  40. data/lib/bsv/wallet/interface/brc100.rb +5 -2
  41. data/lib/bsv/wallet/proto_wallet/key_deriver.rb +2 -0
  42. data/lib/bsv/wallet/proto_wallet.rb +6 -0
  43. data/lib/bsv/wire_format.rb +40 -14
  44. data/lib/bsv-sdk.rb +14 -0
  45. metadata +4 -3
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: a76170f9d5dcdfc76945c20c541b556a02f3fb5a7214a8d6f777eb589f048461
4
- data.tar.gz: 57176276d41d430d654682dd22aef0f93bee9e2e3c57d4fb76d435dbd8c3e705
3
+ metadata.gz: 6bb4b97822f38d793a308e9beddc94bd90e64457bda8f1782875ddcd8a1ff1f3
4
+ data.tar.gz: 85009a67a04ee1264acd883224ea418ed46558629a18db3d1757486cc544852d
5
5
  SHA512:
6
- metadata.gz: fb7257bda0b14c14ab55d8da3a19fdc27a8eca113cfeb857487079d4e0f042ebbaef184e81382d50aed7a9efd69dc4d2cb2d77594b6e1ae5f3f6d047c01d2ab9
7
- data.tar.gz: 20f9e8fe53476ddd87900d1b58d00811da403703d5ae8cf6dc0f6d4a4836ab8a12e42ddce88c7323bd281e710770f4b7b6805cb842ab4f164c4b17559afa46e7
6
+ metadata.gz: 85e2958f6bb6af774b2ca06e5187bbc1c756546e322430375108617b4675e6e5d5373aa8d93fd4504327d3c48e29507a2caea566d0b9af5e16c3fdd089bc2fbc
7
+ data.tar.gz: 56797e445e8996c2a41e85484e49f53b3568eaebeaf01de39a1e919c6949f40b172236ad5bd89fbc77eb9405a532653c9ab1972e696e699d6410e503318360d7
data/CHANGELOG.md CHANGED
@@ -5,6 +5,59 @@ 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.18.0 — 2026-05-09
9
+
10
+ ### Breaking Changes
11
+ - Eliminated display-order binary txid from public API — all methods now use `wtxid` (wire-order) internally (#690)
12
+
13
+ ### Added
14
+ - Provider auth and rate limit metadata: `auth:`, `rate_limit:`, `authenticated?` on Provider; generic auth dispatch in Protocol `build_request` (#718)
15
+ - JungleBus protocol: transaction lookup, address queries, block headers via GorillaPool JungleBus REST API (#717)
16
+ - Ordinals protocol: fixed endpoint paths, added tx details, tx status, UTXOs, balance, spends, chain tip (#727)
17
+ - Full WoC API coverage: expanded from 30 to 54 endpoints with complete address, script, UTXO, and analytics support (#715)
18
+ - Integration tests for ARC, JungleBus, Ordinals, and WoCREST against live APIs (#726)
19
+ - API documentation URLs in all protocol file headers (#730)
20
+
21
+ ### Fixed
22
+ - Ordinals `get_tx` path corrected (`/hex` → `/raw`) and response handler fixed (binary, not JSON) (#727)
23
+ - Auth normalisation: `{ bearer: nil }` and `{ api_key: nil }` treated as unauthenticated (#725)
24
+ - WoC health endpoint path corrected (`/health` → `/woc`)
25
+ - Bulk POST body formats and `get_utxos_all` path corrected for WoC
26
+ - Lazy removal of expired sessions during identity-key lookup (#707)
27
+ - Session TTL expiration added to SessionManager (#707)
28
+ - Guard against nil source data and out-of-bounds input_index in sighash (#702)
29
+ - Wire format deep conversion depth limit (#709)
30
+
31
+ ### Changed
32
+ - BeefTx refactored to polymorphic subclass hierarchy with validated constructors (#692-698)
33
+ - WoCREST `build_request` override removed — replaced by generic auth dispatch
34
+ - MCP dependency bumped from ~> 0.12 to ~> 0.15 (#700)
35
+
36
+ ## 0.17.0 — 2026-05-02
37
+
38
+ ### Breaking Changes
39
+ - Renamed `TransactionInput#prev_tx_id` to `#prev_wtxid` — all call sites must update (#678)
40
+ - Renamed MerklePath parameters from `txid_hex` to `dtxid_hex` throughout (#681)
41
+
42
+ ### Added
43
+ - `Transaction#wtxid` — wire-order transaction ID (raw SHA-256d bytes) (#673)
44
+ - `Transaction#dtxid` / `#dtxid_hex` — display-order hex aliases (#680)
45
+ - `TransactionInput#dtxid_hex` — display-order hex of the referenced transaction (#678)
46
+ - `TransactionInput.wtxid_from_hex` — convert display-order hex to wire-order bytes (#678)
47
+ - `Hex.validate_wtxid!` / `Hex.validate_dtxid_hex!` — runtime validation for wire/display txid formats (#685)
48
+ - `Hex.validate_hash32!` — general-purpose 32-byte binary hash validator (#686)
49
+ - `BSV.logger` — opt-in debug instrumentation with zero overhead when unused (#686)
50
+ - Debug logging at txid conversions, sighash preimage, script interpreter, ARC broadcast, BEEF ancestry, and ProtoWallet key derivation (#686, #688)
51
+
52
+ ### Changed
53
+ - All internal txid variables renamed to `wtxid` (wire-order) convention (#679)
54
+ - BEEF internals enforce wire-order throughout — no byte-reversals inside the bundle (#675)
55
+ - MerklePath `compute_root_hex` and `from_tsc` now validate hex input (#686)
56
+
57
+ ### Fixed
58
+ - `BeefTx#dtxid` now returns hex string (was returning binary) (#677)
59
+ - Certificate field naming aligned with BRC-52 convention (#677)
60
+
8
61
  ## 0.16.0 — 2026-05-01
9
62
 
10
63
  ### Breaking Changes
@@ -81,6 +81,9 @@ module BSV
81
81
  buf << [@subject].pack('H*')
82
82
  buf << [@certifier].pack('H*')
83
83
 
84
+ # Certificate binary format: revocation outpoint txid stored in display byte
85
+ # order (matching TS and Go SDKs). Go encodes via WriteBytesReverse(wire_order),
86
+ # TS encodes via toArray(display_hex) — both produce identical display-order bytes.
84
87
  txid_hex, output_index_str = @revocation_outpoint.to_s.split('.', 2)
85
88
  buf << [txid_hex].pack('H*')
86
89
  buf << BSV::Transaction::VarInt.encode(output_index_str.to_i)
@@ -124,7 +127,8 @@ module BSV
124
127
  certifier_bytes = data.byteslice(pos, 33)
125
128
  pos += 33
126
129
 
127
- txid_bytes = data.byteslice(pos, 32)
130
+ # Outpoint txid bytes — stored in display byte order (matching TS and Go SDKs).
131
+ outpoint_txid = data.byteslice(pos, 32)
128
132
  pos += 32
129
133
  output_index, vi_len = BSV::Transaction::VarInt.decode(data, pos)
130
134
  pos += vi_len
@@ -158,7 +162,7 @@ module BSV
158
162
  serial_number: Base64.strict_encode64(serial_bytes),
159
163
  subject: subject_bytes.unpack1('H*'),
160
164
  certifier: certifier_bytes.unpack1('H*'),
161
- revocation_outpoint: "#{txid_bytes.unpack1('H*')}.#{output_index}",
165
+ revocation_outpoint: "#{outpoint_txid.unpack1('H*')}.#{output_index}",
162
166
  fields: fields,
163
167
  signature: signature
164
168
  )
@@ -21,7 +21,9 @@ module BSV
21
21
  # - +:types+ [Hash] type (Base64 string) → array of field names to reveal
22
22
  # @param verifier_identity_key [String] the verifier's compressed public key hex
23
23
  # @return [Array<VerifiableCertificate>] list of verifiable certificates ready for
24
- # presentation, or +[]+ on any failure
24
+ # presentation, or +[]+ when the wallet does not support certificate operations
25
+ # @raise [StandardError] propagates unexpected errors (network failures, key derivation
26
+ # errors, etc.) to the caller — only +UnsupportedActionError+ is swallowed
25
27
  def get_verifiable_certificates(wallet, requested_certificates, verifier_identity_key)
26
28
  return [] unless wallet.respond_to?(:list_certificates) && wallet.respond_to?(:prove_certificate)
27
29
 
@@ -65,11 +67,9 @@ module BSV
65
67
  signature: cert[:signature] || cert['signature']
66
68
  )
67
69
  end
68
- rescue StandardError
69
- # Auto-fetch is best-effort: wallet may raise UnsupportedActionError,
70
- # key derivation errors, or other failures. The peer protocol handles
71
- # "no certificates" gracefully — the requesting peer enforces its own
72
- # certificate requirements independently.
70
+ rescue BSV::Wallet::UnsupportedActionError
71
+ # Wallet does not implement certificate operations (e.g. ProtoWallet).
72
+ # Return empty the requesting peer enforces its own requirements independently.
73
73
  []
74
74
  end
75
75
  end
data/lib/bsv/auth/peer.rb CHANGED
@@ -488,7 +488,7 @@ module BSV
488
488
  signature = fetch!(message, :signature)
489
489
 
490
490
  # Verify the echoed nonce is one we created
491
- raise AuthError, "Nonce verification failed from peer: #{peer_key}" unless Nonce.verify(our_nonce, @wallet)
491
+ verify_nonce!(our_nonce, context: "initial response from peer: #{peer_key}")
492
492
 
493
493
  session = @session_manager.get_session(our_nonce)
494
494
  raise AuthError, "No pending session for nonce: #{our_nonce.inspect}" unless session
@@ -566,7 +566,7 @@ module BSV
566
566
  msg_nonce = fetch!(message, :nonce)
567
567
 
568
568
  # Verify the echoed nonce is one we created
569
- raise AuthError, "Unable to verify nonce for general message from: #{peer_key}" unless Nonce.verify(our_nonce, @wallet)
569
+ verify_nonce!(our_nonce, context: "general message from: #{peer_key}")
570
570
 
571
571
  session = @session_manager.get_session(our_nonce)
572
572
  raise AuthError, "Session not found for nonce: #{our_nonce.inspect}" unless session
@@ -602,7 +602,7 @@ module BSV
602
602
  requested = message[:requested_certificates] || message['requested_certificates']
603
603
  signature = fetch!(message, :signature)
604
604
 
605
- raise AuthError, "Unable to verify nonce for certificate request message from: #{peer_key}" unless Nonce.verify(our_nonce, @wallet)
605
+ verify_nonce!(our_nonce, context: "certificate request from: #{peer_key}")
606
606
 
607
607
  session = @session_manager.get_session(our_nonce)
608
608
  raise AuthError, "Session not found for nonce: #{our_nonce.inspect}" unless session
@@ -647,7 +647,7 @@ module BSV
647
647
  certs = message[:certificates] || message['certificates'] || []
648
648
  signature = fetch!(message, :signature)
649
649
 
650
- raise AuthError, "Unable to verify nonce for certificate response from: #{peer_key}" unless Nonce.verify(our_nonce, @wallet)
650
+ verify_nonce!(our_nonce, context: "certificate response from: #{peer_key}")
651
651
 
652
652
  session = @session_manager.get_session(our_nonce)
653
653
  raise AuthError, "Session not found for nonce: #{our_nonce.inspect}" unless session
@@ -773,6 +773,12 @@ module BSV
773
773
  certs.map { |c| c.respond_to?(:to_h) ? c.to_h : c }
774
774
  end
775
775
 
776
+ def verify_nonce!(nonce, context:)
777
+ return if Nonce.verify(nonce, @wallet)
778
+
779
+ raise AuthError, "Nonce verification failed for #{context}"
780
+ end
781
+
776
782
  def current_time_ms
777
783
  (Time.now.to_f * 1000).to_i
778
784
  end
@@ -9,9 +9,16 @@ module BSV
9
9
  # peer identity key are supported — the most recently updated session
10
10
  # is returned when looking up by identity key.
11
11
  #
12
+ # Sessions expire after +default_ttl+ seconds (default: 3600). Pass
13
+ # +default_ttl: nil+ to disable expiry entirely. Expired sessions are
14
+ # removed lazily on access; call {#sweep_expired} for proactive cleanup.
15
+ #
12
16
  # Matches the ts-sdk SessionManager dual-index design.
13
17
  class SessionManager
14
- def initialize
18
+ # @param default_ttl [Integer, nil] seconds before a session expires;
19
+ # +nil+ disables TTL entirely.
20
+ def initialize(default_ttl: 3600)
21
+ @default_ttl = default_ttl
15
22
  # session_nonce -> PeerSession
16
23
  @by_nonce = {}
17
24
  # peer_identity_key -> Set of session_nonces
@@ -50,25 +57,40 @@ module BSV
50
57
  #
51
58
  # When the identifier is a session nonce, returns that exact session.
52
59
  # When the identifier is a peer identity key, returns the most recently
53
- # updated session for that peer.
60
+ # updated non-expired session for that peer.
61
+ #
62
+ # Returns +nil+ if the session has expired (and removes it from the store).
54
63
  #
55
64
  # @param identifier [String]
56
65
  # @return [PeerSession, nil]
57
66
  def get_session(identifier)
58
67
  @mutex.synchronize do
59
68
  direct = @by_nonce[identifier]
60
- return direct if direct
69
+ if direct
70
+ if expired_locked?(direct)
71
+ remove_session_locked(direct)
72
+ return nil
73
+ end
74
+ return direct
75
+ end
61
76
 
62
77
  nonces = @by_identity[identifier]
63
78
  return nil if nonces.nil? || nonces.empty?
64
79
 
65
80
  best = nil
81
+ expired_nonces = []
66
82
  nonces.each do |nonce|
67
83
  s = @by_nonce[nonce]
68
84
  next if s.nil?
69
85
 
86
+ if expired_locked?(s)
87
+ expired_nonces << s
88
+ next
89
+ end
90
+
70
91
  best = s if best.nil? || (s.last_update || 0) > (best.last_update || 0)
71
92
  end
93
+ expired_nonces.each { |s| remove_session_locked(s) }
72
94
  best
73
95
  end
74
96
  end
@@ -84,13 +106,53 @@ module BSV
84
106
  # @return [Boolean]
85
107
  def session?(identifier)
86
108
  @mutex.synchronize do
87
- return true if @by_nonce.key?(identifier)
109
+ direct = @by_nonce[identifier]
110
+ if direct
111
+ if expired_locked?(direct)
112
+ remove_session_locked(direct)
113
+ return false
114
+ end
115
+ return true
116
+ end
88
117
 
89
118
  nonces = @by_identity[identifier]
90
- !nonces.nil? && !nonces.empty?
119
+ return false if nonces.nil? || nonces.empty?
120
+
121
+ has_active = false
122
+ expired_sessions = []
123
+ nonces.each do |nonce|
124
+ s = @by_nonce[nonce]
125
+ next if s.nil?
126
+
127
+ if expired_locked?(s)
128
+ expired_sessions << s
129
+ else
130
+ has_active = true
131
+ end
132
+ end
133
+ expired_sessions.each { |s| remove_session_locked(s) }
134
+ has_active
91
135
  end
92
136
  end
93
137
 
138
+ # Removes all expired sessions from the store.
139
+ #
140
+ # Not called automatically — intended for use in long-running servers
141
+ # that want to proactively reclaim memory.
142
+ #
143
+ # @return [Integer] number of sessions removed
144
+ def sweep_expired
145
+ removed = 0
146
+ @mutex.synchronize do
147
+ expired = @by_nonce.values.select { |s| expired_locked?(s) }
148
+ expired.each do |s|
149
+ remove_session_locked(s)
150
+ removed += 1
151
+ end
152
+ end
153
+ removed
154
+ end
155
+
94
156
  private
95
157
 
96
158
  def add_session_locked(session)
@@ -115,6 +177,20 @@ module BSV
115
177
  nonces.delete(session.session_nonce)
116
178
  @by_identity.delete(session.peer_identity_key) if nonces.empty?
117
179
  end
180
+
181
+ # Must be called within @mutex.
182
+ def expired_locked?(session)
183
+ return false if @default_ttl.nil?
184
+
185
+ last = session.last_update
186
+ return true if last.nil? || last.zero?
187
+
188
+ current_time_ms - last > @default_ttl * 1000
189
+ end
190
+
191
+ def current_time_ms
192
+ (Time.now.to_f * 1000).to_i
193
+ end
118
194
  end
119
195
  end
120
196
  end
@@ -203,10 +203,13 @@ module BSV
203
203
  raise 'Revoke failed: invalid outputIndex from overlay' if output_idx.negative?
204
204
 
205
205
  beef = BSV::Transaction::Beef.from_binary(beef_bytes)
206
- tx = beef.transactions.last&.transaction
207
- raise 'Revoke failed: no transaction found in BEEF' unless tx
206
+ beef_tx = beef.transactions.last
207
+ raise 'Revoke failed: no transaction found in BEEF' if beef_tx.nil? || beef_tx.is_a?(BSV::Transaction::Beef::TxidOnlyEntry)
208
+
209
+ tx = beef_tx.transaction
208
210
  raise 'Revoke failed: outputIndex out of range' if output_idx >= tx.outputs.length
209
211
 
212
+ # Overlay API boundary: outpoint uses display-order hex txid as per overlay convention
210
213
  txid = tx.txid_hex
211
214
  outpoint = "#{txid}.#{output_idx}"
212
215
 
@@ -97,8 +97,8 @@ module BSV
97
97
 
98
98
  all_utxos = utxo_result.data.map do |entry|
99
99
  BSV::Network::UTXO.new(
100
- tx_hash: entry[:tx_hash], tx_pos: entry[:tx_pos],
101
- satoshis: entry[:satoshis], height: entry[:height]
100
+ tx_hash: entry['tx_hash'], tx_pos: entry['tx_pos'],
101
+ value: entry['value'], height: entry['height']
102
102
  )
103
103
  end
104
104
 
@@ -116,7 +116,7 @@ module BSV
116
116
  return Helpers.error_response("Broadcast failed: #{arc_result.message}") unless arc_result.success?
117
117
 
118
118
  result = {
119
- txid: arc_result.data[:txid],
119
+ txid: arc_result.data[:txid], # MCP tool boundary: display-order hex from ARC response
120
120
  tx_status: arc_result.data[:tx_status],
121
121
  hex: tx.to_hex
122
122
  }
@@ -154,7 +154,7 @@ module BSV
154
154
  selected_utxos.each do |utxo|
155
155
  locking_script = p2pkh_lock_for(sender_address)
156
156
  input = BSV::Transaction::TransactionInput.new(
157
- prev_tx_id: BSV::Transaction::TransactionInput.txid_from_hex(utxo.tx_hash),
157
+ prev_wtxid: BSV::Transaction::TransactionInput.wtxid_from_hex(utxo.tx_hash),
158
158
  prev_tx_out_index: utxo.tx_pos
159
159
  )
160
160
  input.source_satoshis = utxo.satoshis
@@ -71,8 +71,8 @@ module BSV
71
71
 
72
72
  utxos = utxo_result.data.map do |entry|
73
73
  BSV::Network::UTXO.new(
74
- tx_hash: entry[:tx_hash], tx_pos: entry[:tx_pos],
75
- satoshis: entry[:satoshis], height: entry[:height]
74
+ tx_hash: entry['tx_hash'], tx_pos: entry['tx_pos'],
75
+ value: entry['value'], height: entry['height']
76
76
  )
77
77
  end
78
78
 
@@ -62,8 +62,8 @@ module BSV
62
62
 
63
63
  utxos = utxo_result.data.map do |entry|
64
64
  BSV::Network::UTXO.new(
65
- tx_hash: entry[:tx_hash], tx_pos: entry[:tx_pos],
66
- satoshis: entry[:satoshis], height: entry[:height]
65
+ tx_hash: entry['tx_hash'], tx_pos: entry['tx_pos'],
66
+ value: entry['value'], height: entry['height']
67
67
  )
68
68
  end
69
69
 
@@ -25,7 +25,7 @@ module BSV
25
25
  # @return [Hash]
26
26
  def self.transaction_to_h(tx)
27
27
  {
28
- txid: tx.txid_hex,
28
+ txid: tx.txid_hex, # MCP tool boundary: display-order hex for human consumption
29
29
  version: tx.version,
30
30
  lock_time: tx.lock_time,
31
31
  inputs: tx.inputs.each_with_index.map { |inp, i| input_to_h(inp, i) },
@@ -38,7 +38,7 @@ module BSV
38
38
  def self.input_to_h(input, _index)
39
39
  unlock_script = input.unlocking_script
40
40
  {
41
- prev_txid: input.prev_tx_id.reverse.unpack1('H*'),
41
+ prev_txid: input.dtxid_hex,
42
42
  vout: input.prev_tx_out_index,
43
43
  script_hex: unlock_script ? unlock_script.to_hex : '',
44
44
  script_asm: unlock_script ? unlock_script.to_asm : '',
@@ -3,10 +3,12 @@
3
3
  module BSV
4
4
  module Network
5
5
  class BroadcastError < StandardError
6
+ # ARC API boundary: display-order hex txid as returned by the ARC error response.
6
7
  attr_reader :status_code, :txid, :arc_status
7
8
 
8
9
  def initialize(message, status_code: nil, txid: nil, arc_status: nil)
9
10
  @status_code = status_code
11
+ BSV::Primitives::Hex.validate_dtxid_hex!(txid, name: 'ARC error txid') if txid
10
12
  @txid = txid
11
13
  @arc_status = arc_status
12
14
  super(message)
@@ -3,10 +3,13 @@
3
3
  module BSV
4
4
  module Network
5
5
  class BroadcastResponse
6
+ # ARC API boundary: display-order hex txid as returned by the ARC broadcast endpoint.
6
7
  attr_reader :txid, :tx_status, :message, :extra_info, :block_hash, :block_height, :timestamp, :competing_txs
7
8
 
8
9
  def initialize(attrs = {})
9
- @txid = attrs[:txid]
10
+ txid = attrs[:txid]
11
+ BSV::Primitives::Hex.validate_dtxid_hex!(txid, name: 'ARC broadcast txid') if txid
12
+ @txid = txid
10
13
  @tx_status = attrs[:tx_status]
11
14
  @message = attrs[:message]
12
15
  @extra_info = attrs[:extra_info]
@@ -103,14 +103,21 @@ module BSV
103
103
  @endpoints = {}
104
104
  @subscriptions = {}
105
105
 
106
- attr_reader :base_url, :api_key, :network, :http_client
106
+ attr_reader :base_url, :api_key, :auth, :network, :http_client
107
107
 
108
108
  # @param base_url [String] base URL, may contain +{network}+ placeholder
109
- # @param api_key [String, nil] API key for authenticated requests
109
+ # @param api_key [String, nil] legacy API key sends +Authorization: Bearer <key>+
110
+ # @param auth [Hash, Symbol, nil] auth config hash; takes precedence over +api_key:+.
111
+ # Supported forms:
112
+ # - +{ bearer: 'token' }+ → +Authorization: Bearer token+
113
+ # - +{ api_key: 'key' }+ → +Authorization: key+ (no Bearer prefix, WoC style)
114
+ # - +{ api_key: 'key', header: 'X-Custom' }+ → +X-Custom: key+
115
+ # - +:none+ or +nil+ → no auth header
110
116
  # @param network [String, Symbol, nil] network name (e.g. 'main', 'test')
111
117
  # @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)
118
+ def initialize(base_url:, api_key: nil, auth: nil, network: nil, http_client: nil)
113
119
  @api_key = api_key
120
+ @auth = normalise_auth(auth)
114
121
  @network = network
115
122
  @http_client = http_client
116
123
  @base_url = build_base_url(base_url, network)
@@ -228,6 +235,14 @@ module BSV
228
235
 
229
236
  # Builds a Net::HTTP request for the given method, URI, and optional body.
230
237
  #
238
+ # Auth header dispatch (in priority order):
239
+ # 1. +auth:+ config hash takes precedence over the legacy +api_key:+ shorthand.
240
+ # 2. +{ bearer: 'token' }+ → +Authorization: Bearer token+
241
+ # 3. +{ api_key: 'key', header: 'X-Custom' }+ → +X-Custom: key+
242
+ # 4. +{ api_key: 'key' }+ → +Authorization: key+ (no Bearer prefix)
243
+ # 5. +auth: :none+ or no auth at all → no Authorization header set
244
+ # 6. Legacy +api_key:+ (no auth: provided) → +Authorization: Bearer api_key+
245
+ #
231
246
  # @param http_method [Symbol] +:get+ or +:post+
232
247
  # @param uri [URI]
233
248
  # @param body [String, nil] raw body for POST requests
@@ -240,7 +255,8 @@ module BSV
240
255
  else raise ArgumentError, "unsupported HTTP method: #{http_method}"
241
256
  end
242
257
 
243
- request['Authorization'] = "Bearer #{@api_key}" if @api_key
258
+ apply_auth(request)
259
+
244
260
  if body && request.respond_to?(:body=)
245
261
  request.body = body
246
262
  request.content_type = 'application/json' unless request.content_type
@@ -248,6 +264,40 @@ module BSV
248
264
  request
249
265
  end
250
266
 
267
+ # Applies the auth header to the request based on the +auth:+ config or
268
+ # the legacy +api_key:+ shorthand.
269
+ #
270
+ # @param request [Net::HTTPRequest]
271
+ def apply_auth(request)
272
+ # auth: config takes precedence over legacy api_key:
273
+ if @auth != :none
274
+ auth = @auth
275
+ if auth[:bearer]
276
+ request['Authorization'] = "Bearer #{auth[:bearer]}"
277
+ elsif auth[:api_key]
278
+ header = auth[:header] || 'Authorization'
279
+ request[header] = auth[:api_key]
280
+ end
281
+ elsif @api_key
282
+ # Legacy shorthand: api_key: without auth: sends Bearer
283
+ request['Authorization'] = "Bearer #{@api_key}"
284
+ end
285
+ end
286
+
287
+ # Normalises the +auth+ argument so that +nil+ and empty hashes are
288
+ # stored as +:none+, giving a single canonical sentinel value for
289
+ # "no authentication".
290
+ #
291
+ # @param auth [Hash, Symbol, nil]
292
+ # @return [Hash, Symbol]
293
+ def normalise_auth(auth)
294
+ return :none if auth.nil?
295
+ return :none if auth == :none
296
+ return :none if auth.is_a?(Hash) && (auth.empty? || (auth[:bearer].nil? && auth[:api_key].nil?))
297
+
298
+ auth
299
+ end
300
+
251
301
  # Executes the request via the injectable client or +Net::HTTP.start+.
252
302
  #
253
303
  # @param uri [URI]
@@ -305,6 +355,8 @@ module BSV
305
355
  JSON.parse(body)
306
356
  when :json_array
307
357
  parsed = JSON.parse(body)
358
+ # Some providers (e.g. WoC) wrap arrays in { "result": [...] }
359
+ parsed = parsed['result'] if parsed.is_a?(Hash) && parsed.key?('result')
308
360
  raise TypeError, "expected Array, got #{parsed.class}" unless parsed.is_a?(Array)
309
361
 
310
362
  parsed
@@ -24,6 +24,8 @@ module BSV
24
24
  # result = arc.call(:broadcast, tx)
25
25
  # result.success? # => true
26
26
  # result.data[:txid] # => "abc123..."
27
+ #
28
+ # @see https://docs.gorillapool.io/arc/api.html ARC API v1 documentation
27
29
  class ARC < Protocol
28
30
  # ARC response statuses that indicate a transaction was NOT accepted.
29
31
  # Matches the TypeScript SDK's ARC broadcaster failure set.
@@ -45,16 +47,17 @@ module BSV
45
47
  endpoint :health, :get, '/v1/health', response: :json
46
48
 
47
49
  # @param base_url [String] ARC base URL (may contain {network})
48
- # @param api_key [String, nil] optional bearer token
50
+ # @param api_key [String, nil] legacy bearer token shorthand — use +auth:+ for new code
51
+ # @param auth [Hash, Symbol, nil] auth config; takes precedence over +api_key:+
49
52
  # @param network [String, nil] network name for base URL interpolation
50
53
  # @param deployment_id [String, nil] deployment identifier for the
51
54
  # XDeployment-ID header; defaults to a per-instance random hex value
52
55
  # @param callback_url [String, nil] optional X-CallbackUrl header value
53
56
  # @param callback_token [String, nil] optional X-CallbackToken header value
54
57
  # @param http_client [#request, nil] injectable HTTP client for testing
55
- def initialize(base_url:, api_key: nil, network: nil, deployment_id: nil,
58
+ def initialize(base_url:, api_key: nil, auth: nil, network: nil, deployment_id: nil,
56
59
  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)
60
+ super(base_url: base_url, api_key: api_key, auth: auth, network: network, http_client: http_client)
58
61
  @deployment_id = deployment_id || "bsv-ruby-sdk-#{SecureRandom.hex(8)}"
59
62
  @callback_url = callback_url
60
63
  @callback_token = callback_token
@@ -138,7 +141,8 @@ module BSV
138
141
  # lacks source_satoshis / source_locking_script.
139
142
  def ef_hex_with_fallback(tx)
140
143
  tx.to_ef_hex
141
- rescue ArgumentError
144
+ rescue ArgumentError => e
145
+ BSV.logger&.debug { "[ARC] EF serialisation failed: #{e.message} — falling back to raw hex" }
142
146
  tx.to_hex
143
147
  end
144
148
 
@@ -269,7 +273,7 @@ module BSV
269
273
  # same field set as broadcast responses rather than the raw parsed JSON.
270
274
  # Also checks for rejection status and missing txid (malformed 2xx).
271
275
  #
272
- # @param txid [String] the transaction ID to query
276
+ # @param txid [String] ARC API boundary: display-order hex transaction ID to query
273
277
  # @return [Result::Success, Result::Error, Result::NotFound]
274
278
  def call_get_tx_status(txid, **)
275
279
  response = default_call(:get_tx_status, txid)
@@ -306,7 +310,7 @@ module BSV
306
310
  # @return [Hash]
307
311
  def arc_data_from(body)
308
312
  {
309
- txid: body['txid'],
313
+ txid: body['txid'], # ARC API boundary: display-order hex from the ARC JSON response
310
314
  tx_status: body['txStatus'],
311
315
  message: body['title'],
312
316
  extra_info: body['extraInfo'],
@@ -22,15 +22,19 @@ module BSV
22
22
  #
23
23
  # result = ct.call(:get_block_header, 800_000)
24
24
  # result.data # => { 'hash' => '...', 'height' => 800000, 'merkleRoot' => '...' }
25
+ #
26
+ # @note Chaintracks is an internal GorillaPool service; no public API documentation
27
+ # is available.
25
28
  class Chaintracks < Protocol
26
29
  endpoint :get_block_header, :get, '/chaintracks/v2/header/height/{height}', response: :json
27
30
  endpoint :current_height, :get, '/chaintracks/v2/tip',
28
31
  response: ->(body) { JSON.parse(body)['height'] }
29
32
 
30
33
  # @param base_url [String] base URL for the Chaintracks API
31
- # @param api_key [String, nil] optional Bearer API key
34
+ # @param api_key [String, nil] legacy Bearer API key shorthand — use +auth:+ for new code
35
+ # @param auth [Hash, Symbol, nil] auth config; takes precedence over +api_key:+
32
36
  # @param http_client [Object, nil] injectable HTTP client for testing
33
- def initialize(base_url:, api_key: nil, http_client: nil)
37
+ def initialize(base_url:, api_key: nil, auth: nil, http_client: nil)
34
38
  super
35
39
  end
36
40
  end