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.
- checksums.yaml +4 -4
- data/CHANGELOG-wallet.md +332 -0
- data/lib/bsv/wallet_interface/chain_provider.rb +14 -0
- data/lib/bsv/wallet_interface/change_generator.rb +192 -0
- data/lib/bsv/wallet_interface/coin_selector.rb +132 -0
- data/lib/bsv/wallet_interface/fee_estimator.rb +124 -0
- data/lib/bsv/wallet_interface/fee_model.rb +21 -0
- data/lib/bsv/wallet_interface/file_store.rb +39 -1
- data/lib/bsv/wallet_interface/memory_store.rb +166 -3
- data/lib/bsv/wallet_interface/null_chain_provider.rb +9 -1
- data/lib/bsv/wallet_interface/proto_wallet.rb +1 -1
- data/lib/bsv/wallet_interface/storage_adapter.rb +79 -0
- data/lib/bsv/wallet_interface/validators.rb +36 -7
- data/lib/bsv/wallet_interface/version.rb +1 -1
- data/lib/bsv/wallet_interface/wallet_client.rb +539 -11
- data/lib/bsv/wallet_interface/whats_on_chain_provider.rb +62 -0
- data/lib/bsv/wallet_interface.rb +8 -2
- metadata +11 -5
|
@@ -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(
|
|
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
|
-
#
|
|
63
|
-
#
|
|
64
|
-
#
|
|
65
|
-
#
|
|
66
|
-
#
|
|
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
|
-
|
|
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
|
-
|
|
1271
|
+
ChangeGenerator::BRC29_PROTOCOL_ID,
|
|
744
1272
|
"#{prefix} #{suffix}",
|
|
745
1273
|
sender_key,
|
|
746
1274
|
for_self: true
|