bsv-wallet 0.6.0 → 0.7.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: 233e4c969fea0e8bb52110ffffe9d034a953854ccb1098c0a3dd3eb76bb2c034
4
+ data.tar.gz: e11ab6c70e949cbe62593f620d0367225e80ac84a144f94b07418d02e9383466
5
5
  SHA512:
6
- metadata.gz: b2861431435b2e6aa871cbb6dfdc020f8a1c010eef487606efa9f0d1de9f8bd5ef6c6f2384eea98c621d72bb9be0175e280de47ac927f3effb4b11e931528d0f
7
- data.tar.gz: b08fc27ca7a74a3f98e9df12190d7198296d8e91527848edba41e288066fbc6e164d9b13e793666232489a8edae7dacc514340058542be7d99e060db4eb5e754
6
+ metadata.gz: 974ffa3042e2bb4fe3c9f7ec94d8503c55ed20e94d5a808a667700339fbb5151eec6b12c185238353ccfd40e62f197477081fdb7b6b9f9ce75da4598a4536b90
7
+ data.tar.gz: 0af0b5d238c57a1b640ea243b2bce0e5e33fd55932354689a62b1c05dfe3b41d776fdfcd5fe245fa3973de98632d6f0985bbfc4bed99b4d2527494dc16fc2f6f
data/CHANGELOG.md CHANGED
@@ -5,6 +5,24 @@ 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.7.0 — 2026-04-12
9
+
10
+ ### Added
11
+ - Pluggable `BroadcastQueue` interface module — duck-typed, follows the `StorageAdapter` pattern
12
+ - `InlineQueue` synchronous default adapter — consolidates broadcast and no-broadcaster paths
13
+ - `WalletClient` accepts `broadcast_queue:` constructor parameter (auto-creates `InlineQueue` when not provided)
14
+ - `BroadcastQueue.status_for_error` shared helper for consistent broadcast error mapping
15
+ - Integration specs for broadcast/rollback flows (20 specs)
16
+ - MemoryStore production warning — logs to stderr when `RACK_ENV`/`RAILS_ENV` is `production` or `staging`
17
+
18
+ ### Fixed
19
+ - TOCTOU window on change outputs — stored as `:pending` directly, eliminating race with concurrent `auto_fund`
20
+ - Broadcast promotion failure no longer deletes confirmed on-chain outputs — only broadcast failure triggers rollback
21
+
22
+ ### Changed
23
+ - `accept_delayed_broadcast: true` no longer logs "not yet implemented" warning — handled by the queue adapter
24
+ - MemoryStore demoted to test/development only (production use triggers a suppressible warning)
25
+
8
26
  ## 0.6.0 — 2026-04-12
9
27
 
10
28
  ### 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]
@@ -2,6 +2,6 @@
2
2
 
3
3
  module BSV
4
4
  module WalletInterface
5
- VERSION = '0.6.0'
5
+ VERSION = '0.7.0'
6
6
  end
7
7
  end
@@ -42,6 +42,9 @@ module BSV
42
42
  # @return [#broadcast, nil] the optional broadcaster (responds to #broadcast(tx))
43
43
  attr_reader :broadcaster
44
44
 
45
+ # @return [BroadcastQueue] the broadcast queue used to dispatch transactions
46
+ attr_reader :broadcast_queue
47
+
45
48
  # @param key [BSV::Primitives::PrivateKey, String, KeyDeriver] signing key
46
49
  # @param storage [StorageAdapter] persistence adapter (default: FileStore).
47
50
  # Use +storage: MemoryStore.new+ for tests.
@@ -50,6 +53,7 @@ module BSV
50
53
  # @param proof_store [ProofStore, nil] merkle proof store (default: LocalProofStore backed by storage)
51
54
  # @param http_client [#request, nil] injectable HTTP client for certificate issuance
52
55
  # @param broadcaster [#broadcast, nil] optional broadcaster; any object responding to #broadcast(tx)
56
+ # @param broadcast_queue [BroadcastQueue, nil] optional broadcast queue; defaults to InlineQueue
53
57
  def initialize(
54
58
  key,
55
59
  storage: FileStore.new,
@@ -60,7 +64,8 @@ module BSV
60
64
  fee_estimator: nil,
61
65
  coin_selector: nil,
62
66
  change_generator: nil,
63
- broadcaster: nil
67
+ broadcaster: nil,
68
+ broadcast_queue: nil
64
69
  )
65
70
  super(key)
66
71
  @storage = storage
@@ -74,6 +79,10 @@ module BSV
74
79
  @injected_fee_estimator = fee_estimator
75
80
  @injected_coin_selector = coin_selector
76
81
  @injected_change_generator = change_generator
82
+ @broadcast_queue = broadcast_queue || InlineQueue.new(
83
+ storage: @storage,
84
+ broadcaster: @broadcaster
85
+ )
77
86
  end
78
87
 
79
88
  # Returns true when a broadcaster has been configured.
@@ -689,22 +698,12 @@ module BSV
689
698
  @storage.store_transaction(txid, tx_hex)
690
699
 
691
700
  if no_send
692
- # Leave inputs as :pending the caller will either broadcast via
693
- # send_with or cancel via abort_action.
694
- store_change_outputs(txid, tx, change_outputs, tx_hex)
695
-
696
- # Record change outpoint positions, then mark them :pending so they
697
- # are not auto-selected by a concurrent create_action. This matches
698
- # the TS SDK where noSend outputs have spendable: false.
699
- change_outpoints = []
700
- change_outputs.each do |spec|
701
- idx = tx.outputs.index { |o| o.instance_variable_get(:@_spec).equal?(spec) }
702
- next unless idx
703
-
704
- op = "#{txid}.#{idx}"
705
- change_outpoints << op
706
- @storage.update_output_state(op, :pending, pending_reference: fund_ref, no_send: true)
707
- end
701
+ # Store change outputs directly as :pending with no_send: true so a
702
+ # concurrent auto-fund cannot select them. No flip loop needed.
703
+ change_outpoints = store_change_outputs(
704
+ txid, tx, change_outputs, tx_hex,
705
+ state: :pending, pending_reference: fund_ref, no_send: true
706
+ )
708
707
 
709
708
  store_action(tx, args, status: 'nosend')
710
709
  store_tracked_outputs(txid, tx, caller_outputs)
@@ -724,46 +723,25 @@ module BSV
724
723
  else
725
724
  # Store everything in pending state first — inputs are already locked
726
725
  # as :pending by lock_utxos above, so we match that discipline here.
726
+ # Change outputs are stored directly as :pending to eliminate the
727
+ # TOCTOU window where a concurrent auto-fund could select them.
727
728
  store_action(tx, args, status: 'pending')
728
- store_change_outputs(txid, tx, change_outputs, tx_hex)
729
-
730
- # Record change outpoints and mark them :pending so a concurrent
731
- # create_action cannot double-spend them before broadcast completes.
732
- change_outpoints = []
733
- change_outputs.each do |spec|
734
- idx = tx.outputs.index { |o| o.instance_variable_get(:@_spec).equal?(spec) }
735
- next unless idx
736
-
737
- op = "#{txid}.#{idx}"
738
- change_outpoints << op
739
- @storage.update_output_state(op, :pending, pending_reference: fund_ref)
740
- end
729
+ change_outpoints = store_change_outputs(
730
+ txid, tx, change_outputs, tx_hex,
731
+ state: :pending, pending_reference: fund_ref
732
+ )
741
733
 
742
734
  store_tracked_outputs(txid, tx, caller_outputs)
743
735
 
744
736
  beef_binary = tx.to_beef
745
737
 
746
- if broadcast_enabled?
747
- broadcast_and_promote(
748
- tx, txid, selected_outpoints, change_outpoints, fund_ref, beef_binary
749
- )
750
- else
751
- # No broadcaster configured — promote immediately and return BEEF
752
- # for the caller to broadcast (backwards-compatible behaviour).
753
- selected_outpoints.each { |op| @storage.update_output_state(op, :spent) }
754
- change_outpoints.each { |op| @storage.update_output_state(op, :spendable) }
755
-
756
- final_status = if args.dig(:options, :accept_delayed_broadcast)
757
- warn '[bsv-wallet] accept_delayed_broadcast: true is not yet implemented; ' \
758
- 'falling through to synchronous processing. ' \
759
- 'Background broadcasting is a planned future feature.'
760
- 'unproven'
761
- else
762
- 'completed'
763
- end
764
- @storage.update_action_status(txid, final_status)
765
- { txid: txid, tx: beef_binary.unpack('C*') }
766
- end
738
+ @broadcast_queue.enqueue(
739
+ tx: tx, txid: txid, beef_binary: beef_binary,
740
+ input_outpoints: selected_outpoints,
741
+ change_outpoints: change_outpoints,
742
+ fund_ref: fund_ref,
743
+ accept_delayed_broadcast: args.dig(:options, :accept_delayed_broadcast)
744
+ )
767
745
  end
768
746
  rescue StandardError
769
747
  # Release the pending lock so the UTXOs are available for retry.
@@ -913,40 +891,55 @@ module BSV
913
891
  tx.add_output(output)
914
892
  end
915
893
 
916
- # Persists change outputs in storage as new spendable UTXOs.
894
+ # Persists change outputs in storage with the specified state.
917
895
  #
918
896
  # Each output is stored with full BRC-29 derivation metadata so the
919
- # wallet can derive the spending key later, and with +:state+ +:spendable+
920
- # so it is eligible for future coin selection.
897
+ # wallet can derive the spending key later. The +:state+ parameter
898
+ # controls the initial state pass +:pending+ with a +:pending_reference+
899
+ # to store outputs atomically in the correct state and avoid any TOCTOU
900
+ # window where a concurrent auto-fund could select them as spendable.
921
901
  #
922
902
  # @param txid [String] hex txid of the signed transaction
923
903
  # @param tx [BSV::Transaction::Transaction]
924
904
  # @param change_specs [Array<Hash>] change descriptors from {ChangeGenerator}
925
905
  # @param tx_hex [String] serialised transaction hex (used as source)
926
- def store_change_outputs(txid, tx, change_specs, tx_hex)
906
+ # @param state [Symbol] initial output state (default: +:spendable+)
907
+ # @param pending_reference [String, nil] reference tag for pending outputs
908
+ # @param no_send [Boolean] when +true+, marks the output as no-send pending
909
+ # @return [Array<String>] list of stored outpoints ("txid.vout" format)
910
+ def store_change_outputs(txid, tx, change_specs, tx_hex, state: :spendable, pending_reference: nil, no_send: false)
911
+ outpoints = []
927
912
  change_specs.each do |spec|
928
913
  actual_idx = tx.outputs.index { |o| o.instance_variable_get(:@_spec).equal?(spec) }
929
914
  next unless actual_idx
930
915
 
931
- locking_script_hex = if spec[:locking_script].is_a?(BSV::Script::Script)
932
- spec[:locking_script].to_hex
933
- else
934
- spec[:locking_script]
935
- end
916
+ entry = change_output_entry(txid, actual_idx, spec, tx_hex, state, pending_reference, no_send)
917
+ @storage.store_output(entry)
918
+ outpoints << "#{txid}.#{actual_idx}"
919
+ end
920
+ outpoints
921
+ end
936
922
 
937
- @storage.store_output({
938
- outpoint: "#{txid}.#{actual_idx}",
939
- satoshis: spec[:satoshis],
940
- locking_script: locking_script_hex,
941
- basket: 'default',
942
- tags: [],
943
- derivation_prefix: spec[:derivation_prefix],
944
- derivation_suffix: spec[:derivation_suffix],
945
- sender_identity_key: spec[:sender_identity_key],
946
- state: :spendable,
947
- source_tx_hex: tx_hex
948
- })
923
+ def change_output_entry(txid, idx, spec, tx_hex, state, pending_reference, no_send)
924
+ locking_script_hex = spec[:locking_script].is_a?(BSV::Script::Script) ? spec[:locking_script].to_hex : spec[:locking_script]
925
+ entry = {
926
+ outpoint: "#{txid}.#{idx}",
927
+ satoshis: spec[:satoshis],
928
+ locking_script: locking_script_hex,
929
+ basket: 'default',
930
+ tags: [],
931
+ derivation_prefix: spec[:derivation_prefix],
932
+ derivation_suffix: spec[:derivation_suffix],
933
+ sender_identity_key: spec[:sender_identity_key],
934
+ state: state,
935
+ source_tx_hex: tx_hex
936
+ }
937
+ if state == :pending
938
+ entry[:pending_since] = Time.now.utc.iso8601
939
+ entry[:pending_reference] = pending_reference if pending_reference
940
+ entry[:no_send] = true if no_send
949
941
  end
942
+ entry
950
943
  end
951
944
 
952
945
  # Lazy accessors for auto-fund components. Created on first use so the
@@ -1217,8 +1210,11 @@ module BSV
1217
1210
  @storage.update_action_status(txid, action_status) if txid && action_status
1218
1211
  end
1219
1212
 
1220
- # Broadcasts the transaction and promotes storage state on success.
1221
- # On failure, rolls back all pending state changes.
1213
+ # Delegates to the broadcast queue for auto-fund promotion.
1214
+ #
1215
+ # Kept as a thin wrapper so +promote_no_send+ callers (send_with path)
1216
+ # remain unaffected. The queue handles broadcast, UTXO promotion, and
1217
+ # rollback.
1222
1218
  #
1223
1219
  # @param tx [Transaction] signed transaction to broadcast
1224
1220
  # @param txid [String] hex transaction id
@@ -1226,21 +1222,15 @@ module BSV
1226
1222
  # @param change_outpoints [Array<String>] change output outpoints
1227
1223
  # @param fund_ref [String] fund reference used when locking
1228
1224
  # @param beef_binary [String] raw BEEF bytes
1229
- # @return [Hash] result hash with :txid, :tx, :broadcast_result or :broadcast_error, and :broadcast_status
1225
+ # @return [Hash] result hash from +BroadcastQueue#enqueue+
1230
1226
  def broadcast_and_promote(tx, txid, input_outpoints, change_outpoints, fund_ref, beef_binary)
1231
- broadcast_result = @broadcaster.broadcast(tx)
1232
-
1233
- # Broadcast succeeded — promote all pending state to final.
1234
- input_outpoints.each { |op| @storage.update_output_state(op, :spent) }
1235
- change_outpoints.each { |op| @storage.update_output_state(op, :spendable) }
1236
- @storage.update_action_status(txid, 'completed')
1237
-
1238
- result = { txid: txid, tx: beef_binary.unpack('C*'), broadcast_result: broadcast_result, broadcast_status: 'success' }
1239
- result[:competing_txs] = broadcast_result.competing_txs if broadcast_result.competing_txs
1240
- result
1241
- rescue StandardError => e
1242
- rollback_pending_action(input_outpoints, change_outpoints, txid, fund_ref, action_status: 'failed')
1243
- { txid: txid, tx: beef_binary.unpack('C*'), broadcast_error: e.message, broadcast_status: broadcast_status_for(e) }
1227
+ @broadcast_queue.enqueue(
1228
+ tx: tx, txid: txid, beef_binary: beef_binary,
1229
+ input_outpoints: input_outpoints,
1230
+ change_outpoints: change_outpoints,
1231
+ fund_ref: fund_ref,
1232
+ accept_delayed_broadcast: false
1233
+ )
1244
1234
  end
1245
1235
 
1246
1236
  # Batch-broadcasts a list of previously no_send transactions.
@@ -1292,10 +1282,29 @@ module BSV
1292
1282
  # Attempts to broadcast and promote a single no_send transaction.
1293
1283
  # Action status is set to 'unproven' (matching TS SDK SendWithResult)
1294
1284
  # because the transaction has been submitted but not yet confirmed.
1285
+ #
1286
+ # INVARIANT: Only broadcast failure triggers rollback. If broadcast succeeds
1287
+ # but promotion raises, the error propagates — confirmed on-chain outputs must
1288
+ # never be deleted. See +broadcast_and_promote+ for the same invariant.
1295
1289
  def promote_no_send(tx, txid, fund_ref, pending_entry)
1296
- @broadcaster.broadcast(tx)
1290
+ begin
1291
+ @broadcaster.broadcast(tx)
1292
+ rescue StandardError => e
1293
+ rollback_pending_action(
1294
+ pending_entry[:locked_outpoints],
1295
+ pending_entry[:change_outpoints],
1296
+ txid,
1297
+ fund_ref,
1298
+ action_status: 'failed'
1299
+ )
1300
+ @pending.delete(fund_ref)
1301
+ @pending_by_txid.delete(txid)
1302
+ return { txid: txid, status: 'failed', error: e.message }
1303
+ end
1297
1304
 
1298
1305
  # Broadcast succeeded — promote state and remove from pending indices.
1306
+ # Do NOT rescue here: if promotion fails, the transaction is confirmed on-chain
1307
+ # and outputs must not be deleted. Let the error propagate to the caller.
1299
1308
  pending_entry[:locked_outpoints].each { |op| @storage.update_output_state(op, :spent) }
1300
1309
  pending_entry[:change_outpoints].each { |op| @storage.update_output_state(op, :spendable) }
1301
1310
  @storage.update_action_status(txid, 'unproven')
@@ -1303,34 +1312,17 @@ module BSV
1303
1312
  @pending_by_txid.delete(txid)
1304
1313
 
1305
1314
  { txid: txid, status: 'unproven' }
1306
- rescue StandardError => e
1307
- rollback_pending_action(
1308
- pending_entry[:locked_outpoints],
1309
- pending_entry[:change_outpoints],
1310
- txid,
1311
- fund_ref,
1312
- action_status: 'failed'
1313
- )
1314
- @pending.delete(fund_ref)
1315
- @pending_by_txid.delete(txid)
1316
-
1317
- { txid: txid, status: 'failed', error: e.message }
1318
1315
  end
1319
1316
 
1320
1317
  # Maps a broadcast exception to a {ReviewActionResultStatus} string.
1321
1318
  #
1319
+ # Delegates to +BroadcastQueue.status_for_error+ for a single source of
1320
+ # truth across all queue adapters.
1321
+ #
1322
1322
  # @param error [StandardError] the exception raised during broadcast
1323
1323
  # @return [String] one of 'doubleSpend', 'invalidTx', 'serviceError'
1324
1324
  def broadcast_status_for(error)
1325
- return 'serviceError' unless error.is_a?(BSV::Network::BroadcastError)
1326
-
1327
- arc_status = error.arc_status.to_s.upcase
1328
- return 'doubleSpend' if arc_status == 'DOUBLE_SPEND_ATTEMPTED'
1329
-
1330
- invalid_statuses = %w[REJECTED INVALID MALFORMED MINED_IN_STALE_BLOCK]
1331
- return 'invalidTx' if invalid_statuses.include?(arc_status) || arc_status.include?('ORPHAN')
1332
-
1333
- 'serviceError'
1325
+ BroadcastQueue.status_for_error(error)
1334
1326
  end
1335
1327
 
1336
1328
  def finalize_action(tx, args)
@@ -1355,30 +1347,16 @@ module BSV
1355
1347
 
1356
1348
  if no_send
1357
1349
  result[:no_send_change] = []
1358
- elsif broadcast_enabled?
1359
- # Broadcast and promote or rollback — same discipline as auto_fund path.
1360
- broadcast_result = nil
1361
- begin
1362
- broadcast_result = @broadcaster.broadcast(tx)
1363
- @storage.update_action_status(txid, 'completed')
1364
- result[:broadcast_result] = broadcast_result
1365
- result[:broadcast_status] = 'success'
1366
- rescue StandardError => e
1367
- @storage.update_action_status(txid, 'failed')
1368
- result[:broadcast_error] = e.message
1369
- result[:broadcast_status] = broadcast_status_for(e)
1370
- end
1371
1350
  else
1372
- # No broadcaster promote immediately (backwards compatible).
1373
- final_status = if delayed
1374
- warn '[bsv-wallet] accept_delayed_broadcast: true is not yet implemented; ' \
1375
- 'falling through to synchronous processing. ' \
1376
- 'Background broadcasting is a planned future feature.'
1377
- 'unproven'
1378
- else
1379
- 'completed'
1380
- end
1381
- @storage.update_action_status(txid, final_status)
1351
+ # The queue handles both broadcaster-present and no-broadcaster cases
1352
+ # internally, so no broadcast_enabled? check is needed here.
1353
+ result.merge!(
1354
+ @broadcast_queue.enqueue(
1355
+ tx: tx, txid: txid, beef_binary: beef_binary,
1356
+ input_outpoints: nil, change_outpoints: nil, fund_ref: nil,
1357
+ accept_delayed_broadcast: delayed
1358
+ )
1359
+ )
1382
1360
  end
1383
1361
 
1384
1362
  result
@@ -12,6 +12,8 @@ module BSV
12
12
  autoload :ProtoWallet, 'bsv/wallet_interface/proto_wallet'
13
13
  autoload :Validators, 'bsv/wallet_interface/validators'
14
14
  autoload :StorageAdapter, 'bsv/wallet_interface/storage_adapter'
15
+ autoload :BroadcastQueue, 'bsv/wallet_interface/broadcast_queue'
16
+ autoload :InlineQueue, 'bsv/wallet_interface/inline_queue'
15
17
  autoload :MemoryStore, 'bsv/wallet_interface/memory_store'
16
18
  autoload :FileStore, 'bsv/wallet_interface/file_store'
17
19
  autoload :ProofStore, 'bsv/wallet_interface/proof_store'
metadata CHANGED
@@ -1,13 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: bsv-wallet
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.6.0
4
+ version: 0.7.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Simon Bettison
8
8
  bindir: bin
9
9
  cert_chain: []
10
- date: 2026-04-12 00:00:00.000000000 Z
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
11
  dependencies:
12
12
  - !ruby/object:Gem::Dependency
13
13
  name: base64
@@ -53,6 +53,7 @@ files:
53
53
  - LICENSE
54
54
  - lib/bsv-wallet.rb
55
55
  - lib/bsv/wallet_interface.rb
56
+ - lib/bsv/wallet_interface/broadcast_queue.rb
56
57
  - lib/bsv/wallet_interface/certificate_signature.rb
57
58
  - lib/bsv/wallet_interface/chain_provider.rb
58
59
  - lib/bsv/wallet_interface/change_generator.rb
@@ -65,6 +66,7 @@ files:
65
66
  - lib/bsv/wallet_interface/fee_estimator.rb
66
67
  - lib/bsv/wallet_interface/fee_model.rb
67
68
  - lib/bsv/wallet_interface/file_store.rb
69
+ - lib/bsv/wallet_interface/inline_queue.rb
68
70
  - lib/bsv/wallet_interface/interface.rb
69
71
  - lib/bsv/wallet_interface/key_deriver.rb
70
72
  - lib/bsv/wallet_interface/local_proof_store.rb
@@ -103,7 +105,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
103
105
  - !ruby/object:Gem::Version
104
106
  version: '0'
105
107
  requirements: []
106
- rubygems_version: 3.6.2
108
+ rubygems_version: 4.0.10
107
109
  specification_version: 4
108
110
  summary: BRC-100 Wallet Interface for the BSV Blockchain
109
111
  test_files: []