bsv-wallet 0.3.4 → 0.5.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.
@@ -4,6 +4,7 @@ require 'securerandom'
4
4
  require 'base64'
5
5
  require 'net/http'
6
6
  require 'json'
7
+ require 'set'
7
8
  require 'uri'
8
9
 
9
10
  module BSV
@@ -45,7 +46,17 @@ module BSV
45
46
  # @param chain_provider [ChainProvider] blockchain data provider (default: NullChainProvider)
46
47
  # @param proof_store [ProofStore, nil] merkle proof store (default: LocalProofStore backed by storage)
47
48
  # @param http_client [#request, nil] injectable HTTP client for certificate issuance
48
- def initialize(key, storage: FileStore.new, network: 'mainnet', chain_provider: NullChainProvider.new, proof_store: nil, http_client: nil)
49
+ def initialize(
50
+ key,
51
+ storage: FileStore.new,
52
+ network: 'mainnet',
53
+ chain_provider: NullChainProvider.new,
54
+ proof_store: nil,
55
+ http_client: nil,
56
+ fee_estimator: nil,
57
+ coin_selector: nil,
58
+ change_generator: nil
59
+ )
49
60
  super(key)
50
61
  @storage = storage
51
62
  @network = network
@@ -53,23 +64,41 @@ module BSV
53
64
  @proof_store = proof_store || LocalProofStore.new(storage)
54
65
  @http_client = http_client
55
66
  @pending = {}
67
+ @injected_fee_estimator = fee_estimator
68
+ @injected_coin_selector = coin_selector
69
+ @injected_change_generator = change_generator
56
70
  end
57
71
 
58
72
  # --- Transaction Operations ---
59
73
 
60
74
  # Creates a new Bitcoin transaction.
61
75
  #
62
- # If all inputs carry unlocking_script values, the transaction is
63
- # finalised immediately and returned with :txid and :tx (BEEF bytes).
64
- # If any input specifies only unlocking_script_length, the transaction
65
- # is held pending and returned as a signable_transaction for external
66
- # signing via {#sign_action}.
76
+ # When the +:auto_fund+ option is +true+ (and +:outputs+ are provided
77
+ # but no +:inputs+), the wallet automatically selects UTXOs from the
78
+ # default basket, estimates fees, generates change, and returns a
79
+ # complete signed transaction (auto-fund mode).
80
+ #
81
+ # Without +:auto_fund+, if all inputs carry unlocking_script values,
82
+ # the transaction is finalised immediately and returned with :txid and
83
+ # :tx (BEEF bytes). If any input specifies only unlocking_script_length,
84
+ # the transaction is held pending and returned as a signable_transaction
85
+ # for external signing via {#sign_action}.
67
86
  #
68
87
  # @param args [Hash] transaction parameters
88
+ # @option args [Boolean] :auto_fund when +true+, automatically selects
89
+ # UTXOs and generates change; requires +:outputs+ and no +:inputs+
69
90
  # @param _originator [String, nil] FQDN of the originating application
70
91
  # @return [Hash] finalised result or signable_transaction
71
92
  def create_action(args, _originator: nil)
72
93
  validate_create_action!(args)
94
+
95
+ outputs = args[:outputs] || []
96
+ inputs = args[:inputs]
97
+
98
+ if (inputs.nil? || inputs.empty?) && !outputs.empty? && (args[:auto_fund] || spendable_pool_eligible?)
99
+ return auto_fund_and_create(args, outputs)
100
+ end
101
+
73
102
  beef = parse_input_beef(args[:input_beef])
74
103
  tx = build_transaction(args, beef)
75
104
 
@@ -101,6 +130,10 @@ module BSV
101
130
 
102
131
  # Aborts a pending signable transaction.
103
132
  #
133
+ # If the pending entry holds UTXO references (stored by auto-fund), any
134
+ # outputs locked as +:pending+ with that reference are released back to
135
+ # +:spendable+ so they become eligible for future coin selection.
136
+ #
104
137
  # @param args [Hash]
105
138
  # @option args [String] :reference base64 reference to abort
106
139
  # @param _originator [String, nil] FQDN of the originating application
@@ -109,7 +142,12 @@ module BSV
109
142
  reference = args[:reference]
110
143
  raise WalletError, 'Transaction not found for the given reference' unless @pending.key?(reference)
111
144
 
112
- @pending.delete(reference)
145
+ 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) }
113
151
  { aborted: true }
114
152
  end
115
153
 
@@ -180,6 +218,13 @@ module BSV
180
218
  validate_internalize_action!(args)
181
219
  beef_binary = args[:tx].pack('C*')
182
220
  beef = BSV::Transaction::Beef.from_binary(beef_binary)
221
+
222
+ # F8.14: verify the BEEF bundle before trusting its contents.
223
+ # Pass the chain provider if it supports SPV root verification;
224
+ # otherwise fall back to structural validation via valid?.
225
+ chain_tracker = @chain_provider.respond_to?(:valid_root_for_height?) ? @chain_provider : nil
226
+ raise WalletError, 'BEEF verification failed: the bundle is structurally invalid' unless beef.verify(chain_tracker)
227
+
183
228
  tx = extract_subject_transaction(beef)
184
229
 
185
230
  store_proofs_from_beef(beef)
@@ -226,6 +271,105 @@ module BSV
226
271
  { version: "bsv-wallet-#{BSV::WalletInterface::VERSION}" }
227
272
  end
228
273
 
274
+ # Discovers on-chain UTXOs for the wallet's identity address and imports
275
+ # any that are not already present in storage.
276
+ #
277
+ # Each imported output is stored in the 'default' basket with state
278
+ # +:spendable+ and +derivation_type: :identity+. The +:identity+
279
+ # derivation type signals to the auto-fund signing path that the root
280
+ # private key should be used directly (these outputs lack BRC-29
281
+ # derivation metadata).
282
+ #
283
+ # The method is idempotent — calling it twice with the same UTXO set
284
+ # produces no duplicates.
285
+ #
286
+ # @return [Integer] number of new UTXOs imported
287
+ def sync_utxos
288
+ address = identity_address
289
+ utxos = @chain_provider.get_utxos(address)
290
+ return 0 if utxos.empty?
291
+
292
+ imported = 0
293
+ utxos.each do |utxo|
294
+ outpoint = "#{utxo[:tx_hash]}.#{utxo[:tx_pos]}"
295
+ next if output_exists?(outpoint)
296
+
297
+ tx_hex = @chain_provider.get_transaction(utxo[:tx_hash])
298
+ tx = BSV::Transaction::Transaction.from_hex(tx_hex)
299
+
300
+ pos = utxo[:tx_pos]
301
+ unless pos.is_a?(Integer) && pos >= 0 && pos < tx.outputs.length
302
+ raise WalletError, "Invalid tx_pos #{pos.inspect} for #{utxo[:tx_hash]} (#{tx.outputs.length} outputs)"
303
+ end
304
+
305
+ locking_script_hex = tx.outputs[pos].locking_script.to_hex
306
+
307
+ @storage.store_output({
308
+ outpoint: outpoint,
309
+ satoshis: utxo[:value],
310
+ locking_script: locking_script_hex,
311
+ basket: 'default',
312
+ tags: [],
313
+ derivation_type: :identity,
314
+ state: :spendable,
315
+ source_tx_hex: tx_hex
316
+ })
317
+ @storage.store_transaction(utxo[:tx_hash], tx_hex)
318
+ imported += 1
319
+ end
320
+
321
+ imported
322
+ end
323
+
324
+ # --- UTXO Pool & Settings ---
325
+
326
+ # Returns the total spendable satoshis across all baskets (or a named basket).
327
+ #
328
+ # Includes every output in +:spendable+ state — regardless of whether the
329
+ # wallet holds the signing key. This answers "how much does this wallet hold?",
330
+ # not "how much can it auto-spend?". Use {#spendable_balance} for the latter.
331
+ #
332
+ # @param basket [String, nil] the basket to total, or +nil+ for all baskets
333
+ # @return [Integer] sum of all spendable output values
334
+ def balance(basket: nil)
335
+ @storage.find_spendable_outputs(basket: basket).sum { |o| o[:satoshis].to_i }
336
+ end
337
+
338
+ # Returns the total satoshis of outputs the wallet can automatically spend.
339
+ #
340
+ # Only outputs carrying full BRC-29 derivation metadata
341
+ # (+derivation_prefix+, +derivation_suffix+, +sender_identity_key+) are
342
+ # counted — these are the outputs the wallet can sign without external input.
343
+ # Basket-only outputs (e.g. tokens without derivation data) contribute to
344
+ # {#balance} but not here.
345
+ #
346
+ # @param basket [String, nil] restrict to a named basket, or +nil+ for all
347
+ # @return [Integer] total auto-spendable satoshis
348
+ def spendable_balance(basket: nil)
349
+ @storage.find_spendable_outputs(basket: basket)
350
+ .select { |o| (o[:derivation_prefix] && o[:derivation_suffix] && o[:sender_identity_key]) || o[:derivation_type] == :identity }
351
+ .sum { |o| o[:satoshis].to_i }
352
+ end
353
+
354
+ # Configures the target UTXO pool parameters for change generation.
355
+ #
356
+ # When set, the auto-fund path will use these parameters to decide how
357
+ # many change outputs to produce:
358
+ # - If the pool is below +:count+, more change outputs are generated
359
+ # (up to +max_outputs+) to build up the UTXO pool.
360
+ # - If the pool is at or above +:count+, fewer outputs are generated
361
+ # (1-2) to avoid fragmenting the pool unnecessarily.
362
+ #
363
+ # @param count [Integer] desired number of spendable UTXOs in 'default' basket
364
+ # @param satoshis [Integer] desired average value per UTXO in satoshis
365
+ # @raise [InvalidParameterError] if +count+ or +satoshis+ are not positive integers
366
+ def set_wallet_change_params(count:, satoshis:)
367
+ raise InvalidParameterError.new('count', 'a positive Integer') unless count.is_a?(Integer) && count.positive?
368
+ raise InvalidParameterError.new('satoshis', 'a positive Integer') unless satoshis.is_a?(Integer) && satoshis.positive?
369
+
370
+ @storage.store_setting('change_params', { count: count, satoshis: satoshis })
371
+ end
372
+
229
373
  # --- Authentication ---
230
374
 
231
375
  # Checks whether the user is authenticated.
@@ -400,8 +544,353 @@ module BSV
400
544
  { total_certificates: total, certificates: certs.map { |c| cert_without_keyring(c) } }
401
545
  end
402
546
 
547
+ # Maximum ancestor depth to traverse when wiring source transactions.
548
+ # Guards against stack overflow on pathologically deep or cyclic chains.
549
+ ANCESTOR_DEPTH_CAP = 64
550
+
551
+ # Rate-limits stale pending recovery to avoid O(n) scans on every
552
+ # auto-fund call. Skips if called again within this interval.
553
+ STALE_CHECK_INTERVAL = 30
554
+
403
555
  private
404
556
 
557
+ # --- Identity helpers ---
558
+
559
+ # Derives the wallet's identity P2PKH address.
560
+ #
561
+ # The network string 'mainnet'/'testnet' is mapped to the PublicKey
562
+ # address method's +:network+ symbol (:mainnet/:testnet).
563
+ #
564
+ # @return [String] Base58Check-encoded P2PKH address
565
+ def identity_address
566
+ net = @network == 'testnet' ? :testnet : :mainnet
567
+ @key_deriver.root_key.public_key.address(network: net)
568
+ end
569
+
570
+ # Returns true if an output with the given outpoint is already in storage,
571
+ # regardless of its state (spendable, spent, or pending).
572
+ # @param outpoint [String] outpoint in "txid.index" format
573
+ # @return [Boolean]
574
+ def output_exists?(outpoint)
575
+ @storage.find_outputs({ outpoint: outpoint, include_spent: true, limit: 1, offset: 0 }).any?
576
+ end
577
+
578
+ # Returns true if the spendable pool contains any state-managed outputs.
579
+ #
580
+ # State-managed outputs are those with an explicit +:state+ field
581
+ # (as opposed to the legacy +:spendable+ boolean used by
582
+ # {#store_tracked_outputs} for basket token insertions). This distinction
583
+ # allows auto-funding to trigger on payment receipts and chain UTXOs while
584
+ # bypassing the gate for basket tracking outputs stored in no-input
585
+ # transactions, which do not carry a spending key derivable by auto-fund.
586
+ #
587
+ # When no state-managed outputs are present, +create_action+ falls through
588
+ # to the standard (no-input) path rather than attempting coin selection.
589
+ # Callers needing forced auto-fund on an empty pool (e.g. explicit
590
+ # +auto_fund: true+) bypass this check via the flag.
591
+ #
592
+ # @return [Boolean]
593
+ def spendable_pool_eligible?
594
+ @storage.find_spendable_outputs.any? { |o| o.key?(:state) }
595
+ end
596
+
597
+ # --- Auto-fund helpers ---
598
+
599
+ # Orchestrates the full auto-fund flow: coin selection, fee convergence,
600
+ # transaction building, signing, and state persistence.
601
+ #
602
+ # Selected UTXOs are immediately locked as +:pending+ (with a unique
603
+ # reference) before building the transaction. If signing or finalisation
604
+ # raises an error the lock is released and the UTXOs are returned to
605
+ # +:spendable+ so they can be used by a subsequent attempt.
606
+ #
607
+ # @param args [Hash] original create_action params
608
+ # @param caller_outputs [Array<Hash>] the caller-specified output specs
609
+ # @return [Hash] finalised result with :txid and :tx
610
+ def auto_fund_and_create(args, caller_outputs)
611
+ release_stale_if_due
612
+ target = caller_outputs.sum { |o| o[:satoshis] || 0 }
613
+ all_spendable = @storage.find_spendable_outputs
614
+ available = all_spendable.select do |o|
615
+ (o[:derivation_prefix] && o[:derivation_suffix] && o[:sender_identity_key]) ||
616
+ o[:derivation_type] == :identity
617
+ end
618
+
619
+ selection = auto_fund_select(available, target, caller_outputs.size)
620
+ change_outputs = converge_change(selection, caller_outputs.size)
621
+
622
+ no_send = args.dig(:options, :no_send)
623
+
624
+ # Atomically lock the selected UTXOs as pending to prevent concurrent
625
+ # double-spend (closes the TOCTOU window between find and lock).
626
+ fund_ref = "auto-fund-#{SecureRandom.hex(16)}"
627
+ selected_outpoints = selection[:inputs].map { |u| u[:outpoint] }
628
+ locked = @storage.lock_utxos(selected_outpoints, reference: fund_ref, no_send: no_send)
629
+
630
+ # If another thread grabbed some UTXOs between selection and locking,
631
+ # release what we did lock and raise — the caller can retry.
632
+ if locked.size < selected_outpoints.size
633
+ release_pending_utxos(locked, fund_ref)
634
+ raise InsufficientFundsError.new(
635
+ required: target,
636
+ available: locked.sum { |op| selection[:inputs].find { |u| u[:outpoint] == op }&.fetch(:satoshis, 0) || 0 }
637
+ )
638
+ end
639
+
640
+ begin
641
+ tx = build_auto_funded_transaction(selection[:inputs], caller_outputs, change_outputs, args)
642
+ tx.sign_all
643
+
644
+ txid = tx.txid_hex
645
+ tx_hex = tx.to_hex
646
+
647
+ # Persist the transaction first; only promote state once all writes succeed.
648
+ @storage.store_transaction(txid, tx_hex)
649
+
650
+ 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
667
+
668
+ store_action(tx, args, status: 'nosend')
669
+ store_tracked_outputs(txid, tx, caller_outputs)
670
+
671
+ # Register in @pending so abort_action can release inputs and
672
+ # remove change outputs.
673
+ @pending[fund_ref] = {
674
+ tx: tx, args: args,
675
+ locked_outpoints: selected_outpoints,
676
+ change_outpoints: change_outpoints
677
+ }
678
+
679
+ beef_binary = tx.to_beef
680
+ { txid: txid, tx: beef_binary.unpack('C*'), reference: fund_ref, no_send_change: change_outpoints }
681
+ 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)
685
+
686
+ # Promote from :pending to :spent now that all storage writes are done.
687
+ selected_outpoints.each { |op| @storage.update_output_state(op, :spent) }
688
+
689
+ beef_binary = tx.to_beef
690
+ { txid: txid, tx: beef_binary.unpack('C*') }
691
+ end
692
+ rescue StandardError
693
+ # Release the pending lock so the UTXOs are available for retry.
694
+ release_pending_utxos(selected_outpoints, fund_ref)
695
+ raise
696
+ end
697
+ end
698
+
699
+ # Runs coin selection, raising {InsufficientFundsError} on failure.
700
+ #
701
+ # @param available [Array<Hash>] spendable UTXOs
702
+ # @param target [Integer] total satoshis required by caller outputs
703
+ # @param num_outputs [Integer] number of caller outputs
704
+ # @return [Hash] coin selection result
705
+ def auto_fund_select(available, target, num_outputs)
706
+ auto_coin_selector.select(
707
+ available: available,
708
+ target_satoshis: target,
709
+ num_outputs: num_outputs
710
+ )
711
+ end
712
+
713
+ # Iteratively converges on a fee-stable set of change outputs.
714
+ #
715
+ # After initial selection, adding change outputs increases the transaction
716
+ # size and therefore the fee. This method recalculates the fee with the
717
+ # final output count and adjusts the excess until stable.
718
+ #
719
+ # @param selection [Hash] initial coin selection result
720
+ # @param num_caller_outputs [Integer] number of caller-specified outputs
721
+ # @return [Array<Hash>] finalised change output descriptors (may be empty)
722
+ def converge_change(selection, num_caller_outputs)
723
+ pool_opts = load_pool_opts
724
+
725
+ change_outputs = auto_change_generator.generate(
726
+ excess_satoshis: selection[:excess],
727
+ num_existing_outputs: num_caller_outputs,
728
+ **pool_opts
729
+ )
730
+
731
+ # Recalculate fee with the actual output count (caller + change).
732
+ total_outputs = num_caller_outputs + change_outputs.size
733
+ revised_fee = auto_fee_estimator.estimate(
734
+ p2pkh_inputs: selection[:inputs].size,
735
+ p2pkh_outputs: total_outputs
736
+ )
737
+
738
+ fee_delta = revised_fee - selection[:fee]
739
+ return change_outputs if fee_delta.zero?
740
+
741
+ # Adjust the excess to account for the revised fee and regenerate change.
742
+ adjusted_excess = selection[:excess] - fee_delta
743
+ return [] if adjusted_excess <= 0
744
+
745
+ auto_change_generator.generate(
746
+ excess_satoshis: adjusted_excess,
747
+ num_existing_outputs: num_caller_outputs,
748
+ **pool_opts
749
+ )
750
+ end
751
+
752
+ # Loads pool health options for ChangeGenerator from stored settings.
753
+ #
754
+ # @return [Hash] keyword args for ChangeGenerator#generate
755
+ def load_pool_opts
756
+ params = @storage.find_setting('change_params')
757
+ return {} unless params
758
+
759
+ pool_size = @storage.find_spendable_outputs.size
760
+ { pool_size: pool_size, change_params: params }
761
+ end
762
+
763
+ # Builds the Transaction object for an auto-funded action.
764
+ #
765
+ # Inputs are wired with source data from storage and assigned P2PKH
766
+ # unlocking templates using the derived private key. Outputs are built
767
+ # from the caller specs followed by any change outputs.
768
+ #
769
+ # @param selected_utxos [Array<Hash>] UTXOs chosen by coin selection
770
+ # @param caller_outputs [Array<Hash>] caller-specified output specs
771
+ # @param change_outputs [Array<Hash>] change output descriptors from ChangeGenerator
772
+ # @param args [Hash] original create_action params (for version/lock_time)
773
+ # @return [BSV::Transaction::Transaction]
774
+ def build_auto_funded_transaction(selected_utxos, caller_outputs, change_outputs, args)
775
+ version = args.fetch(:version, 1)
776
+ lock_time = args.fetch(:lock_time, 0)
777
+ tx = BSV::Transaction::Transaction.new(version: version, lock_time: lock_time)
778
+
779
+ selected_utxos.each { |utxo| add_auto_funded_input(tx, utxo) }
780
+ caller_outputs.each { |spec| add_output_from_spec(tx, spec) }
781
+ change_outputs.each { |spec| add_output_from_spec(tx, spec) }
782
+
783
+ # Randomise unless explicitly disabled
784
+ shuffle_outputs(tx) if args.dig(:options, :randomize_outputs) != false && (caller_outputs.size + change_outputs.size) > 1
785
+
786
+ tx
787
+ end
788
+
789
+ # Adds a single auto-funded input to the transaction, wiring source data
790
+ # and attaching a P2PKH unlocking template using the derived private key.
791
+ #
792
+ # @param tx [BSV::Transaction::Transaction]
793
+ # @param utxo [Hash] UTXO hash from storage
794
+ def add_auto_funded_input(tx, utxo)
795
+ txid_hex, index_str = utxo[:outpoint].split('.')
796
+ output_index = index_str.to_i
797
+
798
+ input = BSV::Transaction::TransactionInput.new(
799
+ prev_tx_id: BSV::Transaction::TransactionInput.txid_from_hex(txid_hex),
800
+ prev_tx_out_index: output_index,
801
+ sequence: 0xFFFFFFFF
802
+ )
803
+
804
+ wire_source_from_storage(input, utxo[:outpoint])
805
+
806
+ priv = if utxo[:derivation_type] == :identity
807
+ @key_deriver.root_key
808
+ else
809
+ @key_deriver.derive_private_key(
810
+ ChangeGenerator::BRC29_PROTOCOL_ID,
811
+ "#{utxo[:derivation_prefix]} #{utxo[:derivation_suffix]}",
812
+ utxo[:sender_identity_key]
813
+ )
814
+ end
815
+ input.unlocking_script_template = BSV::Transaction::P2PKH.new(priv)
816
+
817
+ tx.add_input(input)
818
+ end
819
+
820
+ # Adds a TransactionOutput to tx from an output spec hash.
821
+ # Tags the output with its spec so store_tracked_outputs can find it
822
+ # by object identity after shuffling.
823
+ #
824
+ # @param tx [BSV::Transaction::Transaction]
825
+ # @param spec [Hash] output descriptor with :satoshis and :locking_script
826
+ def add_output_from_spec(tx, spec)
827
+ locking_script = if spec[:locking_script].is_a?(BSV::Script::Script)
828
+ spec[:locking_script]
829
+ else
830
+ BSV::Script::Script.from_hex(spec[:locking_script])
831
+ end
832
+ output = BSV::Transaction::TransactionOutput.new(
833
+ satoshis: spec[:satoshis],
834
+ locking_script: locking_script
835
+ )
836
+ output.instance_variable_set(:@_spec, spec)
837
+ tx.add_output(output)
838
+ end
839
+
840
+ # Persists change outputs in storage as new spendable UTXOs.
841
+ #
842
+ # 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.
845
+ #
846
+ # @param txid [String] hex txid of the signed transaction
847
+ # @param tx [BSV::Transaction::Transaction]
848
+ # @param change_specs [Array<Hash>] change descriptors from {ChangeGenerator}
849
+ # @param tx_hex [String] serialised transaction hex (used as source)
850
+ def store_change_outputs(txid, tx, change_specs, tx_hex)
851
+ change_specs.each do |spec|
852
+ actual_idx = tx.outputs.index { |o| o.instance_variable_get(:@_spec).equal?(spec) }
853
+ next unless actual_idx
854
+
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
860
+
861
+ @storage.store_output({
862
+ outpoint: "#{txid}.#{actual_idx}",
863
+ satoshis: spec[:satoshis],
864
+ locking_script: locking_script_hex,
865
+ basket: nil,
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
+ })
873
+ end
874
+ end
875
+
876
+ # Lazy accessors for auto-fund components. Created on first use so the
877
+ # wallet is not penalised when the auto-fund path is never exercised.
878
+
879
+ def auto_fee_estimator
880
+ @auto_fee_estimator ||= @injected_fee_estimator || FeeEstimator.new
881
+ end
882
+
883
+ def auto_coin_selector
884
+ @auto_coin_selector ||= @injected_coin_selector || CoinSelector.new(fee_estimator: auto_fee_estimator)
885
+ end
886
+
887
+ def auto_change_generator
888
+ @auto_change_generator ||= @injected_change_generator || ChangeGenerator.new(
889
+ key_deriver: @key_deriver,
890
+ fee_estimator: auto_fee_estimator
891
+ )
892
+ end
893
+
405
894
  # --- Validation ---
406
895
 
407
896
  def validate_create_action!(args)
@@ -521,7 +1010,10 @@ module BSV
521
1010
  end
522
1011
 
523
1012
  def wire_source_from_storage(input, outpoint)
524
- results = @storage.find_outputs({ outpoint: outpoint, limit: 1 })
1013
+ # Use include_spent: true so that outputs in :pending or :spent state
1014
+ # can still be wired — the lock was just placed by the auto-fund flow
1015
+ # and the source transaction data is still needed for signing.
1016
+ results = @storage.find_outputs({ outpoint: outpoint, include_spent: true, limit: 1 })
525
1017
  stored = results.first
526
1018
  return unless stored
527
1019
 
@@ -542,18 +1034,28 @@ module BSV
542
1034
  input.source_transaction = source_tx
543
1035
  end
544
1036
 
545
- def wire_source_tx_ancestors(tx)
1037
+ def wire_source_tx_ancestors(tx, visited: nil, depth: 0)
1038
+ return if depth >= ANCESTOR_DEPTH_CAP
1039
+
1040
+ visited ||= Set.new
1041
+ tx_txid = tx.txid_hex
1042
+ return if visited.include?(tx_txid)
1043
+
1044
+ visited.add(tx_txid)
1045
+
546
1046
  tx.inputs.each do |inp|
547
1047
  next if inp.source_transaction
548
1048
 
549
1049
  ancestor_txid_hex = inp.prev_tx_id.reverse.unpack1('H*')
1050
+ next if visited.include?(ancestor_txid_hex)
1051
+
550
1052
  tx_hex = @storage.find_transaction(ancestor_txid_hex)
551
1053
  next unless tx_hex
552
1054
 
553
1055
  ancestor_tx = BSV::Transaction::Transaction.from_hex(tx_hex)
554
1056
  proof = @proof_store.resolve_proof(ancestor_txid_hex)
555
1057
  ancestor_tx.merkle_path = proof if proof
556
- wire_source_tx_ancestors(ancestor_tx) unless ancestor_tx.merkle_path
1058
+ wire_source_tx_ancestors(ancestor_tx, visited: visited, depth: depth + 1) unless ancestor_tx.merkle_path
557
1059
  inp.source_transaction = ancestor_tx
558
1060
  end
559
1061
  end
@@ -593,6 +1095,32 @@ module BSV
593
1095
  { signable_transaction: { tx: tx_bytes.unpack('C*'), reference: reference } }
594
1096
  end
595
1097
 
1098
+ # Reverts a set of outpoints from +:pending+ back to +:spendable+.
1099
+ #
1100
+ # Only releases an outpoint if its stored +:pending_reference+ matches
1101
+ # +ref+, to avoid accidentally unlocking UTXOs locked by a different
1102
+ # concurrent operation.
1103
+ #
1104
+ # @param outpoints [Array<String>] list of outpoints to release
1105
+ # @param ref [String] the reference used when locking
1106
+ def release_stale_if_due
1107
+ now = Time.now.utc
1108
+ return if @last_stale_check && (now - @last_stale_check) < STALE_CHECK_INTERVAL
1109
+
1110
+ @storage.release_stale_pending!
1111
+ @last_stale_check = now
1112
+ end
1113
+
1114
+ def release_pending_utxos(outpoints, ref)
1115
+ Array(outpoints).each do |op|
1116
+ outputs = @storage.find_outputs({ outpoint: op, include_spent: true, limit: 1, offset: 0 })
1117
+ next if outputs.empty?
1118
+ next unless outputs.first[:pending_reference] == ref
1119
+
1120
+ @storage.update_output_state(op, :spendable)
1121
+ end
1122
+ end
1123
+
596
1124
  def finalize_action(tx, args)
597
1125
  tx.sign_all if tx.inputs.any?(&:unlocking_script_template)
598
1126
  txid = tx.txid_hex
@@ -740,7 +1268,7 @@ module BSV
740
1268
 
741
1269
  # BRC-29: derive the expected P2PKH key for this payment
742
1270
  derived_pub = @key_deriver.derive_public_key(
743
- [2, '3241645161d8'],
1271
+ ChangeGenerator::BRC29_PROTOCOL_ID,
744
1272
  "#{prefix} #{suffix}",
745
1273
  sender_key,
746
1274
  for_self: true