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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 9a0dbc7f524004e1db715058dd17d336e62f99687239ec4c7807512d730e6ef5
4
- data.tar.gz: 45b1fc354cbfe718a38e01c0d4896d537600d188bba909f351da1bc17bac66c6
3
+ metadata.gz: 6152fa9bc473f3ff11eca96fa5a9f1302d88cdbe3d72782c13da1760a5507514
4
+ data.tar.gz: e3f45fa1d03d36f9943d172fb8db396869f2ad2ff333b8d071af161062e56fc3
5
5
  SHA512:
6
- metadata.gz: 6e212bf3c077ab32a6d5187c0e3cbd93fd61f26844d40dce31b28c0574a1111db3a081dd0d843be90dc682779ede74ece03f0f7eccc0eec930a372306cdc3ca2
7
- data.tar.gz: ec1e1b2e0c7b5ae142b52cb1863000fd262d0c1dc30346c2a7d412af2e1f66cbc3057c864bc9d6229737be59c30eae2eaf6d7fee7c296a9393c6e0f8b636b558
6
+ metadata.gz: 802d2a0fe86432760fa64b12ac6787b4e8a9c3ce02ea5f66e19eee9e3b61bc050f0712fa8c98f5cfe0ca92550a7b7df7927624f5b120fb5274aac082acb66a0b
7
+ data.tar.gz: 7ecae6d4a729130f3d14e637b5822cec7bcab141a7e32abcd4d0275b4c81a0c545d64a2272a8684ef8702b85f4a676f6e3545437ccca5f38a1b57108099baf57
data/CHANGELOG.md CHANGED
@@ -5,6 +5,40 @@ All notable changes to the `bsv-sdk` gem are documented here.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/)
6
6
  and this gem adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## 0.26.0 — 2026-07-08
9
+
10
+ ### Breaking Changes
11
+ - `Transaction::Tx#verify` — the `verified:` kwarg now accepts `nil | Hash{String => Boolean}`
12
+ (previously `nil | Set<String>`). The Hash is mutated in place, so a caller can both pre-seed
13
+ the already-verified wtxid set and read back the wtxids walked during verification. A frozen
14
+ Hash, or a Hash with a truthy `default`/`default_proc`, is rejected at entry (funds risk) (#904).
15
+
16
+ ### Added
17
+ - `Transaction::Tx#verify(verified:)` bidirectional verification cache: pre-seeding short-circuits
18
+ already-verified ancestor subtrees; post-read exposes the walked wtxid set for a persistent
19
+ verification cache (#904, #909, #910).
20
+ - Transaction memoisation: per-struct binary, component-hash, and wire-order memos across `Tx`,
21
+ inputs, and outputs, invalidated on mutation — repeated `to_binary`/`wtxid`/hash computation is
22
+ now cached (#881).
23
+ - Conformance canonical corpus: a pinned-revision loader plus fetch/sync tooling and cache layout,
24
+ with the BEEF, BUMP, sighash, SHA-256, ECIES, BRC-42 key-derivation, and script-test specs
25
+ migrated onto it (#840, #841, #842–#847).
26
+
27
+ ### Fixed
28
+ - Interpreter consensus hardening: correct Chronicle gating of `OP_VER`/`OP_VERIF` (with
29
+ tx_version), `OP_2MUL`/`OP_2DIV`, and multi-`OP_ELSE` (relaxed mode); enforce the `CLEANSTACK`
30
+ and `SIGPUSHONLY` verification flags; whitelist verification flags (#837, #850, #851, #852, #853,
31
+ #854).
32
+ - `Transaction::Tx#verify` — translate the fee-gate `ArgumentError` (missing source data) into a
33
+ `VerificationError` (`:missing_source`) so every failure mode raises a consistent error type
34
+ (#904).
35
+ - `Transaction::Tx#verify` — fix a funds-risk bug where a Hash with a truthy default could silently
36
+ short-circuit verification for unseen wtxids (#904).
37
+
38
+ ### Changed
39
+ - Performance: reuse OpenSSL digest contexts in primitives hashing (thread-safety documented); split
40
+ `clear_caches` from `invalidate_caches` in the transaction cache lifecycle (#881, #882).
41
+
8
42
  ## 0.25.0 — 2026-06-18
9
43
 
10
44
  ### Added
data/README.md CHANGED
@@ -24,7 +24,7 @@ This Ruby SDK is a port of the official BSV Blockchain SDKs, which serve as its
24
24
 
25
25
  The reference SDKs:
26
26
 
27
- - [TypeScript SDK](https://github.com/bsv-blockchain/ts-sdk)
27
+ - [TypeScript SDK](https://github.com/bsv-blockchain/ts-stack)
28
28
  - [Go SDK](https://github.com/bsv-blockchain/go-sdk)
29
29
  - [Python SDK](https://github.com/bsv-blockchain/py-sdk)
30
30
 
@@ -89,8 +89,10 @@ locking_script = BSV::Script::Script.p2pkh_lock(pubkey_hash)
89
89
  tx = BSV::Transaction::Tx.new
90
90
 
91
91
  # Add an input referencing a previous transaction output
92
+ # wtxid_from_hex converts a display-order hex txid to wire-order binary
93
+ utxo_txid = '5884e5db9de218238671572340b207ee85b628074e7e467096c267266baf77a4'
92
94
  input = BSV::Transaction::TransactionInput.new(
93
- prev_tx_id: source_txid_bytes, # 32-byte binary txid of the UTXO
95
+ prev_wtxid: BSV::Transaction::TransactionInput.wtxid_from_hex(utxo_txid),
94
96
  prev_tx_out_index: 0
95
97
  )
96
98
  input.source_satoshis = 100_000
@@ -9,33 +9,111 @@ module BSV
9
9
  # Thin wrappers around +OpenSSL::Digest+ and +OpenSSL::HMAC+ providing
10
10
  # the hash algorithms used throughout the BSV protocol: SHA-1, SHA-256,
11
11
  # double-SHA-256, SHA-512, RIPEMD-160, Hash160, HMAC, and PBKDF2.
12
+ #
13
+ # @note **Per-fibre cached OpenSSL contexts (via +Thread.current+)**
14
+ #
15
+ # +sha256+, +sha256d+, +sha1+, and +sha512+ each cache one
16
+ # +OpenSSL::Digest+ instance per fibre in +Thread.current+ under a
17
+ # namespaced key (e.g. +:bsv_sdk_sha256_digest+). Because MRI has no
18
+ # fibres in this SDK's +lib/+ today, "per fibre" and "per thread" are
19
+ # the same in practice — but the primitive is fibre-local, so the more
20
+ # precise term is used throughout. On every call the context is reset
21
+ # with +OpenSSL::Digest#reset+, which calls +EVP_DigestInit_ex+ against
22
+ # the +EVP_MD*+ already stored on the context. This skips the
23
+ # +EVP_get_digestbyname+ namemap lookup that
24
+ # +OpenSSL::Digest::SHA256.new+ (or +.digest+) performs on every fresh
25
+ # allocation. The namemap cost is paid exactly once per fibre per
26
+ # algorithm.
27
+ #
28
+ # **Fibre-local semantics.** +Thread.current[:key]+ is *fibre-local*
29
+ # in MRI (documented since Ruby 2.0, verified on 3.3 / 3.4 / 4.0) — each
30
+ # Fibre gets its own cached context. This is stronger than the SDK
31
+ # currently requires (no Fibres in +lib/+) and is correctly safe if
32
+ # Fibres are introduced later. The HLR body's "GVL makes it safe"
33
+ # rationale is subtly wrong; the correct reason is fibre-local scope.
34
+ #
35
+ # **Do not touch the cached instances externally.** The keys
36
+ # +:bsv_sdk_sha256_digest+, +:bsv_sdk_sha1_digest+, and
37
+ # +:bsv_sdk_sha512_digest+ are technically reachable via +Thread.current+
38
+ # but must never have +update+ called on them outside this module.
39
+ # Calling +update+ out-of-band would corrupt the next call's input.
40
+ #
41
+ # **User-visible invariants are unchanged:** return values are always
42
+ # +ASCII-8BIT+ binary strings; identical inputs always produce identical
43
+ # outputs; successive calls return distinct +String+ objects (output
44
+ # buffers are never cached).
45
+ #
46
+ # **Prefix convention.** +:bsv_sdk_<algo>_digest+ is the SDK-wide
47
+ # naming convention for any future +Thread.current+ usage in this
48
+ # codebase — the prefix avoids collision with other libraries that may
49
+ # also use +Thread.current+.
50
+ #
51
+ # **FIPS.** SHA-1, SHA-256, and SHA-512 remain FIPS-approved algorithms
52
+ # under this pattern; the algorithm fetch happens once at +.new+, not per
53
+ # call.
54
+ #
55
+ # **CI matrix.** Verified on MRI 3.3, 3.4, and 4.0. JRuby and
56
+ # TruffleRuby are not in the CI matrix — per-thread caching is correct on
57
+ # true-parallel Rubies (each thread has independent +Thread.current+) but
58
+ # unverified here.
59
+ #
60
+ # **Why other SDKs do not do this.** The TypeScript, Go, and Python SDKs
61
+ # allocate fresh digest contexts per call because their OpenSSL bindings
62
+ # cache the algorithm handle internally. MRI's +openssl+ gem bridges
63
+ # through +EVP_get_digestbyname+ on every allocation — we are patching
64
+ # the Ruby binding overhead, not diverging from BSV protocol semantics.
65
+ #
66
+ # **Out of scope with rationale:**
67
+ # - +ripemd160+ — pure-Ruby implementation; no OpenSSL context.
68
+ # - +hmac_sha256+ / +hmac_sha512+ — HMAC key changes per call. A future
69
+ # cache *must* key on wrapper-object identity (+object_id+), never on
70
+ # key bytes — a key-bytes cache lookup would create a secret-dependent
71
+ # side-channel via cache-hit timing.
72
+ # - +pbkdf2_hmac_sha512+ — one-shot at BIP-39 seed derivation; not a
73
+ # hot path.
12
74
  module Digest
13
75
  module_function
14
76
 
15
77
  # Compute SHA-1 digest.
16
78
  #
17
79
  # @param data [String] binary data to hash
18
- # @return [String] 20-byte digest
80
+ # @return [String] 20-byte digest (ASCII-8BIT)
19
81
  def sha1(data)
20
- OpenSSL::Digest::SHA1.digest(data)
82
+ d = Thread.current[:bsv_sdk_sha1_digest] ||= OpenSSL::Digest.new('SHA1')
83
+ d.reset
84
+ d.update(data)
85
+ d.digest
21
86
  end
22
87
 
23
88
  # Compute SHA-256 digest.
24
89
  #
25
90
  # @param data [String] binary data to hash
26
- # @return [String] 32-byte digest
91
+ # @return [String] 32-byte digest (ASCII-8BIT)
27
92
  def sha256(data)
28
- OpenSSL::Digest::SHA256.digest(data)
93
+ d = Thread.current[:bsv_sdk_sha256_digest] ||= OpenSSL::Digest.new('SHA256')
94
+ d.reset
95
+ d.update(data)
96
+ d.digest
29
97
  end
30
98
 
31
99
  # Compute double-SHA-256 (SHA-256d) digest.
32
100
  #
33
101
  # Used extensively in Bitcoin for transaction and block hashing.
102
+ # Inlines the two-round chain against the same cached SHA-256 context,
103
+ # saving one context allocation, one namemap lookup, one intermediate
104
+ # +String+, and one +module_function+ dispatch per call compared with
105
+ # the naive +sha256(sha256(data))+ decomposition.
34
106
  #
35
107
  # @param data [String] binary data to hash
36
- # @return [String] 32-byte digest
108
+ # @return [String] 32-byte digest (ASCII-8BIT)
37
109
  def sha256d(data)
38
- sha256(sha256(data))
110
+ d = Thread.current[:bsv_sdk_sha256_digest] ||= OpenSSL::Digest.new('SHA256')
111
+ d.reset
112
+ d.update(data)
113
+ first = d.digest
114
+ d.reset # defensive: some Ruby versions internally reset after #digest
115
+ d.update(first)
116
+ d.digest
39
117
  end
40
118
 
41
119
  alias hash256 sha256d
@@ -44,15 +122,23 @@ module BSV
44
122
  # Compute SHA-512 digest.
45
123
  #
46
124
  # @param data [String] binary data to hash
47
- # @return [String] 64-byte digest
125
+ # @return [String] 64-byte digest (ASCII-8BIT)
48
126
  def sha512(data)
49
- OpenSSL::Digest::SHA512.digest(data)
127
+ d = Thread.current[:bsv_sdk_sha512_digest] ||= OpenSSL::Digest.new('SHA512')
128
+ d.reset
129
+ d.update(data)
130
+ d.digest
50
131
  end
51
132
 
52
133
  # Compute RIPEMD-160 digest.
53
134
  #
135
+ # @note Uses the pure-Ruby {BSV::Primitives::Ripemd160} implementation —
136
+ # no OpenSSL context is held or cached here. RIPEMD-160 is not in
137
+ # scope for the per-thread caching optimisation (different optimisation
138
+ # track; not currently a hot path).
139
+ #
54
140
  # @param data [String] binary data to hash
55
- # @return [String] 20-byte digest
141
+ # @return [String] 20-byte digest (ASCII-8BIT)
56
142
  def ripemd160(data)
57
143
  Ripemd160.digest(data)
58
144
  end
@@ -62,25 +148,32 @@ module BSV
62
148
  # Standard Bitcoin hash used for addresses and P2PKH script matching.
63
149
  #
64
150
  # @param data [String] binary data to hash
65
- # @return [String] 20-byte digest
151
+ # @return [String] 20-byte digest (ASCII-8BIT)
66
152
  def hash160(data)
67
153
  ripemd160(sha256(data))
68
154
  end
69
155
 
70
156
  # Compute HMAC-SHA-256.
71
157
  #
158
+ # @note HMAC context reuse is deferred — the key changes per call, so a
159
+ # future cache must key on wrapper-object identity (+object_id+), never
160
+ # on key bytes, to avoid a secret-dependent side-channel via cache-hit
161
+ # timing.
162
+ #
72
163
  # @param key [String] HMAC key
73
164
  # @param data [String] data to authenticate
74
- # @return [String] 32-byte MAC
165
+ # @return [String] 32-byte MAC (ASCII-8BIT)
75
166
  def hmac_sha256(key, data)
76
167
  OpenSSL::HMAC.digest('SHA256', key, data)
77
168
  end
78
169
 
79
170
  # Compute HMAC-SHA-512.
80
171
  #
172
+ # @note HMAC context reuse is deferred — see +hmac_sha256+ note.
173
+ #
81
174
  # @param key [String] HMAC key
82
175
  # @param data [String] data to authenticate
83
- # @return [String] 64-byte MAC
176
+ # @return [String] 64-byte MAC (ASCII-8BIT)
84
177
  def hmac_sha512(key, data)
85
178
  OpenSSL::HMAC.digest('SHA512', key, data)
86
179
  end
@@ -88,12 +181,13 @@ module BSV
88
181
  # Derive a key using PBKDF2-HMAC-SHA-512.
89
182
  #
90
183
  # Used by BIP-39 to convert mnemonic phrases into seeds.
184
+ # One-shot at key creation — not a hot path; no context reuse applied.
91
185
  #
92
186
  # @param password [String] the password (mnemonic phrase)
93
187
  # @param salt [String] the salt (+"mnemonic"+ + passphrase)
94
188
  # @param iterations [Integer] iteration count (default: 2048 per BIP-39)
95
189
  # @param key_length [Integer] desired output length in bytes (default: 64)
96
- # @return [String] derived key bytes
190
+ # @return [String] derived key bytes (ASCII-8BIT)
97
191
  def pbkdf2_hmac_sha512(password, salt, iterations: 2048, key_length: 64)
98
192
  OpenSSL::PKCS5.pbkdf2_hmac(password, salt, iterations, key_length, 'sha512')
99
193
  end
@@ -52,6 +52,8 @@ module BSV
52
52
  STACK_MEMORY_EXCEEDED = :stack_memory_exceeded
53
53
  UNIMPLEMENTED_OPCODE = :unimplemented_opcode
54
54
  MISSING_TX_CONTEXT = :missing_tx_context
55
+ SIG_PUSHONLY = :sig_pushonly
56
+ CLEAN_STACK = :clean_stack
55
57
  end
56
58
  end
57
59
  end
@@ -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')