bsv-sdk 0.24.0 → 0.26.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.
Files changed (44) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +62 -0
  3. data/README.md +4 -2
  4. data/lib/bsv/kv_store/entry.rb +15 -0
  5. data/lib/bsv/kv_store/global.rb +210 -0
  6. data/lib/bsv/kv_store/interpreter.rb +109 -0
  7. data/lib/bsv/kv_store/token.rb +10 -0
  8. data/lib/bsv/kv_store.rb +10 -0
  9. data/lib/bsv/mcp/tools/helpers.rb +3 -3
  10. data/lib/bsv/overlay/admin_token_template.rb +2 -2
  11. data/lib/bsv/overlay/historian.rb +118 -0
  12. data/lib/bsv/overlay/topic_broadcaster.rb +1 -1
  13. data/lib/bsv/overlay.rb +1 -0
  14. data/lib/bsv/primitives/digest.rb +107 -13
  15. data/lib/bsv/primitives/ecies.rb +12 -3
  16. data/lib/bsv/registry/client.rb +43 -1
  17. data/lib/bsv/script/bip276.rb +143 -0
  18. data/lib/bsv/script/interpreter/error.rb +2 -0
  19. data/lib/bsv/script/interpreter/interpreter.rb +226 -7
  20. data/lib/bsv/script/interpreter/operations/flow_control.rb +30 -13
  21. data/lib/bsv/script/push_drop_template.rb +2 -2
  22. data/lib/bsv/script/script.rb +21 -2
  23. data/lib/bsv/script.rb +1 -0
  24. data/lib/bsv/storage/downloader.rb +174 -0
  25. data/lib/bsv/storage/errors.rb +8 -0
  26. data/lib/bsv/storage/utils.rb +90 -0
  27. data/lib/bsv/storage.rb +16 -0
  28. data/lib/bsv/transaction/beef.rb +168 -14
  29. data/lib/bsv/transaction/beef_party.rb +119 -0
  30. data/lib/bsv/transaction/chain_tracker.rb +1 -1
  31. data/lib/bsv/transaction/fee_model.rb +1 -1
  32. data/lib/bsv/transaction/fee_models/live_policy.rb +1 -1
  33. data/lib/bsv/transaction/fee_models/satoshis_per_kilobyte.rb +1 -1
  34. data/lib/bsv/transaction/merkle_path.rb +1 -1
  35. data/lib/bsv/transaction/p2pkh.rb +1 -1
  36. data/lib/bsv/transaction/transaction_input.rb +78 -14
  37. data/lib/bsv/transaction/transaction_output.rb +46 -6
  38. data/lib/bsv/transaction/tx.rb +370 -39
  39. data/lib/bsv/transaction/unlocking_script_template.rb +2 -2
  40. data/lib/bsv/transaction.rb +1 -0
  41. data/lib/bsv/version.rb +1 -1
  42. data/lib/bsv-sdk.rb +2 -0
  43. metadata +13 -2
  44. data/lib/bsv/secp256k1_native.bundle +0 -0
@@ -53,18 +53,74 @@ module BSV
53
53
  # stack overflow from deeply nested conditionals.
54
54
  MAX_CONDITIONAL_DEPTH = 256
55
55
 
56
+ # Opcodes that require Chronicle to execute. With explicit flags but
57
+ # without UTXO_AFTER_CHRONICLE, executing any of these raises
58
+ # DISABLED_OPCODE. OP_VER / OP_VERIF / OP_VERNOTIF are included because
59
+ # pre-Chronicle they're either reserved (OP_VER) or behave as a
60
+ # conditional-only NOP in non-executing branches — execution itself is
61
+ # disabled. Mirrors TS Spend.ts (lines ~694-709) and Go IsDisabled.
62
+ CHRONICLE_ONLY_OPCODES = [
63
+ Opcodes::OP_2MUL, Opcodes::OP_2DIV,
64
+ Opcodes::OP_VER, Opcodes::OP_VERIF, Opcodes::OP_VERNOTIF
65
+ ].freeze
66
+
67
+ # The two version-conditional opcodes — extracted to module-level
68
+ # constants so the interpreter hot path (every conditional dispatch /
69
+ # every executed chunk) doesn't allocate a fresh Array per call.
70
+ VER_CONDITIONAL_OPCODES = [Opcodes::OP_VERIF, Opcodes::OP_VERNOTIF].freeze
71
+
72
+ # Recognised verification flag names. Catches typos (a misspelled
73
+ # +SIGPUSHONLLY+ would otherwise silently disable enforcement) and forces
74
+ # any new flag to be declared here before use. The set is the union of
75
+ # flags appearing in the canonical conformance corpus and the Bitcoin
76
+ # Core +script_tests.json+ fixture, plus the witness/taproot family that
77
+ # downstream callers filter out before reaching the interpreter.
78
+ KNOWN_FLAGS = Set[
79
+ 'CHECKLOCKTIMEVERIFY', 'CHECKSEQUENCEVERIFY',
80
+ 'CLEANSTACK', 'DERSIG', 'DISCOURAGE_UPGRADABLE_NOPS',
81
+ 'DISCOURAGE_UPGRADABLE_WITNESS_PROGRAM',
82
+ 'GENESIS', 'LOW_S',
83
+ 'MINIMALDATA', 'MINIMALIF',
84
+ 'NULLDUMMY', 'NULLFAIL',
85
+ 'P2SH', 'SIGHASH_FORKID', 'SIGPUSHONLY', 'STRICTENC',
86
+ 'TAPROOT',
87
+ 'UTXO_AFTER_CHRONICLE', 'UTXO_AFTER_GENESIS',
88
+ 'WITNESS'
89
+ ].freeze
90
+
56
91
  # Evaluate unlock + lock scripts without transaction context.
57
92
  #
58
93
  # Signature operations will always fail (no sighash available).
59
94
  #
95
+ # @example Relaxed mode (post-Chronicle defaults, no malleability checks)
96
+ # Interpreter.evaluate(unlock, lock) # flags defaults to nil
97
+ #
98
+ # @example Explicit flag set
99
+ # Interpreter.evaluate(unlock, lock, flags: %w[UTXO_AFTER_GENESIS CLEANSTACK])
100
+ #
101
+ # @example Explicit-but-empty (pre-Genesis, pre-Chronicle, strict)
102
+ # Interpreter.evaluate(unlock, lock, flags: []) # NOT the same as nil
103
+ #
60
104
  # @param unlock_script [Script] the unlocking script
61
105
  # @param lock_script [Script] the locking script
106
+ # @param flags [Array<String>, Set<String>, nil] explicit verification flags
107
+ # (e.g. +SIGPUSHONLY+, +CLEANSTACK+, +UTXO_AFTER_CHRONICLE+). +nil+
108
+ # selects relaxed (post-Chronicle) mode where malleability checks are
109
+ # off; an empty +[]+ array is explicit-but-empty (pre-Genesis,
110
+ # pre-Chronicle strict mode). Each flag string must appear in
111
+ # {KNOWN_FLAGS} — unknown names raise +ArgumentError+.
112
+ # @param tx_version [Integer, nil] transaction version made available to
113
+ # +OP_VER+/+OP_VERIF+/+OP_VERNOTIF+ when no transaction is supplied
62
114
  # @return [Boolean] +true+ if execution succeeds
63
115
  # @raise [ScriptError] if script execution fails
64
- def self.evaluate(unlock_script, lock_script)
116
+ # @raise [ArgumentError] if +flags+ contains an unknown name or
117
+ # +tx_version+ is not a valid uint32
118
+ def self.evaluate(unlock_script, lock_script, flags: nil, tx_version: nil)
65
119
  new(
66
120
  unlock_script: unlock_script,
67
- lock_script: lock_script
121
+ lock_script: lock_script,
122
+ flags: flags,
123
+ tx_version: tx_version
68
124
  ).execute
69
125
  end
70
126
 
@@ -75,19 +131,28 @@ module BSV
75
131
  # @param unlock_script [Script] the input's unlocking script
76
132
  # @param lock_script [Script] the previous output's locking script
77
133
  # @param satoshis [Integer] the value of the previous output in satoshis
134
+ # @param flags [Array<String>, Set<String>, nil] explicit verification flags
135
+ # (see {.evaluate}). Production callers (e.g. +Tx#verify_input+) do not
136
+ # pass +flags+, so mainnet transaction validation always runs in
137
+ # relaxed mode; the parameter exists for conformance-corpus and
138
+ # regression-vector runners that need to drive specific flag combinations.
78
139
  # @return [Boolean] +true+ if verification succeeds
79
140
  # @raise [ScriptError] if script execution fails
80
- def self.verify(tx:, input_index:, unlock_script:, lock_script:, satoshis:)
141
+ # @raise [ArgumentError] if +flags+ contains an unknown name
142
+ def self.verify(tx:, input_index:, unlock_script:, lock_script:, satoshis:, flags: nil)
81
143
  new(
82
144
  unlock_script: unlock_script,
83
145
  lock_script: lock_script,
84
146
  tx: tx,
85
147
  input_index: input_index,
86
- satoshis: satoshis
148
+ satoshis: satoshis,
149
+ flags: flags
87
150
  ).execute
88
151
  end
89
152
 
90
153
  def execute
154
+ enforce_sig_pushonly
155
+
91
156
  scripts = [@unlock_script, @lock_script]
92
157
  script_names = %w[unlock_script lock_script]
93
158
 
@@ -123,12 +188,15 @@ module BSV
123
188
 
124
189
  private
125
190
 
126
- def initialize(unlock_script:, lock_script:, tx: nil, input_index: nil, satoshis: nil)
191
+ def initialize(unlock_script:, lock_script:, tx: nil, input_index: nil, satoshis: nil,
192
+ flags: nil, tx_version: nil)
127
193
  @unlock_script = unlock_script
128
194
  @lock_script = lock_script
129
195
  @tx = tx
130
196
  @input_index = input_index
131
197
  @satoshis = satoshis
198
+ @flags = normalise_flags(flags)
199
+ @tx_version = validate_tx_version(tx_version)
132
200
 
133
201
  @dstack = Stack.new
134
202
  @astack = Stack.new
@@ -141,24 +209,137 @@ module BSV
141
209
  @current_chunk_idx = 0
142
210
  end
143
211
 
212
+ # Normalises the +flags:+ kwarg into a frozen Set of recognised names.
213
+ # +nil+ stays +nil+ (relaxed mode); any iterable is coerced to a Set of
214
+ # strings, dropping +nil+ / empty entries silently (so callers can pass
215
+ # +flags_csv.split(',')+ without trimming). Unknown flag names raise
216
+ # +ArgumentError+ — typos in consensus-affecting flag strings would
217
+ # otherwise silently disable the corresponding rule.
218
+ def normalise_flags(flags)
219
+ return nil if flags.nil?
220
+
221
+ normalised = Set.new
222
+ flags.each do |raw|
223
+ next if raw.nil?
224
+
225
+ name = raw.to_s.strip
226
+ next if name.empty?
227
+
228
+ unless KNOWN_FLAGS.include?(name)
229
+ raise ArgumentError,
230
+ "unknown verification flag: #{raw.inspect} (add to KNOWN_FLAGS if intentional)"
231
+ end
232
+
233
+ normalised << name
234
+ end
235
+ normalised.freeze
236
+ end
237
+
238
+ # Ensures the +tx_version:+ kwarg is a valid uint32. Returns +nil+ for nil
239
+ # input. Raises +ArgumentError+ for negative values, values > 2^32-1, or
240
+ # non-Integer inputs — these would otherwise silently coerce inside
241
+ # +Array#pack('V')+ (negative wraps to 0xFFFFFFFF, oversized wraps mod
242
+ # 2^32), masking caller bugs.
243
+ def validate_tx_version(version)
244
+ return nil if version.nil?
245
+ return version if version.is_a?(Integer) && version >= 0 && version <= 0xFFFFFFFF
246
+
247
+ raise ArgumentError, "tx_version must be a uint32 (0..0xFFFFFFFF), got #{version.inspect}"
248
+ end
249
+
250
+ # Whether explicit verification flags were supplied.
251
+ # In their absence the interpreter behaves as if Chronicle is active and
252
+ # the unlock-script malleability flags are off (matches the TS SDK's
253
+ # +isRelaxed+ default — see Spend.ts).
254
+ def explicit_flags?
255
+ !@flags.nil?
256
+ end
257
+
258
+ def flag?(name)
259
+ explicit_flags? && @flags.include?(name)
260
+ end
261
+
262
+ # Whether post-Chronicle semantics apply (OP_2MUL/2DIV enabled,
263
+ # OP_VER/OP_VERIF/OP_VERNOTIF interpret +tx_version+).
264
+ def chronicle?
265
+ return flag?('UTXO_AFTER_CHRONICLE') if explicit_flags?
266
+
267
+ true
268
+ end
269
+
270
+ # Whether post-Genesis rules apply. With explicit flags this requires one
271
+ # of the genesis-era flags; without, the interpreter is always post-Genesis.
272
+ def after_genesis?
273
+ return flag?('GENESIS') || flag?('UTXO_AFTER_GENESIS') || flag?('UTXO_AFTER_CHRONICLE') if explicit_flags?
274
+
275
+ true
276
+ end
277
+
278
+ def enforce_sig_pushonly?
279
+ return flag?('SIGPUSHONLY') if explicit_flags?
280
+
281
+ false
282
+ end
283
+
284
+ def enforce_sig_pushonly
285
+ return unless enforce_sig_pushonly?
286
+ return if @unlock_script.push_only?
287
+
288
+ raise ScriptError.new(
289
+ ScriptErrorCode::SIG_PUSHONLY,
290
+ 'unlock script must contain only push-data operations'
291
+ )
292
+ end
293
+
294
+ def enforce_clean_stack?
295
+ return flag?('CLEANSTACK') if explicit_flags?
296
+
297
+ false
298
+ end
299
+
300
+ def enforce_clean_stack
301
+ return unless enforce_clean_stack?
302
+ return if @dstack.length == 1
303
+
304
+ raise ScriptError.new(
305
+ ScriptErrorCode::CLEAN_STACK,
306
+ "CLEANSTACK requires exactly one stack item at end (found #{@dstack.length})"
307
+ )
308
+ end
309
+
144
310
  def execute_opcode(chunk)
145
311
  opcode = chunk.opcode
146
312
 
313
+ # Pre-Chronicle, pre-Genesis mode (explicit non-genesis flags) treats
314
+ # OP_VER / OP_VERIF / OP_VERNOTIF as universally illegal — they raise
315
+ # even in a non-executing branch. Mirrors TS Spend.ts line ~700.
316
+ enforce_pre_genesis_ver_gate(opcode)
317
+
147
318
  # After OP_RETURN inside a conditional: only process flow control opcodes
148
319
  # and OP_RETURN itself (which may terminate at top level once conditionals
149
320
  # are balanced), matching Go SDK's branchExecuting semantics.
150
321
  if @after_op_return
151
- dispatch_opcode(opcode, chunk) if conditional_opcode?(opcode) || opcode == Opcodes::OP_RETURN
322
+ if conditional_opcode?(opcode) || opcode == Opcodes::OP_RETURN
323
+ return if skipped_ver_conditional?(opcode)
324
+
325
+ dispatch_opcode(opcode, chunk)
326
+ end
152
327
  return
153
328
  end
154
329
 
155
330
  # In non-executing branch: only dispatch conditional opcodes (for nesting tracking).
156
331
  # All other opcodes are skipped.
157
332
  unless branch_executing?
158
- dispatch_opcode(opcode, chunk) if conditional_opcode?(opcode)
333
+ if conditional_opcode?(opcode)
334
+ return if skipped_ver_conditional?(opcode)
335
+
336
+ dispatch_opcode(opcode, chunk)
337
+ end
159
338
  return
160
339
  end
161
340
 
341
+ enforce_chronicle_gate(opcode)
342
+
162
343
  BSV.logger&.debug do
163
344
  name = Opcodes.name_for(opcode) || format('0x%02x', opcode)
164
345
  "[Interpreter] #{name} (stack: #{@dstack.length})"
@@ -166,6 +347,41 @@ module BSV
166
347
  dispatch_opcode(opcode, chunk)
167
348
  end
168
349
 
350
+ def enforce_chronicle_gate(opcode)
351
+ return unless CHRONICLE_ONLY_OPCODES.include?(opcode)
352
+ return if chronicle?
353
+
354
+ raise ScriptError.new(
355
+ ScriptErrorCode::DISABLED_OPCODE,
356
+ "#{Opcodes.name_for(opcode) || format('0x%02x', opcode)} is disabled outside Chronicle"
357
+ )
358
+ end
359
+
360
+ # OP_VERIF / OP_VERNOTIF in a non-executing branch are conditional opcodes
361
+ # post-Chronicle (push to cond_stack, like OP_IF in a non-executing branch),
362
+ # but a complete NOP pre-Chronicle post-genesis — they neither push nor
363
+ # consume. This matches TS Spend.ts (line ~710) and Go opcodeVerConditional.
364
+ def skipped_ver_conditional?(opcode)
365
+ return false unless VER_CONDITIONAL_OPCODES.include?(opcode)
366
+
367
+ !chronicle? && after_genesis?
368
+ end
369
+
370
+ # Pre-Genesis VERIF / VERNOTIF are unconditionally illegal (even in
371
+ # non-executing branches — they enter the dispatcher as conditional opcodes).
372
+ # OP_VER pre-Genesis is illegal only when executing; in a non-executing
373
+ # branch it's skipped silently. Mirrors the Bitcoin Core script test
374
+ # rule "VER non-functional (ok if not executed); VERIF illegal everywhere".
375
+ def enforce_pre_genesis_ver_gate(opcode)
376
+ return unless VER_CONDITIONAL_OPCODES.include?(opcode)
377
+ return unless explicit_flags? && !after_genesis?
378
+
379
+ raise ScriptError.new(
380
+ ScriptErrorCode::DISABLED_OPCODE,
381
+ "#{Opcodes.name_for(opcode) || format('0x%02x', opcode)} is illegal pre-Genesis"
382
+ )
383
+ end
384
+
169
385
  def dispatch_opcode(opcode, chunk)
170
386
  case opcode
171
387
  # --- Data push ---
@@ -295,9 +511,12 @@ module BSV
295
511
  end
296
512
 
297
513
  # Verify final stack state: must have at least one truthy element on top.
514
+ # When the CLEANSTACK flag is set, additionally requires exactly one item.
298
515
  def check_final_stack
299
516
  raise ScriptError.new(ScriptErrorCode::EMPTY_STACK, 'stack empty at end of script execution') if @dstack.empty?
300
517
 
518
+ enforce_clean_stack
519
+
301
520
  return if @dstack.pop_bool
302
521
 
303
522
  raise ScriptError.new(ScriptErrorCode::EVAL_FALSE, 'false stack entry at end of script execution')
@@ -42,12 +42,21 @@ module BSV
42
42
  @else_stack.push(false)
43
43
  end
44
44
 
45
- # OP_ELSE: toggle conditional branch (only one ELSE per IF after genesis)
45
+ # OP_ELSE: toggle the current conditional branch.
46
+ #
47
+ # Multi-ELSE handling matches the TS SDK (Spend.ts OP_ELSE case):
48
+ # - In relaxed mode (no explicit flags), each ELSE toggles the branch —
49
+ # any number of ELSEs per IF is valid. This is the script-021 case.
50
+ # - With explicit post-genesis flags, only one ELSE per IF is permitted;
51
+ # a second ELSE raises UNBALANCED_CONDITIONAL. Vectors such as
52
+ # node.script.bitcoin-sv.0731 and teranode.0057 enforce this.
46
53
  def op_else
47
54
  raise ScriptError.new(ScriptErrorCode::UNBALANCED_CONDITIONAL, 'OP_ELSE without matching OP_IF') if @cond_stack.empty?
48
55
 
49
- # After genesis: only one ELSE per IF
50
- raise ScriptError.new(ScriptErrorCode::UNBALANCED_CONDITIONAL, 'duplicate OP_ELSE') if @else_stack.pop
56
+ already_seen_else = @else_stack.pop
57
+ if already_seen_else && explicit_flags? && after_genesis?
58
+ raise ScriptError.new(ScriptErrorCode::UNBALANCED_CONDITIONAL, 'duplicate OP_ELSE')
59
+ end
51
60
 
52
61
  case @cond_stack.last
53
62
  when :true then @cond_stack[-1] = :false
@@ -97,20 +106,23 @@ module BSV
97
106
 
98
107
  # OP_VER: push 4-byte little-endian transaction version onto the stack.
99
108
  #
100
- # Requires a transaction context (@tx must not be nil). Raises
101
- # MISSING_TX_CONTEXT when called from Interpreter.evaluate (no-tx path).
109
+ # Resolves the version from +@tx.version+ (when verifying a
110
+ # transaction) or from the +tx_version:+ parameter passed to
111
+ # +Interpreter.evaluate+. Raises MISSING_TX_CONTEXT only if neither
112
+ # is available.
102
113
  #
103
114
  # The version is always encoded as exactly 4 bytes (LE uint32), never as
104
115
  # a ScriptNumber. Version 1 → 01000000, version 2 → 02000000.
105
116
  def op_ver
106
- if @tx.nil?
117
+ bytes = tx_version_bytes
118
+ if bytes.nil?
107
119
  raise ScriptError.new(
108
120
  ScriptErrorCode::MISSING_TX_CONTEXT,
109
121
  'OP_VER requires transaction context'
110
122
  )
111
123
  end
112
124
 
113
- @dstack.push_bytes(tx_version_bytes)
125
+ @dstack.push_bytes(bytes)
114
126
  end
115
127
 
116
128
  # OP_VERIF: version-conditional branch — executes like OP_IF but compares
@@ -166,20 +178,25 @@ module BSV
166
178
  end
167
179
 
168
180
  # Returns the transaction version as a 4-byte little-endian binary
169
- # string. Used by OP_VER, OP_VERIF, and OP_VERNOTIF.
181
+ # string, or +nil+ if no version is available. Used by OP_VER,
182
+ # OP_VERIF, and OP_VERNOTIF. Prefers +@tx.version+ when verifying a
183
+ # transaction; falls back to the +tx_version:+ argument used by
184
+ # evaluate-without-tx (e.g. conformance corpus runs).
170
185
  def tx_version_bytes
171
- [@tx.version].pack('V')
186
+ v = @tx&.version || @tx_version
187
+ v.nil? ? nil : [v].pack('V')
172
188
  end
173
189
 
174
190
  # Compares raw bytes against the current transaction version.
175
191
  # Returns false if:
176
192
  # - +data+ is not exactly 4 bytes
177
- # - @tx is nil (no transaction context)
178
- # - the bytes do not match the 4-byte LE encoding of @tx.version
193
+ # - no version is available (neither @tx nor @tx_version set)
194
+ # - the bytes do not match the 4-byte LE encoding of the version
179
195
  def tx_version_matches?(data)
180
- return false if @tx.nil? || data.bytesize != 4
196
+ return false if data.bytesize != 4
181
197
 
182
- data == tx_version_bytes
198
+ bytes = tx_version_bytes
199
+ !bytes.nil? && data == bytes
183
200
  end
184
201
  end
185
202
  end
@@ -93,7 +93,7 @@ module BSV
93
93
  # the wallet's derived key, then returns a P2PKH unlock wrapped in a
94
94
  # PushDrop unlock (which is a pass-through).
95
95
  #
96
- # @param tx [BSV::Transaction::Tx] the spending transaction
96
+ # @param tx [Transaction::Tx] the spending transaction
97
97
  # @param input_index [Integer] which input to sign
98
98
  # @return [BSV::Script::Script] the unlocking script
99
99
  def sign(tx, input_index)
@@ -125,7 +125,7 @@ module BSV
125
125
 
126
126
  # Estimated byte length of the unlocking script.
127
127
  #
128
- # @param _tx [BSV::Transaction::Tx] unused
128
+ # @param _tx [Transaction::Tx] unused
129
129
  # @param _input_index [Integer] unused
130
130
  # @return [Integer]
131
131
  def estimated_length(_tx, _input_index)
@@ -22,12 +22,20 @@ module BSV
22
22
  # script.p2pkh? #=> true
23
23
  # script.to_asm #=> "OP_DUP OP_HASH160 ... OP_EQUALVERIFY OP_CHECKSIG"
24
24
  class Script
25
- # @return [String] the raw script bytes
25
+ # @return [String] the raw script bytes (frozen, binary encoding)
26
+ # @note Scripts are protocol-level immutable; the underlying bytes are
27
+ # defensively copied + frozen on construction. Downstream callers
28
+ # that need to build a script incrementally must compose bytes
29
+ # into a new String and pass it to a fresh +Script.new+.
26
30
  attr_reader :bytes
27
31
 
28
32
  # @param bytes [String] raw script bytes (default: empty)
29
33
  def initialize(bytes = ''.b)
30
- @bytes = bytes.b
34
+ # Defensive copy + freeze: scripts are immutable in the protocol; freezing
35
+ # @bytes prevents external in-place mutation from staling caches that
36
+ # depend on the script's serialisation (see
37
+ # docs/reference/sighash-cache.md).
38
+ @bytes = bytes.b.dup.freeze
31
39
  @chunks = nil
32
40
  end
33
41
 
@@ -460,6 +468,17 @@ module BSV
460
468
  b.getbyte(22) == Opcodes::OP_EQUAL
461
469
  end
462
470
 
471
+ # Whether the script consists entirely of push-data operations.
472
+ #
473
+ # A script is push-only iff every chunk's opcode is at most +OP_16+
474
+ # (0x60). Used by the script interpreter to enforce the +SIGPUSHONLY+
475
+ # verification flag on unlock scripts.
476
+ #
477
+ # @return [Boolean]
478
+ def push_only?
479
+ chunks.all? { |chunk| chunk.opcode <= Opcodes::OP_16 }
480
+ end
481
+
463
482
  # Whether this is an OP_RETURN data carrier script.
464
483
  #
465
484
  # Matches both +OP_RETURN ...+ and +OP_FALSE OP_RETURN ...+ forms.
data/lib/bsv/script.rb CHANGED
@@ -18,5 +18,6 @@ module BSV
18
18
  autoload :Stack, 'bsv/script/interpreter/stack'
19
19
 
20
20
  autoload :PushDropTemplate, 'bsv/script/push_drop_template'
21
+ autoload :BIP276, 'bsv/script/bip276'
21
22
  end
22
23
  end
@@ -0,0 +1,174 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'net/http'
4
+ require 'openssl'
5
+ require 'uri'
6
+
7
+ module BSV
8
+ module Storage
9
+ # Result returned by {Downloader#download}.
10
+ DownloadResult = Data.define(:data, :mime_type)
11
+
12
+ # Downloads UHRP-addressed content from distributed storage hosts.
13
+ #
14
+ # Resolution: queries the +ls_uhrp+ lookup service via a {BSV::Overlay::LookupResolver},
15
+ # decodes each PushDrop output to extract the host URL (field[2]) and expiry timestamp
16
+ # (field[3], varint). Expired entries are silently dropped.
17
+ #
18
+ # Download: fetches each resolved URL in turn. Any HTTP 4xx/5xx, empty body, or
19
+ # network exception is treated as a failed host and the next URL is tried. This
20
+ # matches the TS SDK contract — no special treatment of 401/402/403.
21
+ #
22
+ # HTTP redirects are not followed (Net::HTTP default). This is intentional in v1.
23
+ #
24
+ # Download URLs are taken directly from the overlay; no private-IP filter is applied
25
+ # here. (The LookupResolver applies SSRF filtering to SLAP-discovered *infrastructure*
26
+ # hosts — content URLs are end-user data and are not subject to the same filter.)
27
+ class Downloader
28
+ # @param network_preset [Symbol] :mainnet, :testnet, or :local
29
+ # @param lookup_resolver [BSV::Overlay::LookupResolver] injectable resolver (nil = default)
30
+ # @param http_client [#call, nil] injectable HTTP client for testing.
31
+ # Must respond to +.call(url_string)+ and return an object exposing +#code+ (Integer),
32
+ # +#body+ (binary String), and +#[](header_name)+ for header access.
33
+ # Default: a thin {Net::HTTP.get_response} wrapper.
34
+ # @param timeout [Integer] per-request timeout in seconds
35
+ def initialize(network_preset: :mainnet, lookup_resolver: nil, http_client: nil, timeout: 30)
36
+ @lookup_resolver = lookup_resolver || BSV::Overlay::LookupResolver.new(network_preset: network_preset)
37
+ @http_client = http_client
38
+ @timeout = timeout
39
+ end
40
+
41
+ # Resolve a UHRP URL to a list of HTTP(S) download URLs.
42
+ #
43
+ # Queries the +ls_uhrp+ lookup service, decodes each PushDrop output,
44
+ # drops expired entries, and returns the remaining URLs.
45
+ #
46
+ # @param uhrp_url [String] UHRP URL (with or without +uhrp://+ prefix)
47
+ # @return [Array<String>] resolved HTTP(S) download URLs
48
+ # @raise [BSV::Storage::DownloadError] if the lookup answer is not an output-list
49
+ def resolve(uhrp_url)
50
+ question = BSV::Overlay::LookupQuestion.new(
51
+ service: 'ls_uhrp',
52
+ query: { 'uhrpUrl' => BSV::Storage::Utils.normalize_url(uhrp_url) }
53
+ )
54
+ answer = @lookup_resolver.query(question)
55
+ raise DownloadError, 'Lookup answer must be an output list' unless answer.type == 'output-list'
56
+
57
+ current_time = Time.now.to_i
58
+ urls = []
59
+
60
+ answer.outputs.each do |output|
61
+ url = decode_output_url(output, current_time)
62
+ urls << url if url
63
+ end
64
+
65
+ urls
66
+ end
67
+
68
+ # Download the content addressed by +uhrp_url+.
69
+ #
70
+ # Validates the URL, resolves it to a list of hosts, then attempts each
71
+ # host in order. Verifies the SHA-256 hash of the downloaded body against
72
+ # the hash embedded in the UHRP URL. Returns on the first successful match.
73
+ #
74
+ # @param uhrp_url [String] UHRP URL
75
+ # @return [BSV::Storage::DownloadResult] downloaded data and MIME type
76
+ # @raise [ArgumentError] if the URL is not a valid UHRP URL
77
+ # @raise [BSV::Storage::DownloadError] if no host yields content matching the hash
78
+ def download(uhrp_url)
79
+ raise ArgumentError, 'Invalid parameter UHRP url' unless BSV::Storage::Utils.valid_url?(uhrp_url)
80
+
81
+ urls = resolve(uhrp_url)
82
+ raise DownloadError, 'No one currently hosts this file!' if urls.empty?
83
+
84
+ expected_hash = BSV::Storage::Utils.get_hash_from_url(uhrp_url)
85
+
86
+ urls.each do |url|
87
+ result = attempt_download(url, expected_hash)
88
+ return result if result
89
+ end
90
+
91
+ raise DownloadError, "Unable to download content from #{uhrp_url}"
92
+ end
93
+
94
+ private
95
+
96
+ def decode_output_url(output, current_time)
97
+ beef_data = output['beef'] || output[:beef]
98
+ output_index = (output['outputIndex'] || output[:output_index] || 0).to_i
99
+ return nil if beef_data.nil?
100
+ return nil if output_index.negative?
101
+
102
+ beef = parse_beef(beef_data)
103
+ return nil unless beef
104
+
105
+ beef_tx = beef.transactions.last
106
+ return nil if beef_tx.nil? || beef_tx.is_a?(BSV::Transaction::Beef::TxidOnlyEntry)
107
+
108
+ txout = beef_tx.transaction.outputs[output_index]
109
+ return nil unless txout
110
+
111
+ fields = txout.locking_script.pushdrop_fields
112
+ return nil if fields.nil? || fields.length < 4
113
+
114
+ expiry, = BSV::Transaction::VarInt.decode(fields[3])
115
+ return nil if expiry < current_time
116
+
117
+ url = fields[2].force_encoding('UTF-8')
118
+ return nil unless url.valid_encoding?
119
+
120
+ url
121
+ rescue StandardError
122
+ nil
123
+ end
124
+
125
+ def parse_beef(beef_data)
126
+ case beef_data
127
+ when String
128
+ BSV::Transaction::Beef.from_binary(beef_data)
129
+ when Array
130
+ BSV::Transaction::Beef.from_binary(beef_data.pack('C*'))
131
+ end
132
+ rescue StandardError
133
+ nil
134
+ end
135
+
136
+ def attempt_download(url, expected_hash)
137
+ response = fetch(url)
138
+ return nil if response.nil?
139
+
140
+ code = response.code.to_i
141
+ # 3xx is treated as a failed host: redirects are intentionally not followed in v1,
142
+ # so a 302 body is never the content we want.
143
+ return nil if code >= 300
144
+
145
+ body = response.body.to_s
146
+ return nil if body.empty?
147
+
148
+ actual_hash = BSV::Primitives::Digest.sha256(body)
149
+ return nil unless actual_hash == expected_hash
150
+
151
+ DownloadResult.new(data: body, mime_type: response['Content-Type'])
152
+ rescue StandardError
153
+ nil
154
+ end
155
+
156
+ def fetch(url)
157
+ if @http_client
158
+ @http_client.call(url)
159
+ else
160
+ uri = URI(url)
161
+ Net::HTTP.start(
162
+ uri.hostname,
163
+ uri.port,
164
+ use_ssl: uri.scheme == 'https',
165
+ open_timeout: @timeout,
166
+ read_timeout: @timeout
167
+ ) { |http| http.request(Net::HTTP::Get.new(uri)) }
168
+ end
169
+ rescue StandardError
170
+ nil
171
+ end
172
+ end
173
+ end
174
+ end
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BSV
4
+ module Storage
5
+ # Raised when a UHRP file cannot be downloaded from any available host.
6
+ class DownloadError < StandardError; end
7
+ end
8
+ end