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
@@ -9,6 +9,13 @@ module BSV
9
9
  # computation (with FORKID), signing, script verification, and fee
10
10
  # estimation.
11
11
  #
12
+ # @note Not thread-safe. Direct mutation of {#inputs} / {#outputs} arrays
13
+ # (e.g. `tx.inputs << input`) bypasses the cache-invalidation contract
14
+ # and may produce silently invalid sighashes. Use {#add_input} /
15
+ # {#add_output} and the documented setter surface, or call
16
+ # {#invalidate_caches} after direct mutation. See
17
+ # {file:docs/reference/sighash-cache.md}.
18
+ #
12
19
  # @example Build, sign, and serialise a transaction
13
20
  # tx = BSV::Transaction::Tx.new
14
21
  # tx.add_input(input)
@@ -59,35 +66,151 @@ module BSV
59
66
 
60
67
  # Append a transaction input.
61
68
  #
62
- # @param input [TransactionInput] the input to add
69
+ # Idempotent on re-add: calling +add_input(x)+ twice with the same input
70
+ # on the same +Transaction::Tx+ returns +self+ on the second call without
71
+ # appending a duplicate or invalidating caches. Raises +ArgumentError+
72
+ # if the input is already attached to a different +Transaction::Tx+.
73
+ #
74
+ # @param input [Transaction::TransactionInput] the input to add
63
75
  # @return [self] for chaining
76
+ # @raise [ArgumentError] if the input is already attached to a different
77
+ # Tx instance. Sharing across Tx instances is anti-idiomatic;
78
+ # construct a fresh instance per Tx. See
79
+ # {file:docs/reference/sighash-cache.md#one-owner}.
64
80
  def add_input(input)
81
+ existing_owner = input.instance_variable_get(:@owning_tx)
82
+ if existing_owner
83
+ return self if existing_owner.equal?(self)
84
+
85
+ raise ArgumentError,
86
+ "TransactionInput #{input.dtxid_hex}:#{input.prev_tx_out_index} is already attached to a Tx"
87
+ end
88
+
89
+ input.instance_variable_set(:@owning_tx, self)
65
90
  @inputs << input
91
+ # Avoid the O(N+M) rebind walk inside invalidate_caches — the new
92
+ # input's backref was just set above and every existing input/output
93
+ # was rebound when it was originally added. clear_caches is O(1).
94
+ clear_caches
66
95
  self
67
96
  end
68
97
 
69
98
  # Append a transaction output.
70
99
  #
71
- # @param output [TransactionOutput] the output to add
100
+ # Idempotent on re-add: calling +add_output(x)+ twice with the same output
101
+ # on the same +Transaction::Tx+ returns +self+ on the second call without
102
+ # appending a duplicate or invalidating caches. Raises +ArgumentError+
103
+ # if the output is already attached to a different +Transaction::Tx+.
104
+ #
105
+ # @param output [Transaction::TransactionOutput] the output to add
72
106
  # @return [self] for chaining
107
+ # @raise [ArgumentError] if the output is already attached to a different
108
+ # Tx instance. Sharing across Tx instances is anti-idiomatic;
109
+ # construct a fresh instance per Tx. See
110
+ # {file:docs/reference/sighash-cache.md#one-owner}.
73
111
  def add_output(output)
112
+ existing_owner = output.instance_variable_get(:@owning_tx)
113
+ if existing_owner
114
+ return self if existing_owner.equal?(self)
115
+
116
+ asm_fragment = output.locking_script&.to_asm || '<nil locking_script>'
117
+ asm_fragment = "#{asm_fragment[0, 40]}..." if asm_fragment.length > 40
118
+ raise ArgumentError,
119
+ "TransactionOutput (#{asm_fragment}) is already attached to a Tx"
120
+ end
121
+
122
+ output.instance_variable_set(:@owning_tx, self)
74
123
  @outputs << output
124
+ # Avoid the O(N+M) rebind walk inside invalidate_caches — the new
125
+ # output's backref was just set above and every existing input/output
126
+ # was rebound when it was originally added. clear_caches is O(1).
127
+ clear_caches
128
+ self
129
+ end
130
+
131
+ # Invalidate all cached state on this transaction (sighash components,
132
+ # wire serialisation, etc.) AND restore the +@owning_tx+ backref on every
133
+ # current input and output. This is what makes the escape hatch complete:
134
+ # if a caller appended an input/output via direct array mutation
135
+ # (bypassing +add_input+ / +add_output+), the new struct's backref is nil
136
+ # and future +sequence=+ / +locking_script=+ / etc. setters would not
137
+ # bubble invalidation up. After +invalidate_caches+, the backref is
138
+ # rebound so subsequent setter mutations flow through correctly.
139
+ #
140
+ # Raises +ArgumentError+ if any input or output is already attached to a
141
+ # different +Transaction::Tx+ — matches the cross-Tx rebind contract of
142
+ # +add_input+ / +add_output+.
143
+ #
144
+ # @example After mutating the inputs array directly
145
+ # tx.inputs << input # bypasses the documented setter surface
146
+ # tx.invalidate_caches # clears all cache layers + rebinds @owning_tx
147
+ # input.sequence = 42 # now invalidates correctly
148
+ # tx.verify(...) # safe
149
+ #
150
+ # @note Rarely needed. Normal mutation through the documented setter
151
+ # surface (input.sequence=, output.satoshis=, etc.) invalidates the
152
+ # right cache slices automatically. This method is an escape hatch
153
+ # for code that mutates @inputs / @outputs through unsupported paths.
154
+ # See {file:docs/reference/sighash-cache.md#escape-hatch}.
155
+ #
156
+ # @return [self] for chaining
157
+ # @raise [ArgumentError] if any input or output is attached to a different
158
+ # Tx instance
159
+ def invalidate_caches
160
+ rebind_owning_tx!
161
+ clear_caches
75
162
  self
76
163
  end
77
164
 
165
+ # Called by +#dup+ and +#clone+. Deep-dups +@inputs+ and +@outputs+ so that
166
+ # the copy and the original do not share mutable input/output state. Rebinds
167
+ # +@owning_tx+ on each copied struct to +self+ (the new transaction). Resets
168
+ # all cache ivars directly so the dup does not share mutable cache state
169
+ # (especially +@hash_outputs_single+, a Hash) with the original.
170
+ #
171
+ # This closes the hazard at +beef.rb:703,707+ where a shallow dup would leave
172
+ # the copied inputs/outputs pointing at the original transaction's cache.
173
+ #
174
+ # Note: we cannot delegate to +invalidate_caches+ here because that method
175
+ # calls +Hash#clear+ on +@hash_outputs_single+, which would mutate the
176
+ # shared Hash and evict the *original*'s per-index cache too. Direct
177
+ # +ivar = nil+ assignments on +self+ leave the original untouched.
178
+ def initialize_copy(other)
179
+ super
180
+ @inputs = @inputs.map do |i|
181
+ dup_input = i.dup
182
+ dup_input.instance_variable_set(:@owning_tx, self)
183
+ dup_input
184
+ end
185
+ @outputs = @outputs.map do |o|
186
+ dup_output = o.dup
187
+ dup_output.instance_variable_set(:@owning_tx, self)
188
+ dup_output
189
+ end
190
+ @to_binary = nil
191
+ @wtxid = nil
192
+ @hash_prevouts = nil
193
+ @hash_sequence = nil
194
+ @hash_outputs_all = nil
195
+ @hash_outputs_single = nil
196
+ end
197
+
78
198
  # --- Serialisation ---
79
199
 
80
200
  # Serialise the transaction to its binary wire format.
81
201
  #
82
- # @return [String] raw transaction bytes
202
+ # @note Memoised; see {file:docs/reference/sighash-cache.md} for the invalidation contract.
203
+ # @return [String] raw transaction bytes (binary encoding, frozen)
83
204
  def to_binary
84
- buf = [@version].pack('V')
85
- buf << VarInt.encode(@inputs.length)
86
- @inputs.each { |i| buf << i.to_binary }
87
- buf << VarInt.encode(@outputs.length)
88
- @outputs.each { |o| buf << o.to_binary }
89
- buf << [@lock_time].pack('V')
90
- buf
205
+ @to_binary ||= begin
206
+ buf = [@version].pack('V')
207
+ buf << VarInt.encode(@inputs.length)
208
+ @inputs.each { |i| buf << i.to_binary }
209
+ buf << VarInt.encode(@outputs.length)
210
+ @outputs.each { |o| buf << o.to_binary }
211
+ buf << [@lock_time].pack('V')
212
+ buf.b.freeze
213
+ end
91
214
  end
92
215
 
93
216
  # Serialise the transaction to a hex string.
@@ -144,7 +267,7 @@ module BSV
144
267
  # Deserialise a transaction from binary data.
145
268
  #
146
269
  # @param data [String] raw binary transaction
147
- # @return [Tx] the parsed transaction
270
+ # @return [Transaction::Tx] the parsed transaction
148
271
  def self.from_binary(data)
149
272
  raise ArgumentError, "truncated transaction: need at least 10 bytes, got #{data.bytesize}" if data.bytesize < 10
150
273
 
@@ -182,7 +305,7 @@ module BSV
182
305
  # Deserialise a transaction from a hex string.
183
306
  #
184
307
  # @param hex [String] hex-encoded transaction
185
- # @return [Tx] the parsed transaction
308
+ # @return [Transaction::Tx] the parsed transaction
186
309
  def self.from_hex(hex)
187
310
  from_binary(BSV::Primitives::Hex.decode(hex, name: 'transaction hex'))
188
311
  end
@@ -190,7 +313,7 @@ module BSV
190
313
  # Deserialise a transaction from Extended Format (BRC-30) binary data.
191
314
  #
192
315
  # @param data [String] raw EF binary
193
- # @return [Tx] the parsed transaction with source data on inputs
316
+ # @return [Transaction::Tx] the parsed transaction with source data on inputs
194
317
  # @raise [ArgumentError] if the EF marker is invalid
195
318
  def self.from_ef(data)
196
319
  raise ArgumentError, "truncated EF transaction: need at least 10 bytes, got #{data.bytesize}" if data.bytesize < 10
@@ -250,7 +373,7 @@ module BSV
250
373
  # Deserialise a transaction from an Extended Format hex string.
251
374
  #
252
375
  # @param hex [String] hex-encoded EF transaction
253
- # @return [Tx] the parsed transaction with source data on inputs
376
+ # @return [Transaction::Tx] the parsed transaction with source data on inputs
254
377
  def self.from_ef_hex(hex)
255
378
  from_ef(BSV::Primitives::Hex.decode(hex, name: 'EF transaction hex'))
256
379
  end
@@ -260,7 +383,7 @@ module BSV
260
383
  #
261
384
  # @param data [String] binary data containing the transaction
262
385
  # @param offset [Integer] byte offset to start reading from
263
- # @return [Array(Tx, Integer)] the transaction and bytes consumed
386
+ # @return [Array(Transaction::Tx, Integer)] the transaction and bytes consumed
264
387
  def self.from_binary_with_offset(data, offset = 0)
265
388
  if data.bytesize < offset + 10
266
389
  raise ArgumentError, "truncated transaction: need at least 10 bytes at offset #{offset}, got #{data.bytesize - offset}"
@@ -370,7 +493,7 @@ module BSV
370
493
  # +wire_source_transactions+ pass in +Beef.from_binary+.
371
494
  #
372
495
  # @param data [String] raw BEEF binary
373
- # @return [Tx, nil] the subject transaction with ancestry wired,
496
+ # @return [Transaction::Tx, nil] the subject transaction with ancestry wired,
374
497
  # or nil if the BEEF is empty or contains no raw transaction entries
375
498
  def self.from_beef(data)
376
499
  beef = Beef.from_binary(data)
@@ -384,7 +507,7 @@ module BSV
384
507
  # Parse a BEEF hex string and return the subject transaction.
385
508
  #
386
509
  # @param hex [String] hex-encoded BEEF
387
- # @return [Tx, nil] the subject transaction with ancestry wired,
510
+ # @return [Transaction::Tx, nil] the subject transaction with ancestry wired,
388
511
  # or nil if the BEEF is empty or contains no raw transaction entries
389
512
  def self.from_beef_hex(hex)
390
513
  from_beef(BSV::Primitives::Hex.decode(hex, name: 'BEEF hex'))
@@ -397,11 +520,14 @@ module BSV
397
520
  # Used by BEEF, BUMPs, and merkle paths, which all work in wire byte order
398
521
  # to match {TransactionInput#prev_wtxid}.
399
522
  #
523
+ # @note Memoised; see {file:docs/reference/sighash-cache.md} for the invalidation contract.
400
524
  # @return [String] 32-byte transaction ID in wire byte order
401
525
  def wtxid
402
- id = BSV::Primitives::Digest.sha256d(to_binary)
403
- BSV.logger&.debug { "[Tx] wtxid computed (dtxid=#{id.reverse.unpack1('H*')})" }
404
- id
526
+ @wtxid ||= begin
527
+ id = BSV::Primitives::Digest.sha256d(to_binary)
528
+ BSV.logger&.debug { "[Tx] wtxid computed (dtxid=#{id.reverse.unpack1('H*')})" }
529
+ id.freeze
530
+ end
405
531
  end
406
532
 
407
533
  # The transaction ID as a hex string (display byte order).
@@ -621,25 +747,103 @@ module BSV
621
747
  # - +:output_overflow+ — Ruby raises; TS/Python return +false+; Go omits the check
622
748
  # - +:script_failure+ — Ruby raises; TS/Python return +false+; Go also propagates errors
623
749
  # - +:missing_source+ — Ruby raises; consistent with TS/Go/Python (all raise/error)
750
+ # - +verified:+ kwarg — Ruby-only; no equivalent seam exists in the TS, Go, or Python SDKs.
751
+ # This is a novel Ruby-side extension: a bidirectional wtxid-dedup Hash that the caller
752
+ # can seed (short-circuit ancestors already verified) and read after +verify+ returns
753
+ # (populate a persistent verification cache). See {file:docs/reference/verify-kwarg.md}.
754
+ # The reference SDKs repeat the full walk every time +verify+ is called.
624
755
  #
625
- # @param chain_tracker [ChainTracker] chain tracker for merkle root validation
756
+ # @param chain_tracker [Transaction::ChainTracker] chain tracker for merkle root validation
626
757
  # @param fee_model [FeeModel, nil] optional fee model to validate the root transaction's fee
758
+ # @param verified [Hash{String => Boolean}, nil] bidirectional wtxid dedup Hash.
759
+ # Keys are 32-byte wire-order binary wtxids (not hex). Values are presence-only —
760
+ # the SDK writes +true+ for wtxids it walks and treats any truthy value as
761
+ # "already verified". A +false+ value is treated as absent (does not short-circuit)
762
+ # and will be overwritten with +true+ if the wtxid is subsequently walked.
763
+ # Callers use this three ways:
764
+ #
765
+ # - **Pre-seed** (short-circuit): pass a Hash pre-populated with wtxids you have
766
+ # verified in an earlier session. Their subtrees are skipped.
767
+ # - **Post-read** (accumulate): pass an empty Hash; after +verify+ returns, read the
768
+ # keys to see which wtxids the SDK walked. Populates a persistent verification cache.
769
+ # - **Bidirectional** (both): pass a Hash that seeds the walk AND collects the walked
770
+ # wtxids in one call.
771
+ #
772
+ # The Hash is mutated in place — the caller's object identity is preserved.
773
+ # +nil+ (default) means "no seed, no post-read" — the SDK uses an internal Hash the
774
+ # caller cannot access. Rejected at entry with +ArgumentError+: anything that is not
775
+ # +nil+ or a mutable +Hash+; a frozen +Hash+; or a +Hash+ with a truthy +default+
776
+ # value or any +default_proc+ (funds risk — missing keys would return truthy and
777
+ # silently short-circuit verification).
627
778
  # @return [true] on successful verification
779
+ # @raise [ArgumentError] if +verified:+ is (a) not +nil+ or a +Hash+, (b) a frozen
780
+ # +Hash+ (the SDK writes walked wtxids into it and cannot mutate a frozen one), or
781
+ # (c) a +Hash+ with a truthy +default+ value or a +default_proc+ (missing keys
782
+ # would return truthy and silently short-circuit verification — funds risk)
628
783
  # @raise [VerificationError] with code +:invalid_merkle_proof+ if a merkle proof is invalid
629
784
  # @raise [VerificationError] with code +:insufficient_fee+ if the fee is below the model's threshold
630
785
  # @raise [VerificationError] with code +:output_overflow+ if outputs exceed inputs
631
786
  # @raise [VerificationError] with code +:script_failure+ if script execution fails
632
787
  # @raise [VerificationError] with code +:missing_source+ if an input is missing required source data
633
- def verify(chain_tracker:, fee_model: nil)
634
- verified = {}
788
+ # @note Security (trust contract): the caller warrants that every wtxid present in
789
+ # +verified:+ at method entry is fully verified — script AND chain-anchor (merkle proof)
790
+ # AND any consensus-flag context. A seeded wtxid is treated as fully verified regardless
791
+ # of what proof or script data it carries. The SDK does **not** re-run the merkle proof
792
+ # for a seeded ancestor. If the caller's cache is stale or compromised, +verify+ will
793
+ # return +true+ for ancestry that has not actually been verified. Cached wtxids are
794
+ # pinned to the consensus flags under which they were originally verified — invalidate
795
+ # the caller's cache when consensus flags change, on chain reorganisation, or on any
796
+ # event that could invalidate a prior verification. Fee validation is not affected by
797
+ # this set — the fee gate is a caller-passed policy, not a cached claim, and runs once
798
+ # at the start of every +verify+ call when +fee_model+ is given.
799
+ # @note Concurrency: the SDK writes to +verified:+ in place without locking. Passing the
800
+ # same Hash to concurrent +verify+ calls is a race — guard it externally, or give each
801
+ # thread its own Hash and merge the results afterwards. The class-level
802
+ # "Not thread-safe" note on {Tx} covers direct-mutation concerns; this is the
803
+ # specific footgun that comes with the in-place-mutation contract of this kwarg.
804
+ # @example Pre-seed short-circuit
805
+ # # wallet has already verified source_tx in a prior session
806
+ # cache = { source_tx.wtxid => true }
807
+ # tx.verify(chain_tracker: tracker, verified: cache)
808
+ # @example Post-read accumulate
809
+ # walked = {}
810
+ # tx.verify(chain_tracker: tracker, verified: walked)
811
+ # walked.keys # => [subject_wtxid, ancestor1_wtxid, ...]
812
+ # @example Bidirectional round-trip
813
+ # cache = load_persistent_verified_cache
814
+ # tx.verify(chain_tracker: tracker, verified: cache)
815
+ # save_persistent_verified_cache(cache) # includes newly-walked wtxids
816
+ # @since 0.11.0
817
+ def verify(chain_tracker:, fee_model: nil, verified: nil)
818
+ verified = normalise_verified(verified)
819
+ # Fee gate runs once at the start of every verify call. Fee is a caller-passed policy,
820
+ # not a cached script-validity claim — seeding the subject wtxid cannot silently disable it.
821
+ # verify_fee → total_input_satoshis raises ArgumentError when source data is missing;
822
+ # translate that to :missing_source VerificationError so callers see a consistent error
823
+ # taxonomy from #verify (all failure modes raise VerificationError, not ArgumentError).
824
+ if fee_model
825
+ begin
826
+ verify_fee(fee_model)
827
+ rescue ArgumentError => e
828
+ raise VerificationError.new(:missing_source,
829
+ "cannot compute fee: #{e.message}")
830
+ end
831
+ end
832
+
635
833
  queue = [self]
636
834
 
637
835
  until queue.empty?
638
836
  tx = queue.shift
639
837
  wtxid = tx.wtxid
640
- next if verified[wtxid]
838
+ # Top-of-loop dedup covers both walked-during-this-call and caller-seeded wtxids.
839
+ # A seeded wtxid short-circuits everything — including the merkle proof — because
840
+ # the caller warrants full trust. See @note Security.
841
+ # fetch(k, false) bypasses any custom Hash default / default_proc — critical, because
842
+ # Hash.new(true)[missing_key] would return true and short-circuit verification silently
843
+ # (funds risk). normalise_verified also rejects such Hashes at entry, but this is
844
+ # defence in depth.
845
+ next if verified.fetch(wtxid, false)
641
846
 
642
- # Merkle path short-circuit: proven transaction needs no input verification
643
847
  if tx.merkle_path
644
848
  unless tx.merkle_path.verify(tx.txid_hex, chain_tracker)
645
849
  raise VerificationError.new(:invalid_merkle_proof,
@@ -650,9 +854,6 @@ module BSV
650
854
  next
651
855
  end
652
856
 
653
- # Fee validation (root transaction only)
654
- verify_fee(fee_model) if tx.equal?(self) && fee_model
655
-
656
857
  # Verify each input
657
858
  tx.inputs.each_with_index do |input, index|
658
859
  # Populate source data from source_transaction when not already set.
@@ -676,9 +877,10 @@ module BSV
676
877
  )
677
878
  end
678
879
 
679
- # Enqueue source transaction for verification if not yet verified
880
+ # Enqueue source transaction unless it's already dealt with (seeded or walked).
881
+ # fetch(k, false) bypasses any custom Hash default — see note above at top-of-loop dedup.
680
882
  source_tx = input.source_transaction
681
- queue << source_tx if source_tx && !verified[source_tx.wtxid]
883
+ queue << source_tx if source_tx && !verified.fetch(source_tx.wtxid, false)
682
884
  end
683
885
 
684
886
  # Output ≤ input check
@@ -806,7 +1008,7 @@ module BSV
806
1008
  # is required. Otherwise, requires a wired +source_transaction+ and a
807
1009
  # valid output at +prev_tx_out_index+.
808
1010
  #
809
- # @param input [TransactionInput]
1011
+ # @param input [Transaction::TransactionInput]
810
1012
  # @param idx [Integer] input index, used in error messages
811
1013
  # @return [TransactionOutput, nil]
812
1014
  def ef_source_output(input, idx)
@@ -832,6 +1034,44 @@ module BSV
832
1034
  "input #{idx} source_transaction has no output at index #{input.prev_tx_out_index}"
833
1035
  end
834
1036
 
1037
+ # Validate the +verified:+ kwarg for {#verify}.
1038
+ #
1039
+ # Returns the caller's Hash directly (no dup, no freeze) so the SDK can mutate it in place —
1040
+ # the bidirectional contract from {#verify}. When +arg+ is +nil+, returns a fresh empty
1041
+ # Hash that the caller has no reference to; that Hash is discarded when +verify+ returns.
1042
+ #
1043
+ # @param arg [Hash, nil]
1044
+ # @return [Hash] the caller's Hash unchanged, or a fresh empty Hash if arg was nil
1045
+ # @raise [ArgumentError] if +arg+ is neither +nil+ nor a Hash, a frozen Hash (the SDK writes
1046
+ # walked wtxids into it and cannot mutate a frozen Hash), or a Hash with a truthy default
1047
+ # value or a default_proc (missing keys would return truthy and silently short-circuit
1048
+ # verification for un-seeded wtxids — funds risk)
1049
+ def normalise_verified(arg)
1050
+ return {} if arg.nil?
1051
+
1052
+ unless arg.is_a?(Hash)
1053
+ raise ArgumentError,
1054
+ "verified: must be nil or Hash of wtxid => truthy entries (got #{arg.class})"
1055
+ end
1056
+
1057
+ if arg.frozen?
1058
+ raise ArgumentError,
1059
+ 'verified: Hash must be mutable — the SDK writes walked wtxids into it'
1060
+ end
1061
+
1062
+ # A Hash with a truthy default or a default_proc could return a truthy value for missing
1063
+ # keys, silently short-circuiting verification for un-seeded wtxids. The walk sites use
1064
+ # `fetch(k, false)` as defence in depth, but reject at entry so the caller sees a clear
1065
+ # error rather than an opaquely-behaving verify.
1066
+ if arg.default_proc || arg.default
1067
+ raise ArgumentError,
1068
+ 'verified: Hash must not have a truthy default or a default_proc — ' \
1069
+ 'missing keys must return falsy so verification cannot be silently skipped'
1070
+ end
1071
+
1072
+ arg
1073
+ end
1074
+
835
1075
  def verify_input_requirements(tx, input, index)
836
1076
  dtxid_hex = tx.txid_hex
837
1077
  if input.unlocking_script.nil?
@@ -866,37 +1106,59 @@ module BSV
866
1106
  "outputs (#{output_total}) exceed inputs (#{input_total}) for transaction #{tx.txid_hex}")
867
1107
  end
868
1108
 
869
- ZERO_HASH = "\x00".b * 32
1109
+ ZERO_HASH = ("\x00".b * 32).freeze # rubocop:disable Style/RedundantFreeze
870
1110
  private_constant :ZERO_HASH
871
1111
 
1112
+ # @note Memoised; see {file:docs/reference/sighash-cache.md} for the invalidation contract.
872
1113
  def hash_prevouts(anyone_can_pay)
873
1114
  return ZERO_HASH if anyone_can_pay
874
1115
 
875
- buf = @inputs.map(&:outpoint_binary).join
876
- BSV::Primitives::Digest.sha256d(buf)
1116
+ @hash_prevouts ||=
1117
+ BSV::Primitives::Digest.sha256d(@inputs.map(&:outpoint_binary).join).freeze
877
1118
  end
878
1119
 
1120
+ # @note Memoised; see {file:docs/reference/sighash-cache.md} for the invalidation contract.
879
1121
  def hash_sequence(anyone_can_pay, base_type)
880
1122
  return ZERO_HASH if anyone_can_pay || base_type == Sighash::SINGLE || base_type == Sighash::NONE
881
1123
 
882
- buf = @inputs.map { |i| [i.sequence].pack('V') }.join
883
- BSV::Primitives::Digest.sha256d(buf)
1124
+ @hash_sequence ||=
1125
+ BSV::Primitives::Digest.sha256d(@inputs.map { |i| [i.sequence].pack('V') }.join).freeze
884
1126
  end
885
1127
 
1128
+ # @note Memoised; see {file:docs/reference/sighash-cache.md} for the invalidation contract.
886
1129
  def hash_outputs(base_type, input_index)
887
1130
  case base_type
888
1131
  when Sighash::NONE
889
1132
  ZERO_HASH
890
1133
  when Sighash::SINGLE
1134
+ # NEVER cache the ZERO_HASH branch — after add_output, an idx that was
1135
+ # out-of-range may move in-range; a cached ZERO_HASH would be stale.
891
1136
  return ZERO_HASH if input_index >= @outputs.length
892
1137
 
893
- BSV::Primitives::Digest.sha256d(@outputs[input_index].to_binary)
1138
+ hash_outputs_single(input_index)
894
1139
  else # ALL (and any non-standard base type per BIP-143)
895
- buf = @outputs.map(&:to_binary).join
896
- BSV::Primitives::Digest.sha256d(buf)
1140
+ hash_outputs_all
897
1141
  end
898
1142
  end
899
1143
 
1144
+ # Memoises the +hash_outputs+ ALL branch (sha256d of all outputs joined).
1145
+ #
1146
+ # @!visibility private
1147
+ def hash_outputs_all
1148
+ @hash_outputs_all ||= BSV::Primitives::Digest.sha256d(@outputs.map(&:to_binary).join).freeze
1149
+ end
1150
+
1151
+ # Memoises +hash_outputs+ for the SIGHASH_SINGLE branch, keyed by input
1152
+ # index. The Hash itself is the ivar; per-key lookup uses +Hash#[]= ||=+
1153
+ # which does not trigger +Naming/MemoizedInstanceVariableName+.
1154
+ #
1155
+ # @!visibility private
1156
+ def hash_outputs_single(input_index)
1157
+ @hash_outputs_single ||= {}
1158
+ @hash_outputs_single[input_index] ||=
1159
+ BSV::Primitives::Digest.sha256d(@outputs[input_index].to_binary).freeze
1160
+ end
1161
+
900
1162
  # Collect this transaction and all its ancestors in dependency order
901
1163
  # (ancestors first, self last). Stops recursion at transactions with
902
1164
  # a merkle_path (proven leaves). Deduplicates by wtxid (wire-order bytes).
@@ -1066,6 +1328,75 @@ module BSV
1066
1328
  scale_factor = LOG10_RECIPROCAL_D_VALUES_1TO9[Random.rand(9)] # Array indexing starts at 0
1067
1329
  (min + (scale_factor * (max - min))).floor
1068
1330
  end
1331
+
1332
+ # Clears the +hash_sequence+ component-hash memo. Called by
1333
+ # +TransactionInput#sequence=+ via the owning-Tx backref.
1334
+ #
1335
+ # @!visibility private
1336
+ def invalidate_sequence_components_cache
1337
+ @hash_sequence = nil
1338
+ end
1339
+
1340
+ # Clears the +hash_outputs+ component-hash memos (both the ALL branch and
1341
+ # the SIGHASH_SINGLE per-index cache). Called by
1342
+ # +TransactionOutput#satoshis=+ and +TransactionOutput#locking_script=+
1343
+ # via the owning-Tx backref.
1344
+ #
1345
+ # @!visibility private
1346
+ def invalidate_outputs_components_cache
1347
+ @hash_outputs_all = nil
1348
+ @hash_outputs_single&.clear
1349
+ end
1350
+
1351
+ # Clears the L2 wire-serialisation caches (+to_binary+ and +wtxid+).
1352
+ # Called by per-struct setter bubbles and +invalidate_caches+.
1353
+ #
1354
+ # @!visibility private
1355
+ def invalidate_wire_cache
1356
+ @to_binary = nil
1357
+ @wtxid = nil
1358
+ end
1359
+
1360
+ # Clears every cache slice on this Tx without the +rebind_owning_tx!+
1361
+ # walk. Called from +add_input+ / +add_output+ where the new struct's
1362
+ # backref was just set inline and existing structs' backrefs were
1363
+ # established when they were added — making the rebind walk redundant
1364
+ # and reducing N-input construction from O(N²) to O(N).
1365
+ #
1366
+ # The public +invalidate_caches+ keeps the rebind walk because it is
1367
+ # the documented escape hatch for direct array mutation, where the
1368
+ # walk is required.
1369
+ #
1370
+ # @!visibility private
1371
+ def clear_caches
1372
+ @hash_prevouts = nil
1373
+ invalidate_sequence_components_cache
1374
+ invalidate_outputs_components_cache
1375
+ invalidate_wire_cache
1376
+ end
1377
+
1378
+ # Restore +@owning_tx+ on every current input and output. Called by
1379
+ # +invalidate_caches+ so the escape hatch covers direct array mutation
1380
+ # (e.g. +tx.inputs << input+ bypasses +add_input+ and leaves the new
1381
+ # struct's backref nil — subsequent setter mutations would not bubble
1382
+ # invalidation).
1383
+ #
1384
+ # Raises if any struct is currently attached to a *different* Tx —
1385
+ # matches the +add_input+ / +add_output+ cross-Tx rebind contract.
1386
+ #
1387
+ # @!visibility private
1388
+ def rebind_owning_tx!
1389
+ (@inputs + @outputs).each do |struct|
1390
+ existing_owner = struct.instance_variable_get(:@owning_tx)
1391
+ if existing_owner && !existing_owner.equal?(self)
1392
+ raise ArgumentError,
1393
+ "#{struct.class.name.split('::').last} is already attached to a different Tx; " \
1394
+ 'invalidate_caches cannot rebind across transactions'
1395
+ end
1396
+
1397
+ struct.instance_variable_set(:@owning_tx, self)
1398
+ end
1399
+ end
1069
1400
  end
1070
1401
  end
1071
1402
  end
@@ -14,7 +14,7 @@ module BSV
14
14
  class UnlockingScriptTemplate
15
15
  # Generate the unlocking script for a transaction input.
16
16
  #
17
- # @param _tx [Tx] the transaction being signed
17
+ # @param _tx [Transaction::Tx] the transaction being signed
18
18
  # @param _input_index [Integer] the input index to sign
19
19
  # @return [Script::Script] the generated unlocking script
20
20
  # @raise [NotImplementedError] if not overridden by a subclass
@@ -24,7 +24,7 @@ module BSV
24
24
 
25
25
  # Estimate the unlocking script length in bytes (for fee estimation).
26
26
  #
27
- # @param _tx [Tx] the transaction
27
+ # @param _tx [Transaction::Tx] the transaction
28
28
  # @param _input_index [Integer] the input index
29
29
  # @return [Integer] estimated script length in bytes
30
30
  # @raise [NotImplementedError] if not overridden by a subclass
@@ -19,6 +19,7 @@ module BSV
19
19
  autoload :ChainTracker, 'bsv/transaction/chain_tracker'
20
20
  autoload :ChainTrackers, 'bsv/transaction/chain_trackers'
21
21
  autoload :Beef, 'bsv/transaction/beef'
22
+ autoload :BeefParty, 'bsv/transaction/beef_party'
22
23
  autoload :UnlockingScriptTemplate, 'bsv/transaction/unlocking_script_template'
23
24
  autoload :P2PKH, 'bsv/transaction/p2pkh'
24
25
  autoload :Tx, 'bsv/transaction/tx'
data/lib/bsv/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module BSV
4
- VERSION = '0.24.0'
4
+ VERSION = '0.26.0'
5
5
  end
data/lib/bsv-sdk.rb CHANGED
@@ -26,6 +26,8 @@ module BSV
26
26
  autoload :Identity, 'bsv/identity'
27
27
  autoload :Registry, 'bsv/registry'
28
28
  autoload :MCP, 'bsv/mcp'
29
+ autoload :Storage, 'bsv/storage'
30
+ autoload :KVStore, 'bsv/kv_store'
29
31
 
30
32
  # Wallet is loaded eagerly to avoid load-path shadowing when bsv-wallet is
31
33
  # also in the bundle (bsv-wallet/lib/bsv/wallet.rb would otherwise win).