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.
@@ -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
- # Not thread-safe; intended for test use only.
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.reject { |o| o[:spendable] == false }
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, 'get_header_for_height (no chain provider configured)'
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] || 'self'
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
- raise InvalidParameterError.new('protocol_id name', 'not ending with " protocol"') if name.end_with?(' protocol')
32
- raise InvalidParameterError.new('protocol_id name', 'not starting with "admin"') if name.start_with?('admin')
33
- raise InvalidParameterError.new('protocol_id name', 'not starting with "p "') if name.start_with?('p ')
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', 'not ending with " basket"') if basket.end_with?(' basket')
71
- raise InvalidParameterError.new('basket', 'not starting with "admin"') if basket.start_with?('admin')
72
- raise InvalidParameterError.new('basket', 'not equal to "default"') if basket == 'default'
73
- raise InvalidParameterError.new('basket', 'not starting with "p "') if basket.start_with?('p ')
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
@@ -2,6 +2,6 @@
2
2
 
3
3
  module BSV
4
4
  module WalletInterface
5
- VERSION = '0.3.4'
5
+ VERSION = '0.5.0'
6
6
  end
7
7
  end