bsv-sdk 0.25.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.
@@ -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
  #
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
+ #
62
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
  #
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
+ #
71
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.
@@ -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
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
@@ -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
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.25.0'
4
+ VERSION = '0.26.0'
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: bsv-sdk
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.25.0
4
+ version: 0.26.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Simon Bettison
@@ -175,7 +175,6 @@ files:
175
175
  - lib/bsv/script/opcodes.rb
176
176
  - lib/bsv/script/push_drop_template.rb
177
177
  - lib/bsv/script/script.rb
178
- - lib/bsv/secp256k1_native.bundle
179
178
  - lib/bsv/storage.rb
180
179
  - lib/bsv/storage/downloader.rb
181
180
  - lib/bsv/storage/errors.rb
Binary file