bsv-sdk 0.9.0 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,72 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BSV
4
+ module MCP
5
+ module Tools
6
+ # Fetches unspent transaction outputs (UTXOs) for a BSV address using
7
+ # the WhatsOnChain API.
8
+ #
9
+ # The http_client dependency is injectable for testing — pass a
10
+ # compatible mock object to +new_woc+.
11
+ class FetchUtxos < ::MCP::Tool
12
+ tool_name 'fetch_utxos'
13
+
14
+ description <<~DESC.strip
15
+ Fetch unspent transaction outputs (UTXOs) for a BSV address.
16
+
17
+ Queries the WhatsOnChain API and returns the list of UTXOs available
18
+ to spend from the given address. Requires network access.
19
+
20
+ Parameters:
21
+ - address: a BSV P2PKH address (mainnet starts with '1'; testnet
22
+ starts with 'm' or 'n')
23
+ - network: 'mainnet' or 'testnet' — overrides the server default
24
+
25
+ Returns an array of UTXO objects, each with:
26
+ - tx_hash: transaction ID of the UTXO
27
+ - tx_pos: output index within that transaction
28
+ - satoshis: value in satoshis
29
+ - height: block height (0 = unconfirmed)
30
+
31
+ Note: addresses are network-specific. A mainnet address on testnet
32
+ (or vice versa) will return no results or an error.
33
+ DESC
34
+
35
+ input_schema(
36
+ type: 'object',
37
+ properties: {
38
+ address: {
39
+ type: 'string',
40
+ description: 'BSV P2PKH address to look up.'
41
+ },
42
+ network: {
43
+ type: 'string',
44
+ enum: %w[mainnet testnet],
45
+ description: 'Network to query. Overrides the server default.'
46
+ }
47
+ },
48
+ required: ['address']
49
+ )
50
+
51
+ def self.call(address:, network: nil, server_context: nil)
52
+ net_sym = Helpers.resolve_network_sym(network, server_context)
53
+ woc = BSV::Network::WhatsOnChain.new(network: net_sym)
54
+ utxos = woc.fetch_utxos(address)
55
+
56
+ result = {
57
+ address: address,
58
+ network: net_sym.to_s,
59
+ utxos: utxos.map { |u| Helpers.utxo_to_h(u) }
60
+ }
61
+
62
+ ::MCP::Tool::Response.new(
63
+ [::MCP::Content::Text.new(result.to_json)],
64
+ structured_content: result
65
+ )
66
+ rescue BSV::Network::ChainProviderError => e
67
+ Helpers.error_response("#{e.message} (HTTP #{e.status_code})")
68
+ end
69
+ end
70
+ end
71
+ end
72
+ end
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BSV
4
+ module MCP
5
+ module Tools
6
+ # Generates a new random BSV private key and derives the corresponding
7
+ # public key and address.
8
+ #
9
+ # Each invocation produces a cryptographically fresh key — the result is
10
+ # never reused or persisted by the server. Store the WIF securely; it
11
+ # cannot be recovered from the address alone.
12
+ class GenerateKey < ::MCP::Tool
13
+ tool_name 'generate_key'
14
+
15
+ description <<~DESC.strip
16
+ Generate a new random BSV private key.
17
+
18
+ Returns the private key in WIF (Wallet Import Format), the compressed
19
+ public key as hex, and the corresponding P2PKH address for the
20
+ configured network.
21
+
22
+ IMPORTANT: A new key is generated on every call. The WIF is never
23
+ stored by the server — save it immediately in secure storage.
24
+ Losing the WIF means losing access to any funds sent to the address.
25
+
26
+ The address format is network-specific: mainnet addresses start with
27
+ '1', testnet addresses start with 'm' or 'n'.
28
+ DESC
29
+
30
+ input_schema(
31
+ type: 'object',
32
+ properties: {
33
+ network: {
34
+ type: 'string',
35
+ enum: %w[mainnet testnet],
36
+ description: 'Network to generate the key for. Overrides the server default.'
37
+ }
38
+ }
39
+ )
40
+
41
+ def self.call(network: nil, server_context: nil)
42
+ net_sym = Helpers.resolve_network_sym(network, server_context)
43
+
44
+ key = BSV::Primitives::PrivateKey.generate
45
+ pub = key.public_key
46
+
47
+ result = {
48
+ wif: key.to_wif(network: net_sym),
49
+ public_key_hex: pub.to_hex,
50
+ address: pub.address(network: net_sym),
51
+ network: net_sym.to_s
52
+ }
53
+
54
+ ::MCP::Tool::Response.new(
55
+ [::MCP::Content::Text.new(result.to_json)],
56
+ structured_content: result
57
+ )
58
+ end
59
+ end
60
+ end
61
+ end
62
+ end
@@ -0,0 +1,87 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BSV
4
+ module MCP
5
+ module Tools
6
+ # Shared helpers used across multiple MCP tools.
7
+ module Helpers
8
+ # Resolve a network string to the SDK symbol (:mainnet or :testnet).
9
+ #
10
+ # Checks the per-call +network_arg+ first, then falls back to the
11
+ # server-level config in +server_context+, then defaults to :mainnet.
12
+ #
13
+ # @param network_arg [String, nil] per-call override ('mainnet'/'testnet')
14
+ # @param server_context [Hash, nil] server context with :bsv_network key
15
+ # @return [Symbol] :mainnet or :testnet
16
+ def self.resolve_network_sym(network_arg, server_context)
17
+ net = network_arg
18
+ net = server_context[:bsv_network] if net.nil? && server_context.is_a?(Hash) && server_context[:bsv_network]
19
+ net == 'testnet' ? :testnet : :mainnet
20
+ end
21
+
22
+ # Convert a Transaction to a hash suitable for JSON responses.
23
+ #
24
+ # @param tx [BSV::Transaction::Transaction]
25
+ # @return [Hash]
26
+ def self.transaction_to_h(tx)
27
+ {
28
+ txid: tx.txid_hex,
29
+ version: tx.version,
30
+ lock_time: tx.lock_time,
31
+ inputs: tx.inputs.each_with_index.map { |inp, i| input_to_h(inp, i) },
32
+ outputs: tx.outputs.each_with_index.map { |out, i| output_to_h(out, i) }
33
+ }
34
+ end
35
+
36
+ # Convert a TransactionInput to a hash.
37
+ # @api private
38
+ def self.input_to_h(input, _index)
39
+ unlock_script = input.unlocking_script
40
+ {
41
+ prev_txid: input.prev_tx_id.reverse.unpack1('H*'),
42
+ vout: input.prev_tx_out_index,
43
+ script_hex: unlock_script ? unlock_script.to_hex : '',
44
+ script_asm: unlock_script ? unlock_script.to_asm : '',
45
+ sequence: input.sequence
46
+ }
47
+ end
48
+
49
+ # Convert a TransactionOutput to a hash.
50
+ # @api private
51
+ def self.output_to_h(output, index)
52
+ script = output.locking_script
53
+ {
54
+ index: index,
55
+ satoshis: output.satoshis,
56
+ script_hex: script ? script.to_hex : '',
57
+ script_asm: script ? script.to_asm : '',
58
+ script_type: script ? script.type : 'empty'
59
+ }
60
+ end
61
+
62
+ # Convert a UTXO to a hash.
63
+ # @api private
64
+ def self.utxo_to_h(utxo)
65
+ {
66
+ tx_hash: utxo.tx_hash,
67
+ tx_pos: utxo.tx_pos,
68
+ satoshis: utxo.satoshis,
69
+ height: utxo.height
70
+ }
71
+ end
72
+
73
+ # Build a standard MCP error response.
74
+ #
75
+ # @param message [String] error description
76
+ # @return [MCP::Tool::Response]
77
+ def self.error_response(message)
78
+ payload = { error: message }
79
+ ::MCP::Tool::Response.new(
80
+ [::MCP::Content::Text.new(payload.to_json)],
81
+ error: true
82
+ )
83
+ end
84
+ end
85
+ end
86
+ end
87
+ end
data/lib/bsv/mcp.rb ADDED
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BSV
4
+ module MCP
5
+ autoload :Config, 'bsv/mcp/config'
6
+ autoload :Server, 'bsv/mcp/server'
7
+
8
+ module Tools
9
+ autoload :Helpers, 'bsv/mcp/tools/helpers'
10
+ autoload :GenerateKey, 'bsv/mcp/tools/generate_key'
11
+ autoload :DecodeTx, 'bsv/mcp/tools/decode_tx'
12
+ autoload :FetchUtxos, 'bsv/mcp/tools/fetch_utxos'
13
+ autoload :FetchTx, 'bsv/mcp/tools/fetch_tx'
14
+ autoload :CheckBalance, 'bsv/mcp/tools/check_balance'
15
+ autoload :BroadcastP2pkh, 'bsv/mcp/tools/broadcast_p2pkh'
16
+ end
17
+ end
18
+ end
@@ -15,6 +15,16 @@ module BSV
15
15
  # The HTTP client is injectable for testability. It must respond to
16
16
  # #request(uri, request) and return an object with #code and #body.
17
17
  class ARC
18
+ # Returns an ARC instance pointed at the GorillaPool public ARC endpoint.
19
+ #
20
+ # @param testnet [Boolean] when true, uses the GorillaPool testnet endpoint
21
+ # @param opts [Hash] forwarded to {#initialize} (e.g. +api_key:+, +callback_url:+)
22
+ # @return [ARC]
23
+ def self.default(testnet: false, **opts)
24
+ url = testnet ? 'https://testnet.arc.gorillapool.io' : 'https://arc.gorillapool.io'
25
+ new(url, **opts)
26
+ end
27
+
18
28
  # ARC response statuses that indicate the transaction was NOT accepted.
19
29
  # Matches the TypeScript SDK's ARC broadcaster failure set (issue #305,
20
30
  # finding F5.13). Prior to this fix, Ruby only recognised REJECTED and
@@ -63,24 +73,62 @@ module BSV
63
73
  # 'SEEN_ON_NETWORK', or 'MINED'. When set, ARC holds the
64
74
  # connection open until the transaction reaches the requested
65
75
  # state (or times out). Defaults to nil (no wait).
76
+ # @param skip_fee_validation [Boolean, nil] when truthy, sends the
77
+ # +X-SkipFeeValidation: true+ header, asking ARC to bypass its
78
+ # minimum-fee check. Useful for zero-fee data transactions or
79
+ # during local testing. Defaults to nil (fee validation applies).
80
+ # @param skip_script_validation [Boolean, nil] when truthy, sends the
81
+ # +X-SkipScriptValidation: true+ header, asking ARC to bypass
82
+ # script correctness checks. Defaults to nil (script validation
83
+ # applies).
66
84
  # @return [BroadcastResponse]
67
85
  # @raise [BroadcastError] when ARC returns a non-2xx HTTP status or a
68
86
  # rejected/orphan +txStatus+
69
- def broadcast(tx, wait_for: nil)
87
+ def broadcast(tx, wait_for: nil, skip_fee_validation: nil, skip_script_validation: nil)
70
88
  uri = URI("#{@url}/v1/tx")
71
- request = Net::HTTP::Post.new(uri)
72
- request['Content-Type'] = 'application/json'
73
- request['XDeployment-ID'] = @deployment_id
74
- request['X-WaitFor'] = wait_for if wait_for
75
- request['X-CallbackUrl'] = @callback_url if @callback_url
76
- request['X-CallbackToken'] = @callback_token if @callback_token
77
- apply_auth_header(request)
89
+ request = build_post_request(uri, wait_for: wait_for,
90
+ skip_fee_validation: skip_fee_validation,
91
+ skip_script_validation: skip_script_validation)
78
92
  request.body = JSON.generate(rawTx: raw_tx_hex(tx))
79
93
 
80
94
  response = execute(uri, request)
81
95
  handle_broadcast_response(response)
82
96
  end
83
97
 
98
+ # Submit multiple transactions to ARC in a single batch request.
99
+ #
100
+ # Each transaction is encoded as Extended Format (BRC-30) hex where
101
+ # possible, falling back to plain raw-tx hex per transaction independently.
102
+ #
103
+ # Returns a mixed array of {BroadcastResponse} and {BroadcastError} objects
104
+ # — one element per submitted transaction in the same order. Per-transaction
105
+ # rejections are returned as {BroadcastError} values rather than raised, so
106
+ # callers can inspect the full result set even when some transactions fail.
107
+ # Only HTTP-level errors (non-2xx) raise a {BroadcastError} for the whole
108
+ # batch.
109
+ #
110
+ # @param txs [Array<Transaction>] transactions to broadcast
111
+ # @param wait_for [String, nil] ARC wait condition (see {#broadcast})
112
+ # @param skip_fee_validation [Boolean, nil] when truthy, sends
113
+ # +X-SkipFeeValidation: true+ for the batch request
114
+ # @param skip_script_validation [Boolean, nil] when truthy, sends
115
+ # +X-SkipScriptValidation: true+ for the batch request
116
+ # @return [Array<BroadcastResponse, BroadcastError>]
117
+ # @raise [BroadcastError] when ARC returns a non-2xx HTTP status or a
118
+ # malformed (non-array) response body
119
+ def broadcast_many(txs, wait_for: nil, skip_fee_validation: nil, skip_script_validation: nil)
120
+ return [] if txs.empty?
121
+
122
+ uri = URI("#{@url}/v1/txs")
123
+ request = build_post_request(uri, wait_for: wait_for,
124
+ skip_fee_validation: skip_fee_validation,
125
+ skip_script_validation: skip_script_validation)
126
+ request.body = JSON.generate(txs.map { |tx| { rawTx: raw_tx_hex(tx) } })
127
+
128
+ response = execute(uri, request)
129
+ handle_batch_response(response)
130
+ end
131
+
84
132
  # Query the status of a previously submitted transaction.
85
133
  # Returns BroadcastResponse on success, raises BroadcastError on failure.
86
134
  def status(txid)
@@ -104,6 +152,19 @@ module BSV
104
152
  tx.to_hex
105
153
  end
106
154
 
155
+ def build_post_request(uri, wait_for: nil, skip_fee_validation: nil, skip_script_validation: nil)
156
+ request = Net::HTTP::Post.new(uri)
157
+ request['Content-Type'] = 'application/json'
158
+ request['XDeployment-ID'] = @deployment_id
159
+ request['X-WaitFor'] = wait_for if wait_for
160
+ request['X-CallbackUrl'] = @callback_url if @callback_url
161
+ request['X-CallbackToken'] = @callback_token if @callback_token
162
+ request['X-SkipFeeValidation'] = 'true' if skip_fee_validation
163
+ request['X-SkipScriptValidation'] = 'true' if skip_script_validation
164
+ apply_auth_header(request)
165
+ request
166
+ end
167
+
107
168
  def apply_auth_header(request)
108
169
  request['Authorization'] = "Bearer #{@api_key}" if @api_key
109
170
  end
@@ -189,6 +250,44 @@ module BSV
189
250
  competing_txs: body['competingTxs']
190
251
  )
191
252
  end
253
+
254
+ def handle_batch_response(response)
255
+ code = response.code.to_i
256
+ body = parse_json(response.body)
257
+
258
+ unless (200..299).cover?(code)
259
+ raise BroadcastError.new(
260
+ body['detail'] || body['title'] || "HTTP #{code}",
261
+ status_code: code
262
+ )
263
+ end
264
+
265
+ unless body.is_a?(Array)
266
+ raise BroadcastError.new(
267
+ 'ARC returned a malformed batch response',
268
+ status_code: code
269
+ )
270
+ end
271
+
272
+ body.map { |item| build_response_or_error(item) }
273
+ end
274
+
275
+ def build_response_or_error(body)
276
+ if rejected_status?(body)
277
+ BroadcastError.new(
278
+ body['detail'] || body['title'] || body['txStatus'],
279
+ status_code: 200,
280
+ txid: body['txid']
281
+ )
282
+ elsif !body['txid']
283
+ BroadcastError.new(
284
+ 'ARC returned a malformed 2xx response',
285
+ status_code: 200
286
+ )
287
+ else
288
+ build_response(body)
289
+ end
290
+ end
192
291
  end
193
292
  end
194
293
  end
@@ -34,6 +34,28 @@ module BSV
34
34
  @s_prime = s_prime
35
35
  @z = z
36
36
  end
37
+
38
+ # Deserialise a proof from its binary representation.
39
+ #
40
+ # The format is: R (33 bytes) + S' (33 bytes) + z (remaining bytes).
41
+ # The z scalar is variable-length to accommodate both the Ruby SDK's
42
+ # fixed 32-byte encoding and the TS SDK's minimal encoding (which
43
+ # omits leading zero bytes). See issue #203.
44
+ #
45
+ # @param data [String] binary proof data (>= 67 bytes)
46
+ # @return [Proof]
47
+ # @raise [ArgumentError] if data is too short
48
+ def self.from_binary(data)
49
+ data = data.b
50
+ raise ArgumentError, "proof too short: #{data.bytesize} bytes (minimum 67)" if data.bytesize < 67
51
+
52
+ r = PublicKey.from_bytes(data.byteslice(0, 33))
53
+ s_prime = PublicKey.from_bytes(data.byteslice(33, 33))
54
+ z_bytes = data.byteslice(66, data.bytesize - 66)
55
+ z = OpenSSL::BN.new(z_bytes, 2)
56
+
57
+ new(r, s_prime, z)
58
+ end
37
59
  end
38
60
 
39
61
  module_function
@@ -51,6 +51,7 @@ module BSV
51
51
  MINIMAL_DATA = :minimal_data
52
52
  STACK_MEMORY_EXCEEDED = :stack_memory_exceeded
53
53
  UNIMPLEMENTED_OPCODE = :unimplemented_opcode
54
+ MISSING_TX_CONTEXT = :missing_tx_context
54
55
  end
55
56
  end
56
57
  end
@@ -40,9 +40,13 @@ module BSV
40
40
  attr_reader :dstack, :astack
41
41
 
42
42
  # Conditional opcodes must be processed even in non-executing branches
43
- # to maintain correct nesting depth.
43
+ # to maintain correct nesting depth. OP_VERIF and OP_VERNOTIF are included
44
+ # here because they open conditional blocks (like OP_IF/OP_NOTIF) and must
45
+ # be dispatched to track nesting depth even when the branch is not executing.
46
+ # This matches the Go SDK's IsConditional() function.
44
47
  CONDITIONAL_OPCODES = [
45
- Opcodes::OP_IF, Opcodes::OP_NOTIF, Opcodes::OP_ELSE, Opcodes::OP_ENDIF
48
+ Opcodes::OP_IF, Opcodes::OP_NOTIF, Opcodes::OP_ELSE, Opcodes::OP_ENDIF,
49
+ Opcodes::OP_VERIF, Opcodes::OP_VERNOTIF
46
50
  ].freeze
47
51
 
48
52
  # Maximum nesting depth for OP_IF / OP_NOTIF blocks. Prevents interpreter
@@ -179,13 +183,9 @@ module BSV
179
183
  when Opcodes::OP_RETURN then op_return
180
184
  when Opcodes::OP_RESERVED, Opcodes::OP_RESERVED1, Opcodes::OP_RESERVED2
181
185
  op_reserved(opcode)
182
- # Chronicle fail-safe: OP_VER, OP_VERIF, OP_VERNOTIF, and the Chronicle
183
- # string/shift slots raise UnimplementedOpcode. Full semantics are
184
- # deferred to SDK v0.10.
185
- when Opcodes::OP_VER, Opcodes::OP_VERIF, Opcodes::OP_VERNOTIF,
186
- Opcodes::OP_SUBSTR, Opcodes::OP_LEFT, Opcodes::OP_RIGHT,
187
- Opcodes::OP_LSHIFTNUM, Opcodes::OP_RSHIFTNUM
188
- op_unimplemented(opcode)
186
+ when Opcodes::OP_VER then op_ver
187
+ when Opcodes::OP_VERIF then op_verif
188
+ when Opcodes::OP_VERNOTIF then op_vernotif
189
189
 
190
190
  # --- Stack manipulation ---
191
191
  when Opcodes::OP_TOALTSTACK then op_toaltstack
@@ -214,6 +214,9 @@ module BSV
214
214
  when Opcodes::OP_NUM2BIN then op_num2bin
215
215
  when Opcodes::OP_BIN2NUM then op_bin2num
216
216
  when Opcodes::OP_SIZE then op_size
217
+ when Opcodes::OP_SUBSTR then op_substr
218
+ when Opcodes::OP_LEFT then op_left
219
+ when Opcodes::OP_RIGHT then op_right
217
220
 
218
221
  # --- Bitwise ---
219
222
  when Opcodes::OP_EQUAL then op_equal
@@ -226,8 +229,8 @@ module BSV
226
229
  # --- Arithmetic ---
227
230
  when Opcodes::OP_1ADD then op_1add
228
231
  when Opcodes::OP_1SUB then op_1sub
229
- when Opcodes::OP_2MUL, Opcodes::OP_2DIV
230
- op_disabled(opcode)
232
+ when Opcodes::OP_2MUL then op_2mul
233
+ when Opcodes::OP_2DIV then op_2div
231
234
  when Opcodes::OP_NEGATE then op_negate
232
235
  when Opcodes::OP_ABS then op_abs
233
236
  when Opcodes::OP_NOT then op_not
@@ -239,6 +242,8 @@ module BSV
239
242
  when Opcodes::OP_MOD then op_mod
240
243
  when Opcodes::OP_LSHIFT then op_lshift
241
244
  when Opcodes::OP_RSHIFT then op_rshift
245
+ when Opcodes::OP_LSHIFTNUM then op_lshiftnum
246
+ when Opcodes::OP_RSHIFTNUM then op_rshiftnum
242
247
  when Opcodes::OP_BOOLAND then op_booland
243
248
  when Opcodes::OP_BOOLOR then op_boolor
244
249
  when Opcodes::OP_NUMEQUAL then op_numequal
@@ -5,7 +5,7 @@ module BSV
5
5
  class Interpreter
6
6
  module Operations
7
7
  # Arithmetic and comparison operations including restored post-Genesis
8
- # opcodes: MUL, DIV, MOD, LSHIFT, RSHIFT.
8
+ # opcodes: MUL, DIV, MOD, LSHIFT, RSHIFT, and Chronicle opcodes: 2MUL, 2DIV.
9
9
  module Arithmetic
10
10
  private
11
11
 
@@ -190,12 +190,48 @@ module BSV
190
190
  @dstack.push_int(ScriptNumber.new(x >= min && x < max ? 1 : 0))
191
191
  end
192
192
 
193
- # OP_2MUL, OP_2DIV: disabled opcodes
194
- def op_disabled(opcode)
195
- raise ScriptError.new(
196
- ScriptErrorCode::DISABLED_OPCODE,
197
- "attempt to execute disabled opcode: 0x#{opcode.to_s(16).rjust(2, '0')}"
198
- )
193
+ # OP_2MUL: multiply top of stack by 2 (restored in Chronicle)
194
+ def op_2mul
195
+ n = @dstack.pop_int
196
+ @dstack.push_int(n * ScriptNumber.new(2))
197
+ end
198
+
199
+ # OP_2DIV: integer-divide top of stack by 2, truncating toward zero (restored in Chronicle)
200
+ def op_2div
201
+ n = @dstack.pop_int
202
+ @dstack.push_int(n / ScriptNumber.new(2))
203
+ end
204
+
205
+ # OP_LSHIFTNUM: numeric left shift (Chronicle opcode 0xb6)
206
+ #
207
+ # Pops n then value; pushes value.value << n.to_i32.
208
+ # Raises INVALID_INPUT_LENGTH if the shift amount is negative.
209
+ def op_lshiftnum
210
+ n = @dstack.pop_int
211
+ value = @dstack.pop_int
212
+ shift = n.to_i32
213
+ raise ScriptError.new(ScriptErrorCode::INVALID_INPUT_LENGTH, 'shift amount negative') if shift.negative?
214
+
215
+ @dstack.push_int(ScriptNumber.new(value.value << shift))
216
+ end
217
+
218
+ # OP_RSHIFTNUM: numeric arithmetic right shift (Chronicle opcode 0xb7)
219
+ #
220
+ # Pops n then value; pushes the arithmetic right shift of value by n bits.
221
+ # Raises INVALID_INPUT_LENGTH if the shift amount is negative.
222
+ #
223
+ # IMPORTANT: Ruby's Integer#>> sign-extends negative numbers (-1 >> 1 == -1).
224
+ # Bitcoin consensus requires -(abs(value) >> n) for negative values, so that
225
+ # -1 >> 1 == 0 (not -1) and -16 >> 2 == -4. This matches the Go and TS SDKs.
226
+ def op_rshiftnum
227
+ n = @dstack.pop_int
228
+ value = @dstack.pop_int
229
+ shift = n.to_i32
230
+ raise ScriptError.new(ScriptErrorCode::INVALID_INPUT_LENGTH, 'shift amount negative') if shift.negative?
231
+
232
+ val = value.value
233
+ shifted = val.negative? ? -(val.abs >> shift) : (val >> shift)
234
+ @dstack.push_int(ScriptNumber.new(shifted))
199
235
  end
200
236
 
201
237
  # --- Byte-array shift helpers ---
@@ -95,16 +95,91 @@ module BSV
95
95
  )
96
96
  end
97
97
 
98
- # Chronicle fail-safe: OP_VER, OP_VERIF, OP_VERNOTIF and the Chronicle
99
- # string/shift slots (OP_SUBSTR, OP_LEFT, OP_RIGHT, OP_LSHIFTNUM,
100
- # OP_RSHIFTNUM). Full semantics are deferred to SDK v0.10. Any script
101
- # that reaches one of these opcodes will fail loudly rather than
102
- # silently succeeding.
103
- def op_unimplemented(opcode)
104
- raise ScriptError.new(
105
- ScriptErrorCode::UNIMPLEMENTED_OPCODE,
106
- "unimplemented opcode: 0x#{opcode.to_s(16).rjust(2, '0')}"
107
- )
98
+ # OP_VER: push 4-byte little-endian transaction version onto the stack.
99
+ #
100
+ # Requires a transaction context (@tx must not be nil). Raises
101
+ # MISSING_TX_CONTEXT when called from Interpreter.evaluate (no-tx path).
102
+ #
103
+ # The version is always encoded as exactly 4 bytes (LE uint32), never as
104
+ # a ScriptNumber. Version 1 → 01000000, version 2 → 02000000.
105
+ def op_ver
106
+ if @tx.nil?
107
+ raise ScriptError.new(
108
+ ScriptErrorCode::MISSING_TX_CONTEXT,
109
+ 'OP_VER requires transaction context'
110
+ )
111
+ end
112
+
113
+ @dstack.push_bytes(tx_version_bytes)
114
+ end
115
+
116
+ # OP_VERIF: version-conditional branch — executes like OP_IF but compares
117
+ # the top stack item against the 4-byte LE transaction version rather than
118
+ # interpreting the item as a boolean.
119
+ #
120
+ # In an executing branch: pops bytes from the stack. If the bytes are
121
+ # exactly 4 bytes and match the current transaction version, the true
122
+ # branch executes; otherwise the false/else branch executes.
123
+ #
124
+ # In a non-executing branch: pushes +:false+ to track nesting depth
125
+ # without touching the data stack.
126
+ #
127
+ # If @tx is nil, +tx_version_matches?+ returns false — the else branch
128
+ # is always taken (not an error, unlike OP_VER).
129
+ def op_verif
130
+ if @cond_stack.length >= MAX_CONDITIONAL_DEPTH
131
+ raise ScriptError.new(
132
+ ScriptErrorCode::UNBALANCED_CONDITIONAL,
133
+ "conditional depth exceeded #{MAX_CONDITIONAL_DEPTH}"
134
+ )
135
+ end
136
+
137
+ if branch_executing?
138
+ data = @dstack.pop_bytes
139
+ @cond_stack.push(tx_version_matches?(data) ? :true : :false)
140
+ else
141
+ @cond_stack.push(:false)
142
+ end
143
+ @else_stack.push(false)
144
+ end
145
+
146
+ # OP_VERNOTIF: inverse version-conditional branch — executes like OP_NOTIF
147
+ # but compares against the 4-byte LE transaction version.
148
+ #
149
+ # In an executing branch: match → false branch (else), no match → true branch.
150
+ # In a non-executing branch: pushes +:false+ for nesting tracking only.
151
+ def op_vernotif
152
+ if @cond_stack.length >= MAX_CONDITIONAL_DEPTH
153
+ raise ScriptError.new(
154
+ ScriptErrorCode::UNBALANCED_CONDITIONAL,
155
+ "conditional depth exceeded #{MAX_CONDITIONAL_DEPTH}"
156
+ )
157
+ end
158
+
159
+ if branch_executing?
160
+ data = @dstack.pop_bytes
161
+ @cond_stack.push(tx_version_matches?(data) ? :false : :true)
162
+ else
163
+ @cond_stack.push(:false)
164
+ end
165
+ @else_stack.push(false)
166
+ end
167
+
168
+ # Returns the transaction version as a 4-byte little-endian binary
169
+ # string. Used by OP_VER, OP_VERIF, and OP_VERNOTIF.
170
+ def tx_version_bytes
171
+ [@tx.version].pack('V')
172
+ end
173
+
174
+ # Compares raw bytes against the current transaction version.
175
+ # Returns false if:
176
+ # - +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
179
+ def tx_version_matches?(data)
180
+ return false if @tx.nil? || data.bytesize != 4
181
+
182
+ data == tx_version_bytes
108
183
  end
109
184
  end
110
185
  end