bsv-wallet 0.6.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 832766edaf4b3b59b4a5a2bdf47e997972b62336ddbf65614e5445f254615fa4
4
- data.tar.gz: c2bb017e369d5b7497c134fde1d5fd729e8952cc8a11bb04c05eaa6ad7f6ebf2
3
+ metadata.gz: c71d9a7e4b68e5d47c4d95e3a8bcd6c23c698a92ca3faa4809a9a7abf1171b8e
4
+ data.tar.gz: f61618ef74ba89eae855dc710244f5a9208bff34a2244f4bb2ab6425d6774fea
5
5
  SHA512:
6
- metadata.gz: b2861431435b2e6aa871cbb6dfdc020f8a1c010eef487606efa9f0d1de9f8bd5ef6c6f2384eea98c621d72bb9be0175e280de47ac927f3effb4b11e931528d0f
7
- data.tar.gz: b08fc27ca7a74a3f98e9df12190d7198296d8e91527848edba41e288066fbc6e164d9b13e793666232489a8edae7dacc514340058542be7d99e060db4eb5e754
6
+ metadata.gz: 9c9d6badbfaac1bb19ed0bf5745ccfe21aba5372822e692be72f1065df56175481277e8f1b41daf4c17dd0acc758e43a2dcf73d4e8c96147d982dafd7b7a7ca3
7
+ data.tar.gz: 161733d4e9c277c55d4e3b1d89eab89bf156d066575c3f924e066c636d55f65606cd1e4df7afdbbdf0ff8cea615d5d6c09c30aafc63773294370c22659a3bf85
data/CHANGELOG.md CHANGED
@@ -5,6 +5,46 @@ All notable changes to the `bsv-wallet` 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.8.0 — 2026-04-15
9
+
10
+ ### Added
11
+ - BRC-100 substrates: `HTTPWalletJSON` for JSON-over-HTTP, `HTTPWalletWire`
12
+ for binary transport, and `WalletWireTransceiver` Interface adapter (#449–#451)
13
+ - `WalletClient` accepts `substrate:` constructor param for remote wallet
14
+ delegation — all Interface methods delegate to the substrate when set (#452)
15
+ - `list_actions` and `list_outputs` honour `include_labels`, `include_inputs`,
16
+ and `include_outputs` flags (#448)
17
+ - `acquire_certificate` uses `AuthFetch` for BRC-104 authenticated certificate
18
+ issuance (#453)
19
+
20
+ ### Fixed
21
+ - `prove_certificate` now uses correct protocol ID (`'certificate field encryption'`)
22
+ and key ID format (`"#{serial_number} #{field_name}"`) matching TS/Go SDKs —
23
+ previously incompatible cross-SDK (#424)
24
+ - Code review findings addressed for substrates and include flags
25
+
26
+ ### Changed
27
+ - `BSV::WalletInterface` module removed — `VERSION` now lives in `BSV::Wallet::VERSION`
28
+ where all other wallet constants already reside
29
+
30
+ ## 0.7.0 — 2026-04-12
31
+
32
+ ### Added
33
+ - Pluggable `BroadcastQueue` interface module — duck-typed, follows the `StorageAdapter` pattern
34
+ - `InlineQueue` synchronous default adapter — consolidates broadcast and no-broadcaster paths
35
+ - `WalletClient` accepts `broadcast_queue:` constructor parameter (auto-creates `InlineQueue` when not provided)
36
+ - `BroadcastQueue.status_for_error` shared helper for consistent broadcast error mapping
37
+ - Integration specs for broadcast/rollback flows (20 specs)
38
+ - MemoryStore production warning — logs to stderr when `RACK_ENV`/`RAILS_ENV` is `production` or `staging`
39
+
40
+ ### Fixed
41
+ - TOCTOU window on change outputs — stored as `:pending` directly, eliminating race with concurrent `auto_fund`
42
+ - Broadcast promotion failure no longer deletes confirmed on-chain outputs — only broadcast failure triggers rollback
43
+
44
+ ### Changed
45
+ - `accept_delayed_broadcast: true` no longer logs "not yet implemented" warning — handled by the queue adapter
46
+ - MemoryStore demoted to test/development only (production use triggers a suppressible warning)
47
+
8
48
  ## 0.6.0 — 2026-04-12
9
49
 
10
50
  ### Added
@@ -0,0 +1,101 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BSV
4
+ module Wallet
5
+ # Duck-typed broadcast queue interface for wallet transaction dispatch.
6
+ #
7
+ # Include this module in broadcast queue adapters and override all instance
8
+ # methods that raise +NotImplementedError+. The +async?+ method may be
9
+ # overridden to return +true+ for adapters that defer execution to a
10
+ # background worker.
11
+ #
12
+ # == Payload contract
13
+ #
14
+ # The +enqueue+ method receives a +Hash+ with the following keys:
15
+ #
16
+ # {
17
+ # tx: BSV::Transaction, # signed transaction object
18
+ # txid: String, # hex txid
19
+ # beef_binary: String, # raw BEEF bytes
20
+ # input_outpoints: Array<String>, # locked input outpoints (nil on finalize path)
21
+ # change_outpoints: Array<String>, # change outpoints (nil on finalize path)
22
+ # fund_ref: String, # fund reference for rollback (nil on finalize path)
23
+ # accept_delayed_broadcast: Boolean # from caller options
24
+ # }
25
+ #
26
+ # When +input_outpoints+ is +nil+, the caller is on the finalize path and
27
+ # the adapter must skip UTXO state transitions.
28
+ #
29
+ # == Example
30
+ #
31
+ # class MyQueue
32
+ # include BSV::Wallet::BroadcastQueue
33
+ #
34
+ # def async?
35
+ # true
36
+ # end
37
+ #
38
+ # def enqueue(payload)
39
+ # MyWorker.perform_later(payload[:txid])
40
+ # { txid: payload[:txid] }
41
+ # end
42
+ #
43
+ # def status(txid)
44
+ # MyWorker.status_for(txid)
45
+ # end
46
+ # end
47
+ module BroadcastQueue
48
+ # Enqueues a transaction for broadcast and state promotion.
49
+ #
50
+ # For synchronous adapters this executes immediately and returns the
51
+ # result. For asynchronous adapters this persists the job and returns
52
+ # a partial result; the caller should treat the action as pending.
53
+ #
54
+ # @param _payload [Hash] broadcast payload (see module-level doc for keys)
55
+ # @return [Hash] result hash containing at minimum +:txid+
56
+ # @raise [NotImplementedError] unless overridden by the including class
57
+ def enqueue(_payload)
58
+ raise NotImplementedError, "#{self.class}#enqueue not implemented"
59
+ end
60
+
61
+ # Returns +false+ by default, indicating synchronous execution.
62
+ #
63
+ # Override and return +true+ in adapters that defer broadcast to a
64
+ # background worker (e.g. SolidQueue, Sidekiq).
65
+ #
66
+ # @return [Boolean]
67
+ def async?
68
+ false
69
+ end
70
+
71
+ # Returns the broadcast status for a previously enqueued transaction.
72
+ #
73
+ # @param _txid [String] hex transaction identifier
74
+ # @return [String, nil] the current status string, or +nil+ if unknown
75
+ # @raise [NotImplementedError] unless overridden by the including class
76
+ def status(_txid)
77
+ raise NotImplementedError, "#{self.class}#status not implemented"
78
+ end
79
+
80
+ # Maps a broadcast exception to a {ReviewActionResultStatus} string.
81
+ #
82
+ # This shared helper is extracted from +WalletClient#broadcast_status_for+
83
+ # so all queue adapters can produce consistent status strings without
84
+ # duplicating the mapping logic.
85
+ #
86
+ # @param error [StandardError] the exception raised during broadcast
87
+ # @return [String] one of +'doubleSpend'+, +'invalidTx'+, +'serviceError'+
88
+ def self.status_for_error(error)
89
+ return 'serviceError' unless error.is_a?(BSV::Network::BroadcastError)
90
+
91
+ arc_status = error.arc_status.to_s.upcase
92
+ return 'doubleSpend' if arc_status == 'DOUBLE_SPEND_ATTEMPTED'
93
+
94
+ invalid_statuses = %w[REJECTED INVALID MALFORMED MINED_IN_STALE_BLOCK]
95
+ return 'invalidTx' if invalid_statuses.include?(arc_status) || arc_status.include?('ORPHAN')
96
+
97
+ 'serviceError'
98
+ end
99
+ end
100
+ end
101
+ end
@@ -0,0 +1,171 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BSV
4
+ module Wallet
5
+ # Synchronous broadcast queue adapter — the default for +WalletClient+.
6
+ #
7
+ # +InlineQueue+ replicates the current wallet broadcast behaviour exactly:
8
+ #
9
+ # * With a broadcaster: calls +broadcaster.broadcast+, promotes UTXO state on
10
+ # success, rolls back on failure.
11
+ # * Without a broadcaster: promotes immediately and returns BEEF for the
12
+ # caller to broadcast manually (backwards-compatible fallback).
13
+ #
14
+ # Because this adapter executes synchronously, +async?+ returns +false+ and
15
+ # the caller can rely on the returned hash containing the final result.
16
+ class InlineQueue
17
+ include BSV::Wallet::BroadcastQueue
18
+
19
+ # @param broadcaster [#broadcast, nil] broadcaster object; +nil+ disables broadcasting
20
+ # @param storage [StorageAdapter] wallet storage adapter
21
+ def initialize(storage:, broadcaster: nil)
22
+ @broadcaster = broadcaster
23
+ @storage = storage
24
+ end
25
+
26
+ # Returns +false+ — this adapter executes synchronously.
27
+ #
28
+ # @return [Boolean]
29
+ def async?
30
+ false
31
+ end
32
+
33
+ # Returns the broadcast status for a previously enqueued transaction.
34
+ #
35
+ # Delegates to storage and returns the action status field, or +nil+ if
36
+ # the action is not found.
37
+ #
38
+ # @param txid [String] hex transaction identifier
39
+ # @return [String, nil]
40
+ def status(txid)
41
+ actions = @storage.find_actions({ txid: txid, limit: 1, offset: 0 })
42
+ actions.first&.dig(:status)
43
+ end
44
+
45
+ # Broadcasts and promotes (or just promotes) a transaction synchronously.
46
+ #
47
+ # Dispatches to +broadcast_and_promote+ when a broadcaster is configured,
48
+ # or +promote_without_broadcast+ when none is present.
49
+ #
50
+ # @param payload [Hash] broadcast payload (see +BroadcastQueue+ module docs)
51
+ # @return [Hash] result hash containing at minimum +:txid+ and +:tx+
52
+ def enqueue(payload)
53
+ if @broadcaster
54
+ broadcast_and_promote(payload)
55
+ else
56
+ promote_without_broadcast(payload)
57
+ end
58
+ end
59
+
60
+ private
61
+
62
+ # Broadcasts the transaction and promotes storage state on success.
63
+ #
64
+ # On broadcast failure with outpoints present, rolls back all pending
65
+ # state (releases locked inputs, deletes change outputs, marks action
66
+ # failed). On failure without outpoints (finalize path), only updates
67
+ # the action status.
68
+ #
69
+ # INVARIANT: Only broadcast failure triggers rollback. If broadcast
70
+ # succeeds but promotion raises, the error propagates — confirmed
71
+ # on-chain outputs must never be deleted.
72
+ #
73
+ # @param payload [Hash] broadcast payload
74
+ # @return [Hash] result hash
75
+ def broadcast_and_promote(payload)
76
+ tx = payload[:tx]
77
+ txid = payload[:txid]
78
+ beef_binary = payload[:beef_binary]
79
+ input_outpoints = payload[:input_outpoints]
80
+ change_outpoints = payload[:change_outpoints]
81
+ fund_ref = payload[:fund_ref]
82
+
83
+ begin
84
+ broadcast_result = @broadcaster.broadcast(tx)
85
+ rescue StandardError => e
86
+ if input_outpoints
87
+ rollback(input_outpoints, change_outpoints, txid, fund_ref)
88
+ elsif txid
89
+ @storage.update_action_status(txid, 'failed')
90
+ end
91
+ return {
92
+ txid: txid,
93
+ tx: beef_binary.unpack('C*'),
94
+ broadcast_error: e.message,
95
+ broadcast_status: BroadcastQueue.status_for_error(e)
96
+ }
97
+ end
98
+
99
+ # Broadcast succeeded — promote all pending state to final.
100
+ promote(input_outpoints, change_outpoints, txid)
101
+
102
+ result = {
103
+ txid: txid,
104
+ tx: beef_binary.unpack('C*'),
105
+ broadcast_result: broadcast_result,
106
+ broadcast_status: 'success'
107
+ }
108
+ result[:competing_txs] = broadcast_result.competing_txs if broadcast_result.respond_to?(:competing_txs) && broadcast_result.competing_txs
109
+ result
110
+ end
111
+
112
+ # Promotes UTXO state without broadcasting — backwards-compatible fallback
113
+ # used when no broadcaster is configured.
114
+ #
115
+ # If +accept_delayed_broadcast+ is set the action status is +unproven+;
116
+ # otherwise it is +completed+.
117
+ #
118
+ # @param payload [Hash] broadcast payload
119
+ # @return [Hash] result hash containing +:txid+ and +:tx+
120
+ def promote_without_broadcast(payload)
121
+ txid = payload[:txid]
122
+ beef_binary = payload[:beef_binary]
123
+ input_outpoints = payload[:input_outpoints]
124
+ change_outpoints = payload[:change_outpoints]
125
+ delayed = payload[:accept_delayed_broadcast]
126
+
127
+ final_status = delayed ? 'unproven' : 'completed'
128
+ promote(input_outpoints, change_outpoints, txid, status: final_status)
129
+
130
+ { txid: txid, tx: beef_binary.unpack('C*') }
131
+ end
132
+
133
+ # Promotes UTXO state: marks inputs as +:spent+, change as +:spendable+,
134
+ # and updates the action status.
135
+ #
136
+ # When +outpoints+ arguments are +nil+ (finalize path), UTXO transitions
137
+ # are skipped and only the action status is updated.
138
+ #
139
+ # @param input_outpoints [Array<String>, nil]
140
+ # @param change_outpoints [Array<String>, nil]
141
+ # @param txid [String, nil]
142
+ # @param status [String]
143
+ def promote(input_outpoints, change_outpoints, txid, status: 'completed')
144
+ Array(input_outpoints).each { |op| @storage.update_output_state(op, :spent) }
145
+ Array(change_outpoints).each { |op| @storage.update_output_state(op, :spendable) }
146
+ @storage.update_action_status(txid, status) if txid
147
+ end
148
+
149
+ # Rolls back a pending auto-funded action.
150
+ #
151
+ # Releases locked inputs (only those matching +fund_ref+), deletes phantom
152
+ # change outputs, and marks the action as +failed+.
153
+ #
154
+ # @param input_outpoints [Array<String>] outpoints locked as inputs
155
+ # @param change_outpoints [Array<String>] change outputs to delete
156
+ # @param txid [String, nil] action txid
157
+ # @param fund_ref [String] fund reference used when locking inputs
158
+ def rollback(input_outpoints, change_outpoints, txid, fund_ref)
159
+ Array(input_outpoints).each do |op|
160
+ outputs = @storage.find_outputs({ outpoint: op, include_spent: true, limit: 1, offset: 0 })
161
+ next if outputs.empty?
162
+ next unless outputs.first[:pending_reference] == fund_ref
163
+
164
+ @storage.update_output_state(op, :spendable)
165
+ end
166
+ Array(change_outpoints).each { |op| @storage.delete_output(op) }
167
+ @storage.update_action_status(txid, 'failed') if txid
168
+ end
169
+ end
170
+ end
171
+ end
@@ -2,9 +2,11 @@
2
2
 
3
3
  module BSV
4
4
  module Wallet
5
- # In-memory storage adapter for testing and single-process use.
5
+ # In-memory storage adapter intended for testing and development only.
6
6
  #
7
7
  # Stores actions, outputs, and certificates in plain Ruby arrays.
8
+ # All data is lost when the process exits — do not use in production.
9
+ # Use PostgresStore (or another persistent adapter) for production wallets.
8
10
  #
9
11
  # Thread safety: a +Mutex+ serialises all state-mutating operations so that
10
12
  # concurrent threads cannot select and mark the same UTXO as pending.
@@ -12,9 +14,29 @@ module BSV
12
14
  #
13
15
  # NOTE: FileStore is NOT process-safe — concurrent processes share no
14
16
  # in-memory lock and may read stale state from disk.
17
+ #
18
+ # == Production Warning
19
+ #
20
+ # When +RACK_ENV+, +RAILS_ENV+, or +APP_ENV+ is set to +production+ or
21
+ # +staging+, a warning is emitted to stderr. Suppress it with either:
22
+ #
23
+ # BSV_MEMORY_STORE_OK=1 # environment variable
24
+ # MemoryStore.warn_in_production = false # Ruby flag (e.g. in test setup)
15
25
  class MemoryStore
16
26
  include StorageAdapter
17
27
 
28
+ # Controls whether the production-environment warning is emitted.
29
+ # Set to +false+ in test suites to silence the warning globally.
30
+ #
31
+ # @return [Boolean]
32
+ def self.warn_in_production?
33
+ @warn_in_production != false
34
+ end
35
+
36
+ class << self
37
+ attr_writer :warn_in_production
38
+ end
39
+
18
40
  def initialize
19
41
  @actions = []
20
42
  @outputs = []
@@ -23,6 +45,12 @@ module BSV
23
45
  @transactions = {}
24
46
  @settings = {}
25
47
  @mutex = Mutex.new
48
+
49
+ return unless self.class.warn_in_production? && production_env?
50
+
51
+ warn '[bsv-wallet] MemoryStore is intended for testing only. ' \
52
+ 'Use PostgresStore for production wallets. ' \
53
+ 'Set BSV_MEMORY_STORE_OK=1 to silence this warning.'
26
54
  end
27
55
 
28
56
  def store_action(action_data)
@@ -255,6 +283,14 @@ module BSV
255
283
 
256
284
  private
257
285
 
286
+ # Returns true when the process is running in a production or staging
287
+ # environment and the operator has not explicitly acknowledged MemoryStore
288
+ # usage via the +BSV_MEMORY_STORE_OK+ environment variable.
289
+ def production_env?
290
+ env = ENV['RACK_ENV'] || ENV['RAILS_ENV'] || ENV.fetch('APP_ENV', nil)
291
+ env && %w[production staging].include?(env) && !ENV['BSV_MEMORY_STORE_OK']
292
+ end
293
+
258
294
  def filter_actions(query)
259
295
  results = @actions
260
296
  return results unless query[:labels]
@@ -0,0 +1,268 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'net/http'
4
+ require 'json'
5
+ require 'uri'
6
+
7
+ module BSV
8
+ module Wallet
9
+ module Substrates
10
+ # BRC-100 wallet substrate that delegates all Interface methods to a remote
11
+ # wallet server via JSON-over-HTTP (POST #{base_url}/#{camelCaseMethodName}).
12
+ #
13
+ # Key conversion is handled by {BSV::WireFormat}: Ruby snake_case symbol keys
14
+ # in args are converted to camelCase strings before the request, and the
15
+ # camelCase JSON response is converted back to snake_case symbol keys.
16
+ #
17
+ # @example
18
+ # wallet = BSV::Wallet::Substrates::HTTPWalletJSON.new('http://localhost:3321',
19
+ # originator: 'myapp.example.com')
20
+ # result = wallet.get_public_key({ identity_key: true })
21
+ # # => { public_key: '02abc...' }
22
+ class HTTPWalletJSON
23
+ include BSV::Wallet::Interface
24
+
25
+ # Maps the 28 BRC-100 Interface method symbols to their camelCase HTTP endpoint names.
26
+ # Derived from Wire::Serializer::CALL_CODES keys via BSV::WireFormat.snake_to_camel.
27
+ METHOD_NAMES = BSV::Wallet::Wire::Serializer::CALL_CODES.keys.to_h do |sym|
28
+ [sym, BSV::WireFormat.snake_to_camel(sym.to_s)]
29
+ end.freeze
30
+
31
+ # @param base_url [String] base URL of the remote wallet server (e.g. 'http://localhost:3321')
32
+ # @param originator [String, nil] FQDN of the originating application (sent as Origin/Originator headers)
33
+ # @param http_client [Object, nil] injectable HTTP client for testing; must respond to
34
+ # `start(uri, &block)` returning a Net::HTTP-compatible response
35
+ def initialize(base_url, originator: nil, http_client: nil)
36
+ @base_url = base_url
37
+ @originator = originator
38
+ @http_client = http_client
39
+ end
40
+
41
+ # Per-call originator is accepted for Interface conformance but not forwarded.
42
+ # The Origin header uses the constructor-level @originator for the lifetime of
43
+ # the connection — matching the TS SDK's HTTPWalletJSON, which also ignores per-call
44
+ # originator. WalletWireTransceiver supports per-call originator because the wire
45
+ # frame encodes it per-message; HTTP substrates identify by connection, not by call.
46
+ # rubocop:disable Lint/UnusedMethodArgument
47
+
48
+ def create_action(args, originator: nil)
49
+ call(METHOD_NAMES[:create_action], args)
50
+ end
51
+
52
+ def sign_action(args, originator: nil)
53
+ call(METHOD_NAMES[:sign_action], args)
54
+ end
55
+
56
+ def abort_action(args, originator: nil)
57
+ call(METHOD_NAMES[:abort_action], args)
58
+ end
59
+
60
+ def list_actions(args, originator: nil)
61
+ call(METHOD_NAMES[:list_actions], args)
62
+ end
63
+
64
+ def internalize_action(args, originator: nil)
65
+ call(METHOD_NAMES[:internalize_action], args)
66
+ end
67
+
68
+ def list_outputs(args, originator: nil)
69
+ call(METHOD_NAMES[:list_outputs], args)
70
+ end
71
+
72
+ def relinquish_output(args, originator: nil)
73
+ call(METHOD_NAMES[:relinquish_output], args)
74
+ end
75
+
76
+ def get_public_key(args, originator: nil)
77
+ call(METHOD_NAMES[:get_public_key], args)
78
+ end
79
+
80
+ def reveal_counterparty_key_linkage(args, originator: nil)
81
+ call(METHOD_NAMES[:reveal_counterparty_key_linkage], args)
82
+ end
83
+
84
+ def reveal_specific_key_linkage(args, originator: nil)
85
+ call(METHOD_NAMES[:reveal_specific_key_linkage], args)
86
+ end
87
+
88
+ def encrypt(args, originator: nil)
89
+ call(METHOD_NAMES[:encrypt], args)
90
+ end
91
+
92
+ def decrypt(args, originator: nil)
93
+ call(METHOD_NAMES[:decrypt], args)
94
+ end
95
+
96
+ def create_hmac(args, originator: nil)
97
+ call(METHOD_NAMES[:create_hmac], args)
98
+ end
99
+
100
+ def verify_hmac(args, originator: nil)
101
+ call(METHOD_NAMES[:verify_hmac], args)
102
+ end
103
+
104
+ def create_signature(args, originator: nil)
105
+ call(METHOD_NAMES[:create_signature], args)
106
+ end
107
+
108
+ def verify_signature(args, originator: nil)
109
+ call(METHOD_NAMES[:verify_signature], args)
110
+ end
111
+
112
+ def acquire_certificate(args, originator: nil)
113
+ call(METHOD_NAMES[:acquire_certificate], args)
114
+ end
115
+
116
+ def list_certificates(args, originator: nil)
117
+ call(METHOD_NAMES[:list_certificates], args)
118
+ end
119
+
120
+ def prove_certificate(args, originator: nil)
121
+ call(METHOD_NAMES[:prove_certificate], args)
122
+ end
123
+
124
+ def relinquish_certificate(args, originator: nil)
125
+ call(METHOD_NAMES[:relinquish_certificate], args)
126
+ end
127
+
128
+ def discover_by_identity_key(args, originator: nil)
129
+ call(METHOD_NAMES[:discover_by_identity_key], args)
130
+ end
131
+
132
+ def discover_by_attributes(args, originator: nil)
133
+ call(METHOD_NAMES[:discover_by_attributes], args)
134
+ end
135
+
136
+ def is_authenticated(args = {}, originator: nil)
137
+ call(METHOD_NAMES[:is_authenticated], args)
138
+ end
139
+
140
+ def wait_for_authentication(args = {}, originator: nil)
141
+ call(METHOD_NAMES[:wait_for_authentication], args)
142
+ end
143
+
144
+ def get_height(args = {}, originator: nil)
145
+ call(METHOD_NAMES[:get_height], args)
146
+ end
147
+
148
+ def get_header_for_height(args, originator: nil)
149
+ call(METHOD_NAMES[:get_header_for_height], args)
150
+ end
151
+
152
+ def get_network(args = {}, originator: nil)
153
+ call(METHOD_NAMES[:get_network], args)
154
+ end
155
+
156
+ def get_version(args = {}, originator: nil)
157
+ call(METHOD_NAMES[:get_version], args)
158
+ end
159
+
160
+ # rubocop:enable Lint/UnusedMethodArgument
161
+
162
+ private
163
+
164
+ # Posts args to the remote wallet endpoint for the given camelCase method name.
165
+ #
166
+ # Outbound: converts snake_case symbol keys to camelCase strings via WireFormat.to_wire.
167
+ # Inbound: converts camelCase string keys to snake_case symbols via WireFormat.from_wire.
168
+ # Errors: non-2xx response raises the appropriate WalletError subclass.
169
+ #
170
+ # @param method_name [String] camelCase endpoint name (e.g. 'getPublicKey')
171
+ # @param args [Hash] method arguments (snake_case symbol keys)
172
+ # @return [Hash] response with snake_case symbol keys
173
+ def call(method_name, args)
174
+ args ||= {}
175
+ wire_args = BSV::WireFormat.to_wire(args)
176
+ body = JSON.generate(wire_args)
177
+
178
+ uri = build_uri(method_name)
179
+ headers = build_headers
180
+
181
+ response = execute_request(uri, body, headers)
182
+
183
+ handle_response(response, method_name, args)
184
+ end
185
+
186
+ def build_uri(method_name)
187
+ URI.parse("#{@base_url}/#{method_name}")
188
+ end
189
+
190
+ def build_headers
191
+ h = {
192
+ 'Content-Type' => 'application/json',
193
+ 'Accept' => 'application/json'
194
+ }
195
+
196
+ if @originator
197
+ origin_value = to_origin_header(@originator)
198
+ h['Origin'] = origin_value
199
+ h['Originator'] = origin_value
200
+ end
201
+
202
+ h
203
+ end
204
+
205
+ # Converts an originator domain to a full Origin header value.
206
+ # Prepends 'http://' if no scheme is present (matching TS SDK toOriginHeader).
207
+ def to_origin_header(originator)
208
+ return originator if originator.match?(%r{\A[a-z][a-z0-9+.-]*://}i)
209
+
210
+ "http://#{originator}"
211
+ end
212
+
213
+ def execute_request(uri, body, headers)
214
+ if @http_client
215
+ @http_client.post(uri, body, headers)
216
+ else
217
+ Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == 'https') do |http|
218
+ request = Net::HTTP::Post.new(uri.request_uri, headers)
219
+ request.body = body
220
+ http.request(request)
221
+ end
222
+ end
223
+ end
224
+
225
+ def handle_response(response, method_name, args)
226
+ code = response.code.to_i
227
+
228
+ unless (200..299).cover?(code)
229
+ data = begin
230
+ parse_json_body(response.body)
231
+ rescue JSON::ParserError
232
+ nil
233
+ end
234
+ raise_error_response(code, data, method_name, args)
235
+ end
236
+
237
+ data = parse_json_body(response.body)
238
+ return {} if data.nil?
239
+
240
+ data.is_a?(Hash) ? BSV::WireFormat.from_wire(data) : data
241
+ end
242
+
243
+ def parse_json_body(body)
244
+ return nil if body.nil? || body.empty?
245
+
246
+ JSON.parse(body)
247
+ end
248
+
249
+ def raise_error_response(code, data, method_name, _args)
250
+ if code == 400 && data.is_a?(Hash) && data['isError']
251
+ case data['code']
252
+ when 5
253
+ raise BSV::Wallet::WalletError.new(data['message'] || 'Review actions required', 5)
254
+ when 6
255
+ raise BSV::Wallet::InvalidParameterError, data['parameter'] || 'unknown'
256
+ when 7
257
+ raise BSV::Wallet::InsufficientFundsError, data['message']
258
+ end
259
+ end
260
+
261
+ message = (data.is_a?(Hash) && data['message']) ||
262
+ "HTTP #{code} error calling #{method_name}"
263
+ raise BSV::Wallet::WalletError, message
264
+ end
265
+ end
266
+ end
267
+ end
268
+ end