bsv-wallet 0.5.1 → 0.6.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: 832766edaf4b3b59b4a5a2bdf47e997972b62336ddbf65614e5445f254615fa4
4
+ data.tar.gz: c2bb017e369d5b7497c134fde1d5fd729e8952cc8a11bb04c05eaa6ad7f6ebf2
5
5
  SHA512:
6
- metadata.gz: 2288b14436eaf937e99c82b79c5c3d001d6e80f28d47d0848da1db309a5714c92398cf50ef1ff30bcaaa17d1f784f70ac8f3ae22340b6ed23276b3bd866e954b
7
- data.tar.gz: 8adc0b4b11cde0e99727ce95c448b2c724f1cd72ea8835bd27e7ca28c19a6ac27f65fd9a6b1455f5ecd0110f7026c214e9adb8da33ebb1b7f89c8f26cbc64769
6
+ metadata.gz: b2861431435b2e6aa871cbb6dfdc020f8a1c010eef487606efa9f0d1de9f8bd5ef6c6f2384eea98c621d72bb9be0175e280de47ac927f3effb4b11e931528d0f
7
+ data.tar.gz: b08fc27ca7a74a3f98e9df12190d7198296d8e91527848edba41e288066fbc6e164d9b13e793666232489a8edae7dacc514340058542be7d99e060db4eb5e754
data/CHANGELOG.md CHANGED
@@ -5,6 +5,34 @@ 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.6.0 — 2026-04-12
9
+
10
+ ### Added
11
+ - Broadcast-before-promote semantics for `create_action` — transactions are
12
+ broadcast via a configurable `broadcaster:` before promoting state, with
13
+ automatic rollback on failure to prevent phantom UTXOs (#369, #371)
14
+ - `send_with` for batched broadcast of previously `no_send` transactions,
15
+ with per-transaction rollback on failure (#373)
16
+ - `accept_delayed_broadcast` option accepted as a stub (defaults to `false`;
17
+ background broadcasting deferred to a follow-up) (#374)
18
+ - `delete_action` and `update_action_status` on `StorageAdapter`, implemented
19
+ in MemoryStore, FileStore, and PostgresStore (#370)
20
+ - Broadcast results mapped to `ReviewActionResultStatus` (`success`,
21
+ `doubleSpend`, `invalidTx`, `serviceError`) and returned in the result
22
+ hash (#372)
23
+
24
+ ### Fixed
25
+ - EF version marker overhead corrected from 2 to 6 bytes in fee estimation
26
+ - Fee estimation now includes EF overhead for ARC compatibility
27
+ - `FeeEstimator` default raised from 1 sat/kB to 100 sat/kB
28
+ - `internalize_payment` now stores outputs in the default basket
29
+ - `derivation_type` comparison uses string comparison for JSON round-trip
30
+ safety (#367)
31
+
32
+ ### Changed
33
+ - Extracted `DEFAULT_SATS_PER_KB` constant for fee estimation
34
+ - Specs reorganised into per-gem directories (#363)
35
+
8
36
  ## 0.5.1 — 2026-04-11
9
37
 
10
38
  ### Fixed
@@ -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
@@ -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.
@@ -30,6 +30,22 @@ module BSV
30
30
  action_data
31
31
  end
32
32
 
33
+ def update_action_status(txid, new_status)
34
+ action = @actions.find { |a| a[:txid] == txid }
35
+ raise WalletError, "Action not found: #{txid}" unless action
36
+
37
+ action[:status] = new_status
38
+ action
39
+ end
40
+
41
+ def delete_action(txid)
42
+ idx = @actions.index { |a| a[:txid] == txid }
43
+ return false unless idx
44
+
45
+ @actions.delete_at(idx)
46
+ true
47
+ end
48
+
33
49
  def find_actions(query)
34
50
  apply_pagination(filter_actions(query), query)
35
51
  end
@@ -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.6.0'
6
6
  end
7
7
  end
@@ -39,6 +39,9 @@ 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
+
42
45
  # @param key [BSV::Primitives::PrivateKey, String, KeyDeriver] signing key
43
46
  # @param storage [StorageAdapter] persistence adapter (default: FileStore).
44
47
  # Use +storage: MemoryStore.new+ for tests.
@@ -46,6 +49,7 @@ module BSV
46
49
  # @param chain_provider [ChainProvider] blockchain data provider (default: NullChainProvider)
47
50
  # @param proof_store [ProofStore, nil] merkle proof store (default: LocalProofStore backed by storage)
48
51
  # @param http_client [#request, nil] injectable HTTP client for certificate issuance
52
+ # @param broadcaster [#broadcast, nil] optional broadcaster; any object responding to #broadcast(tx)
49
53
  def initialize(
50
54
  key,
51
55
  storage: FileStore.new,
@@ -55,7 +59,8 @@ module BSV
55
59
  http_client: nil,
56
60
  fee_estimator: nil,
57
61
  coin_selector: nil,
58
- change_generator: nil
62
+ change_generator: nil,
63
+ broadcaster: nil
59
64
  )
60
65
  super(key)
61
66
  @storage = storage
@@ -63,12 +68,19 @@ module BSV
63
68
  @chain_provider = chain_provider
64
69
  @proof_store = proof_store || LocalProofStore.new(storage)
65
70
  @http_client = http_client
71
+ @broadcaster = broadcaster
66
72
  @pending = {}
73
+ @pending_by_txid = {}
67
74
  @injected_fee_estimator = fee_estimator
68
75
  @injected_coin_selector = coin_selector
69
76
  @injected_change_generator = change_generator
70
77
  end
71
78
 
79
+ # Returns true when a broadcaster has been configured.
80
+ def broadcast_enabled?
81
+ !@broadcaster.nil?
82
+ end
83
+
72
84
  # --- Transaction Operations ---
73
85
 
74
86
  # Creates a new Bitcoin transaction.
@@ -92,11 +104,29 @@ module BSV
92
104
  def create_action(args, _originator: nil)
93
105
  validate_create_action!(args)
94
106
 
107
+ send_with_txids = Array(args.dig(:options, :send_with))
108
+
95
109
  outputs = args[:outputs] || []
96
110
  inputs = args[:inputs]
97
111
 
112
+ # When send_with is provided but no new transaction body is specified,
113
+ # broadcast only the batched no_send transactions.
114
+ if !send_with_txids.empty? && outputs.empty? && (inputs.nil? || inputs.empty?)
115
+ raise WalletError, 'A broadcaster is required to use send_with' unless broadcast_enabled?
116
+
117
+ return { send_with_results: broadcast_send_with(send_with_txids) }
118
+ end
119
+
98
120
  if (inputs.nil? || inputs.empty?) && !outputs.empty? && (args[:auto_fund] || spendable_pool_eligible?)
99
- return auto_fund_and_create(args, outputs)
121
+ result = auto_fund_and_create(args, outputs)
122
+ # If send_with was also specified, batch-broadcast those alongside the
123
+ # current transaction's implicit broadcast.
124
+ unless send_with_txids.empty?
125
+ raise WalletError, 'A broadcaster is required to use send_with' unless broadcast_enabled?
126
+
127
+ result[:send_with_results] = broadcast_send_with(send_with_txids)
128
+ end
129
+ return result
100
130
  end
101
131
 
102
132
  beef = parse_input_beef(args[:input_beef])
@@ -125,7 +155,15 @@ module BSV
125
155
  tx = pending[:tx]
126
156
  apply_spends(tx, args[:spends])
127
157
  @pending.delete(reference)
128
- finalize_action(tx, pending[:args])
158
+
159
+ # Merge sign_action's own options over the original create_action args so
160
+ # callers can supply accept_delayed_broadcast at sign time.
161
+ merged_args = if args[:options]
162
+ pending[:args].merge(options: (pending[:args][:options] || {}).merge(args[:options]))
163
+ else
164
+ pending[:args]
165
+ end
166
+ finalize_action(tx, merged_args)
129
167
  end
130
168
 
131
169
  # Aborts a pending signable transaction.
@@ -143,11 +181,14 @@ module BSV
143
181
  raise WalletError, 'Transaction not found for the given reference' unless @pending.key?(reference)
144
182
 
145
183
  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) }
184
+ txid = pending_entry[:tx]&.txid_hex
185
+ @pending_by_txid.delete(txid) if txid
186
+ rollback_pending_action(
187
+ pending_entry[:locked_outpoints],
188
+ pending_entry[:change_outpoints],
189
+ txid,
190
+ reference
191
+ )
151
192
  { aborted: true }
152
193
  end
153
194
 
@@ -347,7 +388,7 @@ module BSV
347
388
  # @return [Integer] total auto-spendable satoshis
348
389
  def spendable_balance(basket: nil)
349
390
  @storage.find_spendable_outputs(basket: basket)
350
- .select { |o| (o[:derivation_prefix] && o[:derivation_suffix] && o[:sender_identity_key]) || o[:derivation_type] == :identity }
391
+ .select { |o| (o[:derivation_prefix] && o[:derivation_suffix] && o[:sender_identity_key]) || o[:derivation_type]&.to_s == 'identity' }
351
392
  .sum { |o| o[:satoshis].to_i }
352
393
  end
353
394
 
@@ -613,7 +654,7 @@ module BSV
613
654
  all_spendable = @storage.find_spendable_outputs(basket: 'default')
614
655
  available = all_spendable.select do |o|
615
656
  (o[:derivation_prefix] && o[:derivation_suffix] && o[:sender_identity_key]) ||
616
- o[:derivation_type] == :identity
657
+ o[:derivation_type]&.to_s == 'identity'
617
658
  end
618
659
 
619
660
  selection = auto_fund_select(available, target, caller_outputs.size)
@@ -668,26 +709,61 @@ module BSV
668
709
  store_action(tx, args, status: 'nosend')
669
710
  store_tracked_outputs(txid, tx, caller_outputs)
670
711
 
671
- # Register in @pending so abort_action can release inputs and
672
- # remove change outputs.
712
+ # Register in @pending so abort_action (or send_with) can release
713
+ # inputs and remove change outputs. Secondary index by txid allows
714
+ # send_with callers to look up pending entries by txid.
673
715
  @pending[fund_ref] = {
674
716
  tx: tx, args: args,
675
717
  locked_outpoints: selected_outpoints,
676
718
  change_outpoints: change_outpoints
677
719
  }
720
+ @pending_by_txid[txid] = fund_ref
678
721
 
679
722
  beef_binary = tx.to_beef
680
723
  { txid: txid, tx: beef_binary.unpack('C*'), reference: fund_ref, no_send_change: change_outpoints }
681
724
  else
682
- store_action(tx, args, status: 'completed')
725
+ # Store everything in pending state first — inputs are already locked
726
+ # as :pending by lock_utxos above, so we match that discipline here.
727
+ store_action(tx, args, status: 'pending')
683
728
  store_change_outputs(txid, tx, change_outputs, tx_hex)
684
- store_tracked_outputs(txid, tx, caller_outputs)
685
729
 
686
- # Promote from :pending to :spent now that all storage writes are done.
687
- selected_outpoints.each { |op| @storage.update_output_state(op, :spent) }
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
741
+
742
+ store_tracked_outputs(txid, tx, caller_outputs)
688
743
 
689
744
  beef_binary = tx.to_beef
690
- { txid: txid, tx: beef_binary.unpack('C*') }
745
+
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
691
767
  end
692
768
  rescue StandardError
693
769
  # Release the pending lock so the UTXOs are available for retry.
@@ -803,7 +879,7 @@ module BSV
803
879
 
804
880
  wire_source_from_storage(input, utxo[:outpoint])
805
881
 
806
- priv = if utxo[:derivation_type] == :identity
882
+ priv = if utxo[:derivation_type]&.to_s == 'identity'
807
883
  @key_deriver.root_key
808
884
  else
809
885
  @key_deriver.derive_private_key(
@@ -897,7 +973,10 @@ module BSV
897
973
  Validators.validate_description!(args[:description])
898
974
  inputs_present = args[:inputs] && !args[:inputs].empty?
899
975
  outputs_present = args[:outputs] && !args[:outputs].empty?
900
- raise InvalidParameterError.new('inputs/outputs', 'at least one input or output') unless inputs_present || outputs_present
976
+ send_with_present = args.dig(:options, :send_with) && !Array(args.dig(:options, :send_with)).empty?
977
+ unless inputs_present || outputs_present || send_with_present
978
+ raise InvalidParameterError.new('inputs/outputs', 'at least one input or output')
979
+ end
901
980
 
902
981
  validate_action_inputs!(args[:inputs]) if args[:inputs]
903
982
  validate_action_outputs!(args[:outputs]) if args[:outputs]
@@ -1121,10 +1200,151 @@ module BSV
1121
1200
  end
1122
1201
  end
1123
1202
 
1203
+ # Rolls back a pending auto-funded action.
1204
+ #
1205
+ # Releases locked input UTXOs back to +:spendable+, deletes phantom change
1206
+ # outputs, and optionally updates the action status. Used by both
1207
+ # broadcast failure and +abort_action+ so UTXO state is always consistent.
1208
+ #
1209
+ # @param input_outpoints [Array<String>] outpoints locked as inputs
1210
+ # @param change_outpoints [Array<String>] change outputs to delete
1211
+ # @param txid [String, nil] txid of the action to update (may be nil)
1212
+ # @param ref [String] fund reference used when locking inputs
1213
+ # @param action_status [String, nil] new action status; nil skips the update
1214
+ def rollback_pending_action(input_outpoints, change_outpoints, txid, ref, action_status: nil)
1215
+ release_pending_utxos(input_outpoints, ref)
1216
+ Array(change_outpoints).each { |op| @storage.delete_output(op) }
1217
+ @storage.update_action_status(txid, action_status) if txid && action_status
1218
+ end
1219
+
1220
+ # Broadcasts the transaction and promotes storage state on success.
1221
+ # On failure, rolls back all pending state changes.
1222
+ #
1223
+ # @param tx [Transaction] signed transaction to broadcast
1224
+ # @param txid [String] hex transaction id
1225
+ # @param input_outpoints [Array<String>] outpoints locked as inputs
1226
+ # @param change_outpoints [Array<String>] change output outpoints
1227
+ # @param fund_ref [String] fund reference used when locking
1228
+ # @param beef_binary [String] raw BEEF bytes
1229
+ # @return [Hash] result hash with :txid, :tx, :broadcast_result or :broadcast_error, and :broadcast_status
1230
+ 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) }
1244
+ end
1245
+
1246
+ # Batch-broadcasts a list of previously no_send transactions.
1247
+ #
1248
+ # Each txid must correspond to a pending no_send entry registered in
1249
+ # +@pending_by_txid+. Transactions are broadcast individually so that one
1250
+ # failure does not block the others. On per-tx success the inputs are
1251
+ # promoted to +:spent+ and change outputs to +:spendable+. On failure the
1252
+ # pending state is rolled back via +rollback_pending_action+.
1253
+ #
1254
+ # @param txids [Array<String>] txids of no_send transactions to broadcast
1255
+ # @return [Array<Hash>] per-tx results matching TS SDK +SendWithResult[]+ shape:
1256
+ # +{ txid: String, status: 'unproven' | 'failed' }+
1257
+ def broadcast_send_with(txids)
1258
+ txids.map { |txid| broadcast_single_no_send(txid) }
1259
+ end
1260
+
1261
+ # Broadcasts one no_send transaction and promotes or rolls back its state.
1262
+ #
1263
+ # @param txid [String] hex txid of the no_send transaction
1264
+ # @return [Hash] +{ txid:, status: }+ result entry
1265
+ def broadcast_single_no_send(txid)
1266
+ fund_ref = @pending_by_txid[txid]
1267
+ return { txid: txid, status: 'failed', error: 'Transaction not found in pending no_send store' } unless fund_ref
1268
+
1269
+ pending_entry = @pending[fund_ref]
1270
+ unless pending_entry
1271
+ @pending_by_txid.delete(txid)
1272
+ return { txid: txid, status: 'failed', error: 'Pending entry expired or already processed' }
1273
+ end
1274
+
1275
+ # Use the original signed transaction from the pending entry — it has
1276
+ # source_satoshis and source_locking_script wired on each input, which
1277
+ # the broadcaster needs for Extended Format (EF/BRC-30) submission.
1278
+ # Reconstructing from stored hex would lose that metadata.
1279
+ tx = pending_entry[:tx]
1280
+ unless tx
1281
+ tx_hex = @storage.find_transaction(txid)
1282
+ return { txid: txid, status: 'failed', error: 'Transaction not found' } unless tx_hex
1283
+
1284
+ tx = BSV::Transaction::Transaction.from_hex(tx_hex)
1285
+ end
1286
+ promote_no_send(tx, txid, fund_ref, pending_entry)
1287
+ rescue StandardError => e
1288
+ # Catch unexpected errors outside the broadcast rescue block.
1289
+ { txid: txid, status: 'failed', error: e.message }
1290
+ end
1291
+
1292
+ # Attempts to broadcast and promote a single no_send transaction.
1293
+ # Action status is set to 'unproven' (matching TS SDK SendWithResult)
1294
+ # because the transaction has been submitted but not yet confirmed.
1295
+ def promote_no_send(tx, txid, fund_ref, pending_entry)
1296
+ @broadcaster.broadcast(tx)
1297
+
1298
+ # Broadcast succeeded — promote state and remove from pending indices.
1299
+ pending_entry[:locked_outpoints].each { |op| @storage.update_output_state(op, :spent) }
1300
+ pending_entry[:change_outpoints].each { |op| @storage.update_output_state(op, :spendable) }
1301
+ @storage.update_action_status(txid, 'unproven')
1302
+ @pending.delete(fund_ref)
1303
+ @pending_by_txid.delete(txid)
1304
+
1305
+ { 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
+ end
1319
+
1320
+ # Maps a broadcast exception to a {ReviewActionResultStatus} string.
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
+ 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'
1334
+ end
1335
+
1124
1336
  def finalize_action(tx, args)
1125
1337
  tx.sign_all if tx.inputs.any?(&:unlocking_script_template)
1126
1338
  txid = tx.txid_hex
1127
- status = args.dig(:options, :no_send) ? 'nosend' : 'completed'
1339
+
1340
+ no_send = args.dig(:options, :no_send)
1341
+ delayed = args.dig(:options, :accept_delayed_broadcast)
1342
+
1343
+ status = if no_send
1344
+ 'nosend'
1345
+ else
1346
+ 'pending'
1347
+ end
1128
1348
 
1129
1349
  @storage.store_transaction(txid, tx.to_hex)
1130
1350
  store_action(tx, args, status: status)
@@ -1132,7 +1352,35 @@ module BSV
1132
1352
 
1133
1353
  beef_binary = tx.to_beef
1134
1354
  result = { txid: txid, tx: beef_binary.unpack('C*') }
1135
- result[:no_send_change] = [] if args.dig(:options, :no_send)
1355
+
1356
+ if no_send
1357
+ 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
+ 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)
1382
+ end
1383
+
1136
1384
  result
1137
1385
  end
1138
1386
 
@@ -1281,6 +1529,7 @@ module BSV
1281
1529
  outpoint: "#{txid}.#{output_index}",
1282
1530
  satoshis: tx_output.satoshis,
1283
1531
  locking_script: tx_output.locking_script.to_hex,
1532
+ basket: 'default',
1284
1533
  spendable: true,
1285
1534
  sender_identity_key: sender_key,
1286
1535
  derivation_prefix: prefix,
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.6.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: 2026-04-12 00:00:00.000000000 Z
11
11
  dependencies:
12
12
  - !ruby/object:Gem::Dependency
13
13
  name: base64