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
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module BSV
|
|
4
|
+
module Wallet
|
|
5
|
+
# Estimates transaction fees before a {BSV::Transaction::Transaction} object exists.
|
|
6
|
+
#
|
|
7
|
+
# Wraps {BSV::Transaction::FeeModels::SatoshisPerKilobyte} to provide pre-construction
|
|
8
|
+
# fee estimation given only input and output counts. Once a real {Transaction} is
|
|
9
|
+
# available, delegates directly to the underlying SDK fee model via {#estimate_for_tx}.
|
|
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.
|
|
13
|
+
#
|
|
14
|
+
# @example Pre-construction estimate
|
|
15
|
+
# estimator = BSV::Wallet::FeeEstimator.new
|
|
16
|
+
# fee = estimator.estimate(p2pkh_inputs: 2, p2pkh_outputs: 3)
|
|
17
|
+
# # => 1 (at 1 sat/kB, ceil(408/1000 * 1) = 1)
|
|
18
|
+
#
|
|
19
|
+
# @example Custom rate
|
|
20
|
+
# estimator = BSV::Wallet::FeeEstimator.new(sats_per_kb: 50)
|
|
21
|
+
# fee = estimator.estimate(p2pkh_inputs: 1, p2pkh_outputs: 1)
|
|
22
|
+
# # => 10 (ceil(192/1000 * 50) = 10)
|
|
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
|
|
27
|
+
|
|
28
|
+
# Estimated size in bytes of a P2PKH output (8 satoshis + varint(25) + 25-byte script).
|
|
29
|
+
P2PKH_OUTPUT_SIZE = 34
|
|
30
|
+
|
|
31
|
+
# Fixed overhead in bytes for version (4) and lock_time (4).
|
|
32
|
+
FIXED_OVERHEAD = 8
|
|
33
|
+
|
|
34
|
+
# Approximate overhead including typical 1-byte varints for input/output
|
|
35
|
+
# counts. Retained for backward compatibility with code that referenced
|
|
36
|
+
# +FeeModel::OVERHEAD+.
|
|
37
|
+
OVERHEAD = 10
|
|
38
|
+
|
|
39
|
+
# @return [Integer] the satoshis-per-kilobyte rate used for estimation
|
|
40
|
+
attr_reader :sats_per_kb
|
|
41
|
+
|
|
42
|
+
# @param sats_per_kb [Integer] fee rate in satoshis per kilobyte (default: 1)
|
|
43
|
+
# @raise [ArgumentError] if sats_per_kb is zero or negative
|
|
44
|
+
def initialize(sats_per_kb: 1)
|
|
45
|
+
raise ArgumentError, 'sats_per_kb must be greater than zero' unless sats_per_kb.positive?
|
|
46
|
+
|
|
47
|
+
@sats_per_kb = sats_per_kb
|
|
48
|
+
@sdk_model = BSV::Transaction::FeeModels::SatoshisPerKilobyte.new(value: sats_per_kb)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Estimate the fee for a transaction described by input and output counts.
|
|
52
|
+
#
|
|
53
|
+
# Computes the serialised byte size using standard P2PKH sizes and correct
|
|
54
|
+
# Bitcoin varint encoding for the input/output count fields, then applies
|
|
55
|
+
# the configured sat/kB rate. Returns at least 1 satoshi.
|
|
56
|
+
#
|
|
57
|
+
# @param p2pkh_inputs [Integer] number of P2PKH inputs
|
|
58
|
+
# @param p2pkh_outputs [Integer] number of P2PKH outputs
|
|
59
|
+
# @param extra_bytes [Integer] additional bytes to include (e.g. OP_RETURN data)
|
|
60
|
+
# @return [Integer] estimated fee in satoshis (minimum 1)
|
|
61
|
+
def estimate(p2pkh_inputs:, p2pkh_outputs:, extra_bytes: 0)
|
|
62
|
+
total_bytes = byte_size(p2pkh_inputs, p2pkh_outputs) + extra_bytes
|
|
63
|
+
fee = (total_bytes / 1000.0 * @sats_per_kb).ceil
|
|
64
|
+
[1, fee].max
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Compute the fee for a fully-constructed transaction.
|
|
68
|
+
#
|
|
69
|
+
# Delegates to the underlying {BSV::Transaction::FeeModels::SatoshisPerKilobyte}
|
|
70
|
+
# model, which uses {BSV::Transaction::Transaction#estimated_size} internally.
|
|
71
|
+
#
|
|
72
|
+
# @param tx [BSV::Transaction::Transaction] the transaction to compute the fee for
|
|
73
|
+
# @return [Integer] fee in satoshis
|
|
74
|
+
def estimate_for_tx(tx)
|
|
75
|
+
@sdk_model.compute_fee(tx)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Alias for {#estimate_for_tx} — matches the SDK's +compute_fee+ naming.
|
|
79
|
+
alias compute estimate_for_tx
|
|
80
|
+
|
|
81
|
+
# Minimum viable change output value at the configured rate.
|
|
82
|
+
#
|
|
83
|
+
# An output is economically viable only if its value exceeds twice the cost of
|
|
84
|
+
# spending it. The cost to spend a single P2PKH input is estimated as the fee
|
|
85
|
+
# for a minimal 1-input / 1-output transaction.
|
|
86
|
+
#
|
|
87
|
+
# @return [Integer] minimum satoshis for a change output to be worth creating
|
|
88
|
+
def dust_floor
|
|
89
|
+
cost = (spend_one_p2pkh_bytes / 1000.0 * @sats_per_kb).ceil
|
|
90
|
+
[1, cost * 2].max
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
private
|
|
94
|
+
|
|
95
|
+
# Total byte size for a transaction with the given input/output counts.
|
|
96
|
+
def byte_size(input_count, output_count)
|
|
97
|
+
FIXED_OVERHEAD +
|
|
98
|
+
varint_size(input_count) +
|
|
99
|
+
(input_count * P2PKH_INPUT_SIZE) +
|
|
100
|
+
varint_size(output_count) +
|
|
101
|
+
(output_count * P2PKH_OUTPUT_SIZE)
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# Byte size of a minimal single-input, single-output transaction.
|
|
105
|
+
# Used to compute the cost of spending one P2PKH UTXO.
|
|
106
|
+
def spend_one_p2pkh_bytes
|
|
107
|
+
byte_size(1, 1)
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# Number of bytes required to encode +n+ as a Bitcoin varint.
|
|
111
|
+
def varint_size(n)
|
|
112
|
+
if n < 0xFD
|
|
113
|
+
1
|
|
114
|
+
elsif n <= 0xFFFF
|
|
115
|
+
3
|
|
116
|
+
elsif n <= 0xFFFF_FFFF
|
|
117
|
+
5
|
|
118
|
+
else
|
|
119
|
+
9
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
end
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'fee_estimator'
|
|
4
|
+
|
|
5
|
+
module BSV
|
|
6
|
+
module Wallet
|
|
7
|
+
# Backward-compatible alias for {FeeEstimator}.
|
|
8
|
+
#
|
|
9
|
+
# All fee estimation is consolidated in {FeeEstimator}, which provides
|
|
10
|
+
# both pre-construction estimation (+estimate+) and post-construction
|
|
11
|
+
# delegation (+compute+ / +estimate_for_tx+). This alias ensures
|
|
12
|
+
# existing code referencing +FeeModel+ continues to work.
|
|
13
|
+
#
|
|
14
|
+
# @example
|
|
15
|
+
# model = BSV::Wallet::FeeModel.new(sats_per_kb: 50)
|
|
16
|
+
# model.estimate(p2pkh_inputs: 2, p2pkh_outputs: 3)
|
|
17
|
+
# model.compute(transaction)
|
|
18
|
+
# model.dust_floor
|
|
19
|
+
FeeModel = FeeEstimator
|
|
20
|
+
end
|
|
21
|
+
end
|
|
@@ -60,6 +60,18 @@ module BSV
|
|
|
60
60
|
result
|
|
61
61
|
end
|
|
62
62
|
|
|
63
|
+
def update_output_state(outpoint, new_state, pending_reference: nil, no_send: nil)
|
|
64
|
+
result = super
|
|
65
|
+
save_outputs
|
|
66
|
+
result
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def lock_utxos(outpoints, reference:, no_send: false)
|
|
70
|
+
locked = super
|
|
71
|
+
save_outputs unless locked.empty?
|
|
72
|
+
locked
|
|
73
|
+
end
|
|
74
|
+
|
|
63
75
|
def store_certificate(cert_data)
|
|
64
76
|
result = super
|
|
65
77
|
save_certificates
|
|
@@ -82,6 +94,11 @@ module BSV
|
|
|
82
94
|
save_transactions
|
|
83
95
|
end
|
|
84
96
|
|
|
97
|
+
def store_setting(key, value)
|
|
98
|
+
super
|
|
99
|
+
save_settings
|
|
100
|
+
end
|
|
101
|
+
|
|
85
102
|
private
|
|
86
103
|
|
|
87
104
|
def proofs_path
|
|
@@ -99,7 +116,7 @@ module BSV
|
|
|
99
116
|
'Other users may be able to access wallet data.')
|
|
100
117
|
end
|
|
101
118
|
|
|
102
|
-
[actions_path, outputs_path, certificates_path, proofs_path, transactions_path].each do |path|
|
|
119
|
+
[actions_path, outputs_path, certificates_path, proofs_path, transactions_path, settings_path].each do |path|
|
|
103
120
|
next unless File.exist?(path)
|
|
104
121
|
|
|
105
122
|
file_mode = File.stat(path).mode & 0o777
|
|
@@ -114,6 +131,10 @@ module BSV
|
|
|
114
131
|
File.join(@dir, 'actions.json')
|
|
115
132
|
end
|
|
116
133
|
|
|
134
|
+
def settings_path
|
|
135
|
+
File.join(@dir, 'settings.json')
|
|
136
|
+
end
|
|
137
|
+
|
|
117
138
|
def outputs_path
|
|
118
139
|
File.join(@dir, 'outputs.json')
|
|
119
140
|
end
|
|
@@ -128,6 +149,7 @@ module BSV
|
|
|
128
149
|
@certificates = load_file(certificates_path)
|
|
129
150
|
@proofs = load_proofs_file
|
|
130
151
|
@transactions = load_transactions_file
|
|
152
|
+
@settings = load_settings_file
|
|
131
153
|
end
|
|
132
154
|
|
|
133
155
|
def load_file(path)
|
|
@@ -186,6 +208,22 @@ module BSV
|
|
|
186
208
|
{}
|
|
187
209
|
end
|
|
188
210
|
|
|
211
|
+
def save_settings
|
|
212
|
+
json = JSON.pretty_generate(stringify_keys_deep(@settings))
|
|
213
|
+
tmp = "#{settings_path}.tmp"
|
|
214
|
+
File.open(tmp, File::WRONLY | File::CREAT | File::TRUNC, 0o600) { |f| f.write(json) }
|
|
215
|
+
File.rename(tmp, settings_path)
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
def load_settings_file
|
|
219
|
+
return {} unless File.exist?(settings_path)
|
|
220
|
+
|
|
221
|
+
data = JSON.parse(File.read(settings_path))
|
|
222
|
+
data.is_a?(Hash) ? data : {}
|
|
223
|
+
rescue JSON::ParserError
|
|
224
|
+
{}
|
|
225
|
+
end
|
|
226
|
+
|
|
189
227
|
def write_file(path, data)
|
|
190
228
|
json = JSON.pretty_generate(stringify_keys_deep(data))
|
|
191
229
|
tmp = "#{path}.tmp"
|
|
@@ -2,10 +2,16 @@
|
|
|
2
2
|
|
|
3
3
|
module BSV
|
|
4
4
|
module Wallet
|
|
5
|
-
# In-memory storage adapter for testing.
|
|
5
|
+
# In-memory storage adapter for testing and single-process use.
|
|
6
6
|
#
|
|
7
7
|
# Stores actions, outputs, and certificates in plain Ruby arrays.
|
|
8
|
-
#
|
|
8
|
+
#
|
|
9
|
+
# Thread safety: a +Mutex+ serialises all state-mutating operations so that
|
|
10
|
+
# concurrent threads cannot select and mark the same UTXO as pending.
|
|
11
|
+
# This makes MemoryStore safe within a single Ruby process.
|
|
12
|
+
#
|
|
13
|
+
# NOTE: FileStore is NOT process-safe — concurrent processes share no
|
|
14
|
+
# in-memory lock and may read stale state from disk.
|
|
9
15
|
class MemoryStore
|
|
10
16
|
include StorageAdapter
|
|
11
17
|
|
|
@@ -15,6 +21,8 @@ module BSV
|
|
|
15
21
|
@certificates = []
|
|
16
22
|
@proofs = {}
|
|
17
23
|
@transactions = {}
|
|
24
|
+
@settings = {}
|
|
25
|
+
@mutex = Mutex.new
|
|
18
26
|
end
|
|
19
27
|
|
|
20
28
|
def store_action(action_data)
|
|
@@ -51,6 +59,129 @@ module BSV
|
|
|
51
59
|
true
|
|
52
60
|
end
|
|
53
61
|
|
|
62
|
+
# Transitions the state of an existing output.
|
|
63
|
+
#
|
|
64
|
+
# When +new_state+ is +:pending+, a +:pending_since+ (ISO 8601 UTC) and
|
|
65
|
+
# +:pending_reference+ are attached to the output so stale locks can be
|
|
66
|
+
# detected via {#release_stale_pending!}.
|
|
67
|
+
#
|
|
68
|
+
# Pass +no_send: true+ to mark the lock as belonging to a +no_send+
|
|
69
|
+
# transaction; these locks are exempt from automatic stale recovery and
|
|
70
|
+
# must be released explicitly via +abort_action+.
|
|
71
|
+
#
|
|
72
|
+
# When transitioning away from +:pending+, all pending metadata is cleared.
|
|
73
|
+
#
|
|
74
|
+
# This method is wrapped in a mutex to prevent concurrent transitions on
|
|
75
|
+
# the same output from two threads.
|
|
76
|
+
#
|
|
77
|
+
# @param outpoint [String] the outpoint identifier
|
|
78
|
+
# @param new_state [Symbol] +:spendable+, +:pending+, or +:spent+
|
|
79
|
+
# @param pending_reference [String, nil] caller-supplied label for the lock
|
|
80
|
+
# @param no_send [Boolean, nil] true if the lock is for a no_send transaction
|
|
81
|
+
# @raise [BSV::Wallet::WalletError] if the outpoint is not found
|
|
82
|
+
def update_output_state(outpoint, new_state, pending_reference: nil, no_send: nil)
|
|
83
|
+
@mutex.synchronize do
|
|
84
|
+
output = @outputs.find { |o| o[:outpoint] == outpoint }
|
|
85
|
+
raise WalletError, "Output not found: #{outpoint}" unless output
|
|
86
|
+
|
|
87
|
+
output[:state] = new_state
|
|
88
|
+
|
|
89
|
+
if new_state == :pending
|
|
90
|
+
output[:pending_since] = Time.now.utc.iso8601
|
|
91
|
+
output[:pending_reference] = pending_reference
|
|
92
|
+
no_send ? output[:no_send] = true : output.delete(:no_send)
|
|
93
|
+
else
|
|
94
|
+
output.delete(:pending_since)
|
|
95
|
+
output.delete(:pending_reference)
|
|
96
|
+
output.delete(:no_send)
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
output
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# Atomically locks the specified outpoints as +:pending+.
|
|
104
|
+
#
|
|
105
|
+
# Holds the mutex for the entire operation so no other thread can
|
|
106
|
+
# read or transition these outputs between the check and the lock.
|
|
107
|
+
#
|
|
108
|
+
# @param outpoints [Array<String>] outpoint identifiers to lock
|
|
109
|
+
# @param reference [String] caller-supplied pending reference
|
|
110
|
+
# @param no_send [Boolean] true if this is a no_send lock
|
|
111
|
+
# @return [Array<String>] outpoints successfully locked
|
|
112
|
+
def lock_utxos(outpoints, reference:, no_send: false)
|
|
113
|
+
now = Time.now.utc.iso8601
|
|
114
|
+
locked = []
|
|
115
|
+
|
|
116
|
+
@mutex.synchronize do
|
|
117
|
+
outpoints.each do |op|
|
|
118
|
+
output = @outputs.find { |o| o[:outpoint] == op }
|
|
119
|
+
next unless output && effective_state(output) == :spendable
|
|
120
|
+
|
|
121
|
+
output[:state] = :pending
|
|
122
|
+
output[:pending_since] = now
|
|
123
|
+
output[:pending_reference] = reference
|
|
124
|
+
no_send ? output[:no_send] = true : output.delete(:no_send)
|
|
125
|
+
locked << op
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
locked
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# Returns only outputs whose effective state is +:spendable+.
|
|
133
|
+
#
|
|
134
|
+
# Legacy outputs that carry no +:state+ key are treated as spendable
|
|
135
|
+
# when +spendable:+ is not explicitly +false+.
|
|
136
|
+
#
|
|
137
|
+
# This method is wrapped in the same mutex as {#update_output_state} so
|
|
138
|
+
# that a thread cannot select a UTXO that another thread is simultaneously
|
|
139
|
+
# marking as pending.
|
|
140
|
+
#
|
|
141
|
+
# @param basket [String, nil] restrict to this basket when provided
|
|
142
|
+
# @param min_satoshis [Integer, nil] exclude outputs below this value
|
|
143
|
+
# @param sort_order [Symbol] +:asc+ or +:desc+ (default +:desc+, largest first)
|
|
144
|
+
# @return [Array<Hash>]
|
|
145
|
+
def find_spendable_outputs(basket: nil, min_satoshis: nil, sort_order: :desc)
|
|
146
|
+
@mutex.synchronize do
|
|
147
|
+
results = @outputs.select { |o| effective_state(o) == :spendable }
|
|
148
|
+
results = results.select { |o| o[:basket] == basket } if basket
|
|
149
|
+
results = results.select { |o| (o[:satoshis] || 0) >= min_satoshis } if min_satoshis
|
|
150
|
+
results.sort_by { |o| sort_order == :asc ? (o[:satoshis] || 0) : -(o[:satoshis] || 0) }
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
# Releases pending locks that have been held longer than +timeout+ seconds.
|
|
155
|
+
#
|
|
156
|
+
# Each output in +:pending+ state whose +:pending_since+ timestamp is older
|
|
157
|
+
# than +timeout+ seconds is reverted to +:spendable+ and its pending
|
|
158
|
+
# metadata is cleared.
|
|
159
|
+
#
|
|
160
|
+
# @param timeout [Integer] lock age in seconds before it is considered stale (default 300)
|
|
161
|
+
# @return [Integer] number of outputs released
|
|
162
|
+
def release_stale_pending!(timeout: 300)
|
|
163
|
+
cutoff = Time.now.utc - timeout
|
|
164
|
+
released = 0
|
|
165
|
+
|
|
166
|
+
@mutex.synchronize do
|
|
167
|
+
@outputs.each do |output|
|
|
168
|
+
next unless effective_state(output) == :pending
|
|
169
|
+
next if output[:no_send]
|
|
170
|
+
next unless output[:pending_since]
|
|
171
|
+
|
|
172
|
+
locked_at = Time.parse(output[:pending_since])
|
|
173
|
+
next unless locked_at < cutoff
|
|
174
|
+
|
|
175
|
+
output[:state] = :spendable
|
|
176
|
+
output.delete(:pending_since)
|
|
177
|
+
output.delete(:pending_reference)
|
|
178
|
+
released += 1
|
|
179
|
+
end
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
released
|
|
183
|
+
end
|
|
184
|
+
|
|
54
185
|
def store_certificate(cert_data)
|
|
55
186
|
@certificates << cert_data
|
|
56
187
|
cert_data
|
|
@@ -90,6 +221,22 @@ module BSV
|
|
|
90
221
|
true
|
|
91
222
|
end
|
|
92
223
|
|
|
224
|
+
# Persists a named wallet setting.
|
|
225
|
+
#
|
|
226
|
+
# @param key [String] the setting name
|
|
227
|
+
# @param value [Object] the setting value (must be JSON-serialisable)
|
|
228
|
+
def store_setting(key, value)
|
|
229
|
+
@settings[key] = value
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
# Retrieves a named wallet setting.
|
|
233
|
+
#
|
|
234
|
+
# @param key [String] the setting name
|
|
235
|
+
# @return [Object, nil] the stored value, or nil if not found
|
|
236
|
+
def find_setting(key)
|
|
237
|
+
@settings[key]
|
|
238
|
+
end
|
|
239
|
+
|
|
93
240
|
private
|
|
94
241
|
|
|
95
242
|
def filter_actions(query)
|
|
@@ -122,7 +269,23 @@ module BSV
|
|
|
122
269
|
end
|
|
123
270
|
end
|
|
124
271
|
end
|
|
125
|
-
query[:include_spent] ? results : results.
|
|
272
|
+
query[:include_spent] ? results : results.select { |o| effective_state(o) == :spendable }
|
|
273
|
+
end
|
|
274
|
+
|
|
275
|
+
# Resolves the effective state of an output from either the new +:state+
|
|
276
|
+
# field or the legacy +:spendable+ boolean, giving +:state+ precedence.
|
|
277
|
+
#
|
|
278
|
+
# The +:state+ value may be a Symbol or a String (the latter arises after
|
|
279
|
+
# a JSON round-trip in FileStore); both are normalised to a Symbol here.
|
|
280
|
+
#
|
|
281
|
+
# Legacy mapping:
|
|
282
|
+
# - +spendable: false+ → +:spent+
|
|
283
|
+
# - +spendable: true+ (or absent) → +:spendable+
|
|
284
|
+
def effective_state(output)
|
|
285
|
+
state = output[:state]
|
|
286
|
+
return state.to_sym if state
|
|
287
|
+
|
|
288
|
+
output[:spendable] == false ? :spent : :spendable
|
|
126
289
|
end
|
|
127
290
|
|
|
128
291
|
def filter_certificates(query)
|
|
@@ -15,7 +15,15 @@ module BSV
|
|
|
15
15
|
end
|
|
16
16
|
|
|
17
17
|
def get_header(_height)
|
|
18
|
-
raise UnsupportedActionError, '
|
|
18
|
+
raise UnsupportedActionError, 'get_header (no chain provider configured)'
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def get_utxos(_address)
|
|
22
|
+
raise UnsupportedActionError, 'get_utxos (no chain provider configured)'
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def get_transaction(_txid)
|
|
26
|
+
raise UnsupportedActionError, 'get_transaction (no chain provider configured)'
|
|
19
27
|
end
|
|
20
28
|
end
|
|
21
29
|
end
|
|
@@ -141,7 +141,7 @@ module BSV
|
|
|
141
141
|
# @param originator [String, nil] FQDN of the originating application
|
|
142
142
|
# @return [Hash] { signature: Array<Integer> } DER-encoded signature as byte array
|
|
143
143
|
def create_signature(args, originator: nil)
|
|
144
|
-
counterparty = args[:counterparty] || '
|
|
144
|
+
counterparty = args[:counterparty] || 'anyone'
|
|
145
145
|
priv_key = @key_deriver.derive_private_key(args[:protocol_id], args[:key_id], counterparty)
|
|
146
146
|
|
|
147
147
|
hash = if args[:hash_to_directly_sign]
|
|
@@ -66,6 +66,85 @@ module BSV
|
|
|
66
66
|
def find_transaction(_txid)
|
|
67
67
|
raise NotImplementedError, "#{self.class}#find_transaction not implemented"
|
|
68
68
|
end
|
|
69
|
+
|
|
70
|
+
# Transitions the state of an existing output.
|
|
71
|
+
#
|
|
72
|
+
# When +new_state+ is +:pending+, pass a +pending_reference:+ string to
|
|
73
|
+
# associate the lock with a specific action. The adapter should record a
|
|
74
|
+
# timestamp (ISO 8601 UTC) alongside the reference so stale locks can be
|
|
75
|
+
# detected and released by {#release_stale_pending!}.
|
|
76
|
+
#
|
|
77
|
+
# When transitioning away from +:pending+, the adapter must clear any
|
|
78
|
+
# stored +:pending_since+ and +:pending_reference+ metadata.
|
|
79
|
+
#
|
|
80
|
+
# @param _outpoint [String] the outpoint identifier (e.g. "txid.vout")
|
|
81
|
+
# @param _new_state [Symbol] one of +:spendable+, +:pending+, +:spent+
|
|
82
|
+
# @param _pending_reference [String, nil] caller-supplied label for a pending lock
|
|
83
|
+
# @param _no_send [Boolean, nil] true if the lock belongs to a no_send transaction;
|
|
84
|
+
# these locks are exempt from automatic stale recovery via {#release_stale_pending!}
|
|
85
|
+
# @raise [NotImplementedError]
|
|
86
|
+
def update_output_state(_outpoint, _new_state, pending_reference: nil, no_send: nil)
|
|
87
|
+
raise NotImplementedError, "#{self.class}#update_output_state not implemented"
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# Atomically finds outputs by outpoint and marks them as +:pending+.
|
|
91
|
+
#
|
|
92
|
+
# This prevents TOCTOU races where two threads select the same UTXO
|
|
93
|
+
# between +find_spendable_outputs+ and +update_output_state+.
|
|
94
|
+
# Only outputs still in +:spendable+ state are locked; any that have
|
|
95
|
+
# already transitioned are skipped.
|
|
96
|
+
#
|
|
97
|
+
# @param outpoints [Array<String>] outpoint identifiers to lock
|
|
98
|
+
# @param reference [String] caller-supplied pending reference
|
|
99
|
+
# @param no_send [Boolean] true if this is a no_send lock
|
|
100
|
+
# @return [Array<String>] outpoints that were successfully locked
|
|
101
|
+
def lock_utxos(outpoints, reference:, no_send: false)
|
|
102
|
+
raise NotImplementedError, "#{self.class}#lock_utxos not implemented"
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# Returns only outputs whose effective state is +:spendable+.
|
|
106
|
+
#
|
|
107
|
+
# @param basket [String, nil] restrict to this basket when provided
|
|
108
|
+
# @param min_satoshis [Integer, nil] exclude outputs below this value
|
|
109
|
+
# @param sort_order [Symbol] +:asc+ or +:desc+ (default +:desc+, largest first)
|
|
110
|
+
# @return [Array<Hash>]
|
|
111
|
+
# @raise [NotImplementedError]
|
|
112
|
+
def find_spendable_outputs(basket: nil, min_satoshis: nil, sort_order: :desc)
|
|
113
|
+
raise NotImplementedError, "#{self.class}#find_spendable_outputs not implemented"
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# Releases pending locks that have been held longer than +timeout+ seconds.
|
|
117
|
+
#
|
|
118
|
+
# Each output in +:pending+ state whose +:pending_since+ timestamp is older
|
|
119
|
+
# than +timeout+ seconds is reverted to +:spendable+ and its pending
|
|
120
|
+
# metadata is cleared.
|
|
121
|
+
#
|
|
122
|
+
# This is a no-op for adapters that do not support pending metadata.
|
|
123
|
+
#
|
|
124
|
+
# @param timeout [Integer] lock age in seconds before it is considered stale
|
|
125
|
+
# @return [Integer] number of outputs released
|
|
126
|
+
# @raise [NotImplementedError]
|
|
127
|
+
def release_stale_pending!(timeout: 300)
|
|
128
|
+
raise NotImplementedError, "#{self.class}#release_stale_pending! not implemented"
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# Persists a named wallet setting.
|
|
132
|
+
#
|
|
133
|
+
# @param _key [String] the setting name
|
|
134
|
+
# @param _value [Object] the setting value (must be JSON-serialisable)
|
|
135
|
+
# @raise [NotImplementedError]
|
|
136
|
+
def store_setting(_key, _value)
|
|
137
|
+
raise NotImplementedError, "#{self.class}#store_setting not implemented"
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
# Retrieves a named wallet setting.
|
|
141
|
+
#
|
|
142
|
+
# @param _key [String] the setting name
|
|
143
|
+
# @return [Object, nil] the stored value, or nil if not found
|
|
144
|
+
# @raise [NotImplementedError]
|
|
145
|
+
def find_setting(_key)
|
|
146
|
+
raise NotImplementedError, "#{self.class}#find_setting not implemented"
|
|
147
|
+
end
|
|
69
148
|
end
|
|
70
149
|
end
|
|
71
150
|
end
|
|
@@ -3,6 +3,23 @@
|
|
|
3
3
|
module BSV
|
|
4
4
|
module Wallet
|
|
5
5
|
module Validators
|
|
6
|
+
# Reserved protocol name prefixes (BRC-44 and BRC-98).
|
|
7
|
+
# Names beginning with any of these strings are disallowed.
|
|
8
|
+
RESERVED_PROTOCOL_PREFIXES = ['admin', 'p '].freeze
|
|
9
|
+
|
|
10
|
+
# Suffix that is disallowed on protocol names.
|
|
11
|
+
RESERVED_PROTOCOL_SUFFIX = ' protocol'
|
|
12
|
+
|
|
13
|
+
# Reserved basket name prefixes.
|
|
14
|
+
# Basket names beginning with any of these strings are disallowed.
|
|
15
|
+
RESERVED_BASKET_PREFIXES = ['admin', 'p '].freeze
|
|
16
|
+
|
|
17
|
+
# Suffix that is disallowed on basket names.
|
|
18
|
+
RESERVED_BASKET_SUFFIX = ' basket'
|
|
19
|
+
|
|
20
|
+
# Basket name that is globally reserved and cannot be used.
|
|
21
|
+
RESERVED_BASKET_NAME = 'default'
|
|
22
|
+
|
|
6
23
|
module_function
|
|
7
24
|
|
|
8
25
|
# BRC-100 protocol ID rules:
|
|
@@ -14,6 +31,10 @@ module BSV
|
|
|
14
31
|
# - must not end with ' protocol'
|
|
15
32
|
# - must not start with 'admin' (BRC-44)
|
|
16
33
|
# - must not start with 'p ' (BRC-98 reserved)
|
|
34
|
+
#
|
|
35
|
+
# The name is normalised (stripped and downcased) before validation so
|
|
36
|
+
# that ' MyProtocol ' and 'myprotocol' are treated identically and do not
|
|
37
|
+
# silently fork to different key-derivation paths (F8.7).
|
|
17
38
|
def validate_protocol_id!(protocol_id)
|
|
18
39
|
unless protocol_id.is_a?(Array) && protocol_id.length == 2
|
|
19
40
|
raise InvalidParameterError.new('protocol_id',
|
|
@@ -24,13 +45,19 @@ module BSV
|
|
|
24
45
|
raise InvalidParameterError.new('protocol_id security level', '0, 1, or 2') unless [0, 1, 2].include?(level)
|
|
25
46
|
raise InvalidParameterError.new('protocol_id name', 'a String') unless name.is_a?(String)
|
|
26
47
|
|
|
48
|
+
name = name.strip.downcase
|
|
49
|
+
|
|
27
50
|
max_length = name.start_with?('specific linkage revelation') ? 430 : 400
|
|
28
51
|
raise InvalidParameterError.new('protocol_id name', "between 5 and #{max_length} characters") if name.length < 5 || name.length > max_length
|
|
29
52
|
raise InvalidParameterError.new('protocol_id name', 'lowercase letters, numbers, and spaces only') unless name.match?(/\A[a-z0-9 ]+\z/)
|
|
30
53
|
raise InvalidParameterError.new('protocol_id name', 'free of consecutive spaces') if name.include?(' ')
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
54
|
+
if name.end_with?(RESERVED_PROTOCOL_SUFFIX)
|
|
55
|
+
raise InvalidParameterError.new('protocol_id name', "not ending with \"#{RESERVED_PROTOCOL_SUFFIX}\"")
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
RESERVED_PROTOCOL_PREFIXES.each do |prefix|
|
|
59
|
+
raise InvalidParameterError.new('protocol_id name', "not starting with \"#{prefix}\"") if name.start_with?(prefix)
|
|
60
|
+
end
|
|
34
61
|
end
|
|
35
62
|
|
|
36
63
|
# Key ID: 1-800 bytes
|
|
@@ -67,10 +94,12 @@ module BSV
|
|
|
67
94
|
raise InvalidParameterError.new('basket', 'between 5 and 300 characters') if basket.length < 5 || basket.length > 300
|
|
68
95
|
raise InvalidParameterError.new('basket', 'lowercase letters, numbers, and spaces only') unless basket.match?(/\A[a-z0-9 ]+\z/)
|
|
69
96
|
raise InvalidParameterError.new('basket', 'free of consecutive spaces') if basket.include?(' ')
|
|
70
|
-
raise InvalidParameterError.new('basket',
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
97
|
+
raise InvalidParameterError.new('basket', "not ending with \"#{RESERVED_BASKET_SUFFIX}\"") if basket.end_with?(RESERVED_BASKET_SUFFIX)
|
|
98
|
+
|
|
99
|
+
RESERVED_BASKET_PREFIXES.each do |prefix|
|
|
100
|
+
raise InvalidParameterError.new('basket', "not starting with \"#{prefix}\"") if basket.start_with?(prefix)
|
|
101
|
+
end
|
|
102
|
+
raise InvalidParameterError.new('basket', "not equal to \"#{RESERVED_BASKET_NAME}\"") if basket == RESERVED_BASKET_NAME
|
|
74
103
|
end
|
|
75
104
|
|
|
76
105
|
# Label: 1-300 characters
|