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.
- checksums.yaml +4 -4
- data/{CHANGELOG-sdk.md → CHANGELOG.md} +37 -0
- data/bin/bsv-mcp +10 -0
- data/lib/bsv/mcp/config.rb +44 -0
- data/lib/bsv/mcp/server.rb +68 -0
- data/lib/bsv/mcp/tools/broadcast_p2pkh.rb +211 -0
- data/lib/bsv/mcp/tools/check_balance.rb +96 -0
- data/lib/bsv/mcp/tools/decode_tx.rb +64 -0
- data/lib/bsv/mcp/tools/fetch_tx.rb +72 -0
- data/lib/bsv/mcp/tools/fetch_utxos.rb +72 -0
- data/lib/bsv/mcp/tools/generate_key.rb +62 -0
- data/lib/bsv/mcp/tools/helpers.rb +87 -0
- data/lib/bsv/mcp.rb +18 -0
- data/lib/bsv/network/arc.rb +107 -8
- data/lib/bsv/primitives/schnorr.rb +22 -0
- data/lib/bsv/script/interpreter/error.rb +1 -0
- data/lib/bsv/script/interpreter/interpreter.rb +16 -11
- data/lib/bsv/script/interpreter/operations/arithmetic.rb +43 -7
- data/lib/bsv/script/interpreter/operations/flow_control.rb +85 -10
- data/lib/bsv/script/interpreter/operations/splice.rb +49 -0
- data/lib/bsv/script/opcodes.rb +2 -3
- data/lib/bsv/transaction/chain_trackers/chaintracks.rb +95 -0
- data/lib/bsv/transaction/chain_trackers.rb +11 -0
- data/lib/bsv/version.rb +1 -1
- data/lib/bsv-sdk.rb +1 -0
- metadata +33 -6
|
@@ -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
|
data/lib/bsv/network/arc.rb
CHANGED
|
@@ -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 =
|
|
72
|
-
|
|
73
|
-
|
|
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
|
|
@@ -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
|
-
|
|
183
|
-
|
|
184
|
-
|
|
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
|
|
230
|
-
|
|
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
|
|
194
|
-
def
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
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
|
-
#
|
|
99
|
-
#
|
|
100
|
-
#
|
|
101
|
-
#
|
|
102
|
-
#
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
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
|