bsv-wallet 0.5.1 → 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: 3ec8e4dbc04e014ab716a010b3059fa9134ba25718306ede7541bae3f7f3df94
4
- data.tar.gz: 6b7727694dcaa8136a919f3abe06f65f5b238264a12a395863ddf3cf8c1f3533
3
+ metadata.gz: 233e4c969fea0e8bb52110ffffe9d034a953854ccb1098c0a3dd3eb76bb2c034
4
+ data.tar.gz: e11ab6c70e949cbe62593f620d0367225e80ac84a144f94b07418d02e9383466
5
5
  SHA512:
6
- metadata.gz: 2288b14436eaf937e99c82b79c5c3d001d6e80f28d47d0848da1db309a5714c92398cf50ef1ff30bcaaa17d1f784f70ac8f3ae22340b6ed23276b3bd866e954b
7
- data.tar.gz: 8adc0b4b11cde0e99727ce95c448b2c724f1cd72ea8835bd27e7ca28c19a6ac27f65fd9a6b1455f5ecd0110f7026c214e9adb8da33ebb1b7f89c8f26cbc64769
6
+ metadata.gz: 974ffa3042e2bb4fe3c9f7ec94d8503c55ed20e94d5a808a667700339fbb5151eec6b12c185238353ccfd40e62f197477081fdb7b6b9f9ce75da4598a4536b90
7
+ data.tar.gz: 0af0b5d238c57a1b640ea243b2bce0e5e33fd55932354689a62b1c05dfe3b41d776fdfcd5fe245fa3973de98632d6f0985bbfc4bed99b4d2527494dc16fc2f6f
data/CHANGELOG.md CHANGED
@@ -5,6 +5,52 @@ 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
+
26
+ ## 0.6.0 — 2026-04-12
27
+
28
+ ### Added
29
+ - Broadcast-before-promote semantics for `create_action` — transactions are
30
+ broadcast via a configurable `broadcaster:` before promoting state, with
31
+ automatic rollback on failure to prevent phantom UTXOs (#369, #371)
32
+ - `send_with` for batched broadcast of previously `no_send` transactions,
33
+ with per-transaction rollback on failure (#373)
34
+ - `accept_delayed_broadcast` option accepted as a stub (defaults to `false`;
35
+ background broadcasting deferred to a follow-up) (#374)
36
+ - `delete_action` and `update_action_status` on `StorageAdapter`, implemented
37
+ in MemoryStore, FileStore, and PostgresStore (#370)
38
+ - Broadcast results mapped to `ReviewActionResultStatus` (`success`,
39
+ `doubleSpend`, `invalidTx`, `serviceError`) and returned in the result
40
+ hash (#372)
41
+
42
+ ### Fixed
43
+ - EF version marker overhead corrected from 2 to 6 bytes in fee estimation
44
+ - Fee estimation now includes EF overhead for ARC compatibility
45
+ - `FeeEstimator` default raised from 1 sat/kB to 100 sat/kB
46
+ - `internalize_payment` now stores outputs in the default basket
47
+ - `derivation_type` comparison uses string comparison for JSON round-trip
48
+ safety (#367)
49
+
50
+ ### Changed
51
+ - Extracted `DEFAULT_SATS_PER_KB` constant for fee estimation
52
+ - Specs reorganised into per-gem directories (#363)
53
+
8
54
  ## 0.5.1 — 2026-04-11
9
55
 
10
56
  ### Fixed
@@ -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
@@ -8,40 +8,56 @@ module BSV
8
8
  # fee estimation given only input and output counts. Once a real {Transaction} is
9
9
  # available, delegates directly to the underlying SDK fee model via {#estimate_for_tx}.
10
10
  #
11
- # The default rate is 1 sat/kB the current BSV network standard. The SDK's own
12
- # {SatoshisPerKilobyte} defaults to 100 sat/kB; this class intentionally differs.
11
+ # The default rate is 100 sat/kB, matching the ARC mining policy endpoint
12
+ # and the SDK's own {SatoshisPerKilobyte} default.
13
13
  #
14
14
  # @example Pre-construction estimate
15
15
  # estimator = BSV::Wallet::FeeEstimator.new
16
16
  # fee = estimator.estimate(p2pkh_inputs: 2, p2pkh_outputs: 3)
17
- # # => 1 (at 1 sat/kB, ceil(408/1000 * 1) = 1)
17
+ # # => 41 (at 100 sat/kB, ceil(408/1000 * 100) = 41)
18
18
  #
19
19
  # @example Custom rate
20
20
  # estimator = BSV::Wallet::FeeEstimator.new(sats_per_kb: 50)
21
21
  # fee = estimator.estimate(p2pkh_inputs: 1, p2pkh_outputs: 1)
22
22
  # # => 10 (ceil(192/1000 * 50) = 10)
23
23
  class FeeEstimator
24
- # Estimated size in bytes of an unsigned P2PKH input.
25
- # Matches {BSV::Transaction::Transaction::UNSIGNED_P2PKH_INPUT_SIZE}.
26
- P2PKH_INPUT_SIZE = BSV::Transaction::Transaction::UNSIGNED_P2PKH_INPUT_SIZE
24
+ # Estimated size in bytes of an unsigned P2PKH input in raw format.
25
+ P2PKH_RAW_INPUT_SIZE = BSV::Transaction::Transaction::UNSIGNED_P2PKH_INPUT_SIZE
26
+
27
+ # Extended Format (BRC-30/EF) adds source_satoshis (8 bytes) +
28
+ # varint(25) (1 byte) + P2PKH locking script (25 bytes) = 34 bytes
29
+ # per input. ARC validates fees against the EF size, not raw.
30
+ EF_INPUT_OVERHEAD = 34
31
+
32
+ # Total estimated input size including EF overhead. This is the size
33
+ # ARC sees and charges fees against.
34
+ P2PKH_INPUT_SIZE = P2PKH_RAW_INPUT_SIZE + EF_INPUT_OVERHEAD
27
35
 
28
36
  # Estimated size in bytes of a P2PKH output (8 satoshis + varint(25) + 25-byte script).
29
37
  P2PKH_OUTPUT_SIZE = 34
30
38
 
31
- # Fixed overhead in bytes for version (4) and lock_time (4).
32
- FIXED_OVERHEAD = 8
39
+ # EF version marker: 6 bytes (\x00\x00\x00\x00\x00\xEF) follows
40
+ # the 4-byte version field, adding 6 bytes of fixed overhead.
41
+ EF_VERSION_OVERHEAD = 6
42
+
43
+ # Fixed overhead in bytes for version (4) + EF marker (6) + lock_time (4).
44
+ FIXED_OVERHEAD = 14
33
45
 
34
46
  # Approximate overhead including typical 1-byte varints for input/output
35
47
  # counts. Retained for backward compatibility with code that referenced
36
48
  # +FeeModel::OVERHEAD+.
37
49
  OVERHEAD = 10
38
50
 
51
+ # Default fee rate in satoshis per kilobyte, matching ARC's mining
52
+ # policy endpoint (/v1/policy → miningFee 100/1000).
53
+ DEFAULT_SATS_PER_KB = 100
54
+
39
55
  # @return [Integer] the satoshis-per-kilobyte rate used for estimation
40
56
  attr_reader :sats_per_kb
41
57
 
42
- # @param sats_per_kb [Integer] fee rate in satoshis per kilobyte (default: 1)
58
+ # @param sats_per_kb [Integer] fee rate in satoshis per kilobyte
43
59
  # @raise [ArgumentError] if sats_per_kb is zero or negative
44
- def initialize(sats_per_kb: 1)
60
+ def initialize(sats_per_kb: DEFAULT_SATS_PER_KB)
45
61
  raise ArgumentError, 'sats_per_kb must be greater than zero' unless sats_per_kb.positive?
46
62
 
47
63
  @sats_per_kb = sats_per_kb
@@ -48,6 +48,18 @@ module BSV
48
48
  result
49
49
  end
50
50
 
51
+ def update_action_status(txid, new_status)
52
+ result = super
53
+ save_actions
54
+ result
55
+ end
56
+
57
+ def delete_action(txid)
58
+ result = super
59
+ save_actions if result
60
+ result
61
+ end
62
+
51
63
  def store_output(output_data)
52
64
  result = super
53
65
  save_outputs
@@ -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
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'openssl'
4
+
3
5
  module BSV
4
6
  module Wallet
5
7
  # BRC-42/43 key derivation for the wallet interface.
@@ -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)
@@ -30,6 +58,22 @@ module BSV
30
58
  action_data
31
59
  end
32
60
 
61
+ def update_action_status(txid, new_status)
62
+ action = @actions.find { |a| a[:txid] == txid }
63
+ raise WalletError, "Action not found: #{txid}" unless action
64
+
65
+ action[:status] = new_status
66
+ action
67
+ end
68
+
69
+ def delete_action(txid)
70
+ idx = @actions.index { |a| a[:txid] == txid }
71
+ return false unless idx
72
+
73
+ @actions.delete_at(idx)
74
+ true
75
+ end
76
+
33
77
  def find_actions(query)
34
78
  apply_pagination(filter_actions(query), query)
35
79
  end
@@ -239,6 +283,14 @@ module BSV
239
283
 
240
284
  private
241
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
+
242
294
  def filter_actions(query)
243
295
  results = @actions
244
296
  return results unless query[:labels]
@@ -67,6 +67,26 @@ module BSV
67
67
  raise NotImplementedError, "#{self.class}#find_transaction not implemented"
68
68
  end
69
69
 
70
+ # Updates the status field of a stored action identified by txid.
71
+ #
72
+ # @param _txid [String] the transaction identifier of the action to update
73
+ # @param _new_status [String] the new status value (e.g. 'failed', 'completed')
74
+ # @return [Hash] the updated action hash
75
+ # @raise [BSV::Wallet::WalletError] if no action with the given txid is found
76
+ # @raise [NotImplementedError]
77
+ def update_action_status(_txid, _new_status)
78
+ raise NotImplementedError, "#{self.class}#update_action_status not implemented"
79
+ end
80
+
81
+ # Removes a stored action by txid.
82
+ #
83
+ # @param _txid [String] the transaction identifier of the action to remove
84
+ # @return [Boolean] true if the action was deleted, false if not found
85
+ # @raise [NotImplementedError]
86
+ def delete_action(_txid)
87
+ raise NotImplementedError, "#{self.class}#delete_action not implemented"
88
+ end
89
+
70
90
  # Transitions the state of an existing output.
71
91
  #
72
92
  # When +new_state+ is +:pending+, pass a +pending_reference:+ string to
@@ -2,6 +2,6 @@
2
2
 
3
3
  module BSV
4
4
  module WalletInterface
5
- VERSION = '0.5.1'
5
+ VERSION = '0.7.0'
6
6
  end
7
7
  end
@@ -39,6 +39,12 @@ module BSV
39
39
  # @return [ProofStore] the merkle proof persistence store
40
40
  attr_reader :proof_store
41
41
 
42
+ # @return [#broadcast, nil] the optional broadcaster (responds to #broadcast(tx))
43
+ attr_reader :broadcaster
44
+
45
+ # @return [BroadcastQueue] the broadcast queue used to dispatch transactions
46
+ attr_reader :broadcast_queue
47
+
42
48
  # @param key [BSV::Primitives::PrivateKey, String, KeyDeriver] signing key
43
49
  # @param storage [StorageAdapter] persistence adapter (default: FileStore).
44
50
  # Use +storage: MemoryStore.new+ for tests.
@@ -46,6 +52,8 @@ module BSV
46
52
  # @param chain_provider [ChainProvider] blockchain data provider (default: NullChainProvider)
47
53
  # @param proof_store [ProofStore, nil] merkle proof store (default: LocalProofStore backed by storage)
48
54
  # @param http_client [#request, nil] injectable HTTP client for certificate issuance
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
49
57
  def initialize(
50
58
  key,
51
59
  storage: FileStore.new,
@@ -55,7 +63,9 @@ module BSV
55
63
  http_client: nil,
56
64
  fee_estimator: nil,
57
65
  coin_selector: nil,
58
- change_generator: nil
66
+ change_generator: nil,
67
+ broadcaster: nil,
68
+ broadcast_queue: nil
59
69
  )
60
70
  super(key)
61
71
  @storage = storage
@@ -63,10 +73,21 @@ module BSV
63
73
  @chain_provider = chain_provider
64
74
  @proof_store = proof_store || LocalProofStore.new(storage)
65
75
  @http_client = http_client
76
+ @broadcaster = broadcaster
66
77
  @pending = {}
78
+ @pending_by_txid = {}
67
79
  @injected_fee_estimator = fee_estimator
68
80
  @injected_coin_selector = coin_selector
69
81
  @injected_change_generator = change_generator
82
+ @broadcast_queue = broadcast_queue || InlineQueue.new(
83
+ storage: @storage,
84
+ broadcaster: @broadcaster
85
+ )
86
+ end
87
+
88
+ # Returns true when a broadcaster has been configured.
89
+ def broadcast_enabled?
90
+ !@broadcaster.nil?
70
91
  end
71
92
 
72
93
  # --- Transaction Operations ---
@@ -92,11 +113,29 @@ module BSV
92
113
  def create_action(args, _originator: nil)
93
114
  validate_create_action!(args)
94
115
 
116
+ send_with_txids = Array(args.dig(:options, :send_with))
117
+
95
118
  outputs = args[:outputs] || []
96
119
  inputs = args[:inputs]
97
120
 
121
+ # When send_with is provided but no new transaction body is specified,
122
+ # broadcast only the batched no_send transactions.
123
+ if !send_with_txids.empty? && outputs.empty? && (inputs.nil? || inputs.empty?)
124
+ raise WalletError, 'A broadcaster is required to use send_with' unless broadcast_enabled?
125
+
126
+ return { send_with_results: broadcast_send_with(send_with_txids) }
127
+ end
128
+
98
129
  if (inputs.nil? || inputs.empty?) && !outputs.empty? && (args[:auto_fund] || spendable_pool_eligible?)
99
- return auto_fund_and_create(args, outputs)
130
+ result = auto_fund_and_create(args, outputs)
131
+ # If send_with was also specified, batch-broadcast those alongside the
132
+ # current transaction's implicit broadcast.
133
+ unless send_with_txids.empty?
134
+ raise WalletError, 'A broadcaster is required to use send_with' unless broadcast_enabled?
135
+
136
+ result[:send_with_results] = broadcast_send_with(send_with_txids)
137
+ end
138
+ return result
100
139
  end
101
140
 
102
141
  beef = parse_input_beef(args[:input_beef])
@@ -125,7 +164,15 @@ module BSV
125
164
  tx = pending[:tx]
126
165
  apply_spends(tx, args[:spends])
127
166
  @pending.delete(reference)
128
- finalize_action(tx, pending[:args])
167
+
168
+ # Merge sign_action's own options over the original create_action args so
169
+ # callers can supply accept_delayed_broadcast at sign time.
170
+ merged_args = if args[:options]
171
+ pending[:args].merge(options: (pending[:args][:options] || {}).merge(args[:options]))
172
+ else
173
+ pending[:args]
174
+ end
175
+ finalize_action(tx, merged_args)
129
176
  end
130
177
 
131
178
  # Aborts a pending signable transaction.
@@ -143,11 +190,14 @@ module BSV
143
190
  raise WalletError, 'Transaction not found for the given reference' unless @pending.key?(reference)
144
191
 
145
192
  pending_entry = @pending.delete(reference)
146
- # Release locked input UTXOs back to :spendable.
147
- release_pending_utxos(pending_entry[:locked_outpoints], reference) if pending_entry[:locked_outpoints]
148
- # Remove change outputs created by the aborted transaction —
149
- # they reference an unbroadcast tx and must not remain in storage.
150
- Array(pending_entry[:change_outpoints]).each { |op| @storage.delete_output(op) }
193
+ txid = pending_entry[:tx]&.txid_hex
194
+ @pending_by_txid.delete(txid) if txid
195
+ rollback_pending_action(
196
+ pending_entry[:locked_outpoints],
197
+ pending_entry[:change_outpoints],
198
+ txid,
199
+ reference
200
+ )
151
201
  { aborted: true }
152
202
  end
153
203
 
@@ -347,7 +397,7 @@ module BSV
347
397
  # @return [Integer] total auto-spendable satoshis
348
398
  def spendable_balance(basket: nil)
349
399
  @storage.find_spendable_outputs(basket: basket)
350
- .select { |o| (o[:derivation_prefix] && o[:derivation_suffix] && o[:sender_identity_key]) || o[:derivation_type] == :identity }
400
+ .select { |o| (o[:derivation_prefix] && o[:derivation_suffix] && o[:sender_identity_key]) || o[:derivation_type]&.to_s == 'identity' }
351
401
  .sum { |o| o[:satoshis].to_i }
352
402
  end
353
403
 
@@ -613,7 +663,7 @@ module BSV
613
663
  all_spendable = @storage.find_spendable_outputs(basket: 'default')
614
664
  available = all_spendable.select do |o|
615
665
  (o[:derivation_prefix] && o[:derivation_suffix] && o[:sender_identity_key]) ||
616
- o[:derivation_type] == :identity
666
+ o[:derivation_type]&.to_s == 'identity'
617
667
  end
618
668
 
619
669
  selection = auto_fund_select(available, target, caller_outputs.size)
@@ -648,46 +698,50 @@ module BSV
648
698
  @storage.store_transaction(txid, tx_hex)
649
699
 
650
700
  if no_send
651
- # Leave inputs as :pending the caller will either broadcast via
652
- # send_with or cancel via abort_action.
653
- store_change_outputs(txid, tx, change_outputs, tx_hex)
654
-
655
- # Record change outpoint positions, then mark them :pending so they
656
- # are not auto-selected by a concurrent create_action. This matches
657
- # the TS SDK where noSend outputs have spendable: false.
658
- change_outpoints = []
659
- change_outputs.each do |spec|
660
- idx = tx.outputs.index { |o| o.instance_variable_get(:@_spec).equal?(spec) }
661
- next unless idx
662
-
663
- op = "#{txid}.#{idx}"
664
- change_outpoints << op
665
- @storage.update_output_state(op, :pending, pending_reference: fund_ref, no_send: true)
666
- 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
+ )
667
707
 
668
708
  store_action(tx, args, status: 'nosend')
669
709
  store_tracked_outputs(txid, tx, caller_outputs)
670
710
 
671
- # Register in @pending so abort_action can release inputs and
672
- # remove change outputs.
711
+ # Register in @pending so abort_action (or send_with) can release
712
+ # inputs and remove change outputs. Secondary index by txid allows
713
+ # send_with callers to look up pending entries by txid.
673
714
  @pending[fund_ref] = {
674
715
  tx: tx, args: args,
675
716
  locked_outpoints: selected_outpoints,
676
717
  change_outpoints: change_outpoints
677
718
  }
719
+ @pending_by_txid[txid] = fund_ref
678
720
 
679
721
  beef_binary = tx.to_beef
680
722
  { txid: txid, tx: beef_binary.unpack('C*'), reference: fund_ref, no_send_change: change_outpoints }
681
723
  else
682
- store_action(tx, args, status: 'completed')
683
- store_change_outputs(txid, tx, change_outputs, tx_hex)
684
- store_tracked_outputs(txid, tx, caller_outputs)
724
+ # Store everything in pending state first — inputs are already locked
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.
728
+ store_action(tx, args, status: 'pending')
729
+ change_outpoints = store_change_outputs(
730
+ txid, tx, change_outputs, tx_hex,
731
+ state: :pending, pending_reference: fund_ref
732
+ )
685
733
 
686
- # Promote from :pending to :spent now that all storage writes are done.
687
- selected_outpoints.each { |op| @storage.update_output_state(op, :spent) }
734
+ store_tracked_outputs(txid, tx, caller_outputs)
688
735
 
689
736
  beef_binary = tx.to_beef
690
- { txid: txid, tx: beef_binary.unpack('C*') }
737
+
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
+ )
691
745
  end
692
746
  rescue StandardError
693
747
  # Release the pending lock so the UTXOs are available for retry.
@@ -803,7 +857,7 @@ module BSV
803
857
 
804
858
  wire_source_from_storage(input, utxo[:outpoint])
805
859
 
806
- priv = if utxo[:derivation_type] == :identity
860
+ priv = if utxo[:derivation_type]&.to_s == 'identity'
807
861
  @key_deriver.root_key
808
862
  else
809
863
  @key_deriver.derive_private_key(
@@ -837,40 +891,55 @@ module BSV
837
891
  tx.add_output(output)
838
892
  end
839
893
 
840
- # Persists change outputs in storage as new spendable UTXOs.
894
+ # Persists change outputs in storage with the specified state.
841
895
  #
842
896
  # Each output is stored with full BRC-29 derivation metadata so the
843
- # wallet can derive the spending key later, and with +:state+ +:spendable+
844
- # 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.
845
901
  #
846
902
  # @param txid [String] hex txid of the signed transaction
847
903
  # @param tx [BSV::Transaction::Transaction]
848
904
  # @param change_specs [Array<Hash>] change descriptors from {ChangeGenerator}
849
905
  # @param tx_hex [String] serialised transaction hex (used as source)
850
- 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 = []
851
912
  change_specs.each do |spec|
852
913
  actual_idx = tx.outputs.index { |o| o.instance_variable_get(:@_spec).equal?(spec) }
853
914
  next unless actual_idx
854
915
 
855
- locking_script_hex = if spec[:locking_script].is_a?(BSV::Script::Script)
856
- spec[:locking_script].to_hex
857
- else
858
- spec[:locking_script]
859
- 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
860
922
 
861
- @storage.store_output({
862
- outpoint: "#{txid}.#{actual_idx}",
863
- satoshis: spec[:satoshis],
864
- locking_script: locking_script_hex,
865
- basket: 'default',
866
- tags: [],
867
- derivation_prefix: spec[:derivation_prefix],
868
- derivation_suffix: spec[:derivation_suffix],
869
- sender_identity_key: spec[:sender_identity_key],
870
- state: :spendable,
871
- source_tx_hex: tx_hex
872
- })
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
873
941
  end
942
+ entry
874
943
  end
875
944
 
876
945
  # Lazy accessors for auto-fund components. Created on first use so the
@@ -897,7 +966,10 @@ module BSV
897
966
  Validators.validate_description!(args[:description])
898
967
  inputs_present = args[:inputs] && !args[:inputs].empty?
899
968
  outputs_present = args[:outputs] && !args[:outputs].empty?
900
- raise InvalidParameterError.new('inputs/outputs', 'at least one input or output') unless inputs_present || outputs_present
969
+ send_with_present = args.dig(:options, :send_with) && !Array(args.dig(:options, :send_with)).empty?
970
+ unless inputs_present || outputs_present || send_with_present
971
+ raise InvalidParameterError.new('inputs/outputs', 'at least one input or output')
972
+ end
901
973
 
902
974
  validate_action_inputs!(args[:inputs]) if args[:inputs]
903
975
  validate_action_outputs!(args[:outputs]) if args[:outputs]
@@ -1121,10 +1193,150 @@ module BSV
1121
1193
  end
1122
1194
  end
1123
1195
 
1196
+ # Rolls back a pending auto-funded action.
1197
+ #
1198
+ # Releases locked input UTXOs back to +:spendable+, deletes phantom change
1199
+ # outputs, and optionally updates the action status. Used by both
1200
+ # broadcast failure and +abort_action+ so UTXO state is always consistent.
1201
+ #
1202
+ # @param input_outpoints [Array<String>] outpoints locked as inputs
1203
+ # @param change_outpoints [Array<String>] change outputs to delete
1204
+ # @param txid [String, nil] txid of the action to update (may be nil)
1205
+ # @param ref [String] fund reference used when locking inputs
1206
+ # @param action_status [String, nil] new action status; nil skips the update
1207
+ def rollback_pending_action(input_outpoints, change_outpoints, txid, ref, action_status: nil)
1208
+ release_pending_utxos(input_outpoints, ref)
1209
+ Array(change_outpoints).each { |op| @storage.delete_output(op) }
1210
+ @storage.update_action_status(txid, action_status) if txid && action_status
1211
+ end
1212
+
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.
1218
+ #
1219
+ # @param tx [Transaction] signed transaction to broadcast
1220
+ # @param txid [String] hex transaction id
1221
+ # @param input_outpoints [Array<String>] outpoints locked as inputs
1222
+ # @param change_outpoints [Array<String>] change output outpoints
1223
+ # @param fund_ref [String] fund reference used when locking
1224
+ # @param beef_binary [String] raw BEEF bytes
1225
+ # @return [Hash] result hash from +BroadcastQueue#enqueue+
1226
+ def broadcast_and_promote(tx, txid, input_outpoints, change_outpoints, fund_ref, beef_binary)
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
+ )
1234
+ end
1235
+
1236
+ # Batch-broadcasts a list of previously no_send transactions.
1237
+ #
1238
+ # Each txid must correspond to a pending no_send entry registered in
1239
+ # +@pending_by_txid+. Transactions are broadcast individually so that one
1240
+ # failure does not block the others. On per-tx success the inputs are
1241
+ # promoted to +:spent+ and change outputs to +:spendable+. On failure the
1242
+ # pending state is rolled back via +rollback_pending_action+.
1243
+ #
1244
+ # @param txids [Array<String>] txids of no_send transactions to broadcast
1245
+ # @return [Array<Hash>] per-tx results matching TS SDK +SendWithResult[]+ shape:
1246
+ # +{ txid: String, status: 'unproven' | 'failed' }+
1247
+ def broadcast_send_with(txids)
1248
+ txids.map { |txid| broadcast_single_no_send(txid) }
1249
+ end
1250
+
1251
+ # Broadcasts one no_send transaction and promotes or rolls back its state.
1252
+ #
1253
+ # @param txid [String] hex txid of the no_send transaction
1254
+ # @return [Hash] +{ txid:, status: }+ result entry
1255
+ def broadcast_single_no_send(txid)
1256
+ fund_ref = @pending_by_txid[txid]
1257
+ return { txid: txid, status: 'failed', error: 'Transaction not found in pending no_send store' } unless fund_ref
1258
+
1259
+ pending_entry = @pending[fund_ref]
1260
+ unless pending_entry
1261
+ @pending_by_txid.delete(txid)
1262
+ return { txid: txid, status: 'failed', error: 'Pending entry expired or already processed' }
1263
+ end
1264
+
1265
+ # Use the original signed transaction from the pending entry — it has
1266
+ # source_satoshis and source_locking_script wired on each input, which
1267
+ # the broadcaster needs for Extended Format (EF/BRC-30) submission.
1268
+ # Reconstructing from stored hex would lose that metadata.
1269
+ tx = pending_entry[:tx]
1270
+ unless tx
1271
+ tx_hex = @storage.find_transaction(txid)
1272
+ return { txid: txid, status: 'failed', error: 'Transaction not found' } unless tx_hex
1273
+
1274
+ tx = BSV::Transaction::Transaction.from_hex(tx_hex)
1275
+ end
1276
+ promote_no_send(tx, txid, fund_ref, pending_entry)
1277
+ rescue StandardError => e
1278
+ # Catch unexpected errors outside the broadcast rescue block.
1279
+ { txid: txid, status: 'failed', error: e.message }
1280
+ end
1281
+
1282
+ # Attempts to broadcast and promote a single no_send transaction.
1283
+ # Action status is set to 'unproven' (matching TS SDK SendWithResult)
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.
1289
+ def promote_no_send(tx, txid, fund_ref, pending_entry)
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
1304
+
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.
1308
+ pending_entry[:locked_outpoints].each { |op| @storage.update_output_state(op, :spent) }
1309
+ pending_entry[:change_outpoints].each { |op| @storage.update_output_state(op, :spendable) }
1310
+ @storage.update_action_status(txid, 'unproven')
1311
+ @pending.delete(fund_ref)
1312
+ @pending_by_txid.delete(txid)
1313
+
1314
+ { txid: txid, status: 'unproven' }
1315
+ end
1316
+
1317
+ # Maps a broadcast exception to a {ReviewActionResultStatus} string.
1318
+ #
1319
+ # Delegates to +BroadcastQueue.status_for_error+ for a single source of
1320
+ # truth across all queue adapters.
1321
+ #
1322
+ # @param error [StandardError] the exception raised during broadcast
1323
+ # @return [String] one of 'doubleSpend', 'invalidTx', 'serviceError'
1324
+ def broadcast_status_for(error)
1325
+ BroadcastQueue.status_for_error(error)
1326
+ end
1327
+
1124
1328
  def finalize_action(tx, args)
1125
1329
  tx.sign_all if tx.inputs.any?(&:unlocking_script_template)
1126
1330
  txid = tx.txid_hex
1127
- status = args.dig(:options, :no_send) ? 'nosend' : 'completed'
1331
+
1332
+ no_send = args.dig(:options, :no_send)
1333
+ delayed = args.dig(:options, :accept_delayed_broadcast)
1334
+
1335
+ status = if no_send
1336
+ 'nosend'
1337
+ else
1338
+ 'pending'
1339
+ end
1128
1340
 
1129
1341
  @storage.store_transaction(txid, tx.to_hex)
1130
1342
  store_action(tx, args, status: status)
@@ -1132,7 +1344,21 @@ module BSV
1132
1344
 
1133
1345
  beef_binary = tx.to_beef
1134
1346
  result = { txid: txid, tx: beef_binary.unpack('C*') }
1135
- result[:no_send_change] = [] if args.dig(:options, :no_send)
1347
+
1348
+ if no_send
1349
+ result[:no_send_change] = []
1350
+ else
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
+ )
1360
+ end
1361
+
1136
1362
  result
1137
1363
  end
1138
1364
 
@@ -1281,6 +1507,7 @@ module BSV
1281
1507
  outpoint: "#{txid}.#{output_index}",
1282
1508
  satoshis: tx_output.satoshis,
1283
1509
  locking_script: tx_output.locking_script.to_hex,
1510
+ basket: 'default',
1284
1511
  spendable: true,
1285
1512
  sender_identity_key: sender_key,
1286
1513
  derivation_prefix: prefix,
@@ -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.5.1
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-11 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: []