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 +4 -4
- data/CHANGELOG.md +28 -0
- data/lib/bsv/wallet_interface/fee_estimator.rb +26 -10
- data/lib/bsv/wallet_interface/file_store.rb +12 -0
- data/lib/bsv/wallet_interface/key_deriver.rb +2 -0
- data/lib/bsv/wallet_interface/memory_store.rb +16 -0
- data/lib/bsv/wallet_interface/storage_adapter.rb +20 -0
- data/lib/bsv/wallet_interface/version.rb +1 -1
- data/lib/bsv/wallet_interface/wallet_client.rb +270 -21
- metadata +2 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 832766edaf4b3b59b4a5a2bdf47e997972b62336ddbf65614e5445f254615fa4
|
|
4
|
+
data.tar.gz: c2bb017e369d5b7497c134fde1d5fd729e8952cc8a11bb04c05eaa6ad7f6ebf2
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
|
12
|
-
#
|
|
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
|
-
# # =>
|
|
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
|
-
|
|
26
|
-
|
|
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
|
-
#
|
|
32
|
-
|
|
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
|
|
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:
|
|
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
|
|
@@ -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
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
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] ==
|
|
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] ==
|
|
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
|
|
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
|
-
|
|
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
|
-
#
|
|
687
|
-
|
|
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
|
-
|
|
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] ==
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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-
|
|
10
|
+
date: 2026-04-12 00:00:00.000000000 Z
|
|
11
11
|
dependencies:
|
|
12
12
|
- !ruby/object:Gem::Dependency
|
|
13
13
|
name: base64
|