bsv-sdk 0.1.0 → 0.2.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: ed81ad480647a52136c32f6bd786fd8a0a590aa6d2805fb02151b583c6b8664b
4
- data.tar.gz: c6eba8e1020913d59958d68b028b66ef7035d494f3c5d34dbd98c401ab30ec35
3
+ metadata.gz: 83e581aac74bb9865acd4dac19b8e7422f8a018f7fea7cd9085eb6ad58add6ca
4
+ data.tar.gz: 7f9bd7d2b7902dc080943a4b4ac30526e82ba38a0d278be14b07d575a8f52ade
5
5
  SHA512:
6
- metadata.gz: aa4673f314ac7dddcb94d04d8e9c6db75a2c635c298e46d9412ee65babb491fd514f1bc37e94996206a41ad7d35ff1c2f57554913201be67e532700793b187da
7
- data.tar.gz: ca38730828db7c78299a8eee34a682c5781f8cdae44186d7762b12030158f0f696d78204819f832e8f2bb8f913298100f9c55fb7a6f1f89cce4453c759d5e2c1
6
+ metadata.gz: a2ad1b26a36e2c052f4109b4ec3649bfdc1f3efd08d0e0e8b08b054a7f61b0a0ae2f777f0da3d4445259681e06b0dd31dc74b252ae0ed8c10f5ff11f67963917
7
+ data.tar.gz: '08955c85b4bee8ffcc1c7e84e775c3306ccf8e573103f2347072f86bcd67633abd96193fe516484e94f4cd047edaad5cda52b6612dabbceade6b05dd272acd09'
data/CHANGELOG.md CHANGED
@@ -5,6 +5,55 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [0.2.0] - 2026-03-07
9
+
10
+ ### Added
11
+
12
+ #### Primitives
13
+
14
+ - ECDH shared secret derivation (`PrivateKey#derive_shared_secret`, `PublicKey#derive_shared_secret`)
15
+ - BRC-42 key derivation (`PrivateKey#derive_child`, `PublicKey#derive_child`) with official spec test vectors
16
+
17
+ #### Transaction
18
+
19
+ - Chain tracker interface (`ChainTracker` base class) with WhatsOnChain implementation
20
+ - Fee model interface (`FeeModel` base class) with `SatoshisPerKilobyte` implementation
21
+ - `Transaction#fee` with change output distribution across multiple change outputs
22
+ - `Transaction#verify` for full SPV verification (merkle path, script execution, recursive ancestry)
23
+ - `TransactionOutput#change` flag for identifying change outputs
24
+ - `MerklePath#verify` for SPV chain tracker integration
25
+ - BEEF completion: `Beef#merge`, `Beef#valid?`, lookup methods (`find_bump`, `find_transaction_for_signing`)
26
+ - `Transaction#to_beef` / `Transaction.from_beef` convenience methods
27
+ - Extended Format (EF) transaction serialisation (`to_ef`, `to_ef_hex`, `from_ef`, `from_ef_hex`)
28
+ - `VerificationError` with typed error codes for SPV verification failures
29
+
30
+ ### Changed
31
+
32
+ - ECIES refactored to use `PrivateKey#derive_shared_secret` internally (no API change)
33
+ - `Transaction#estimated_size` made public for fee model access
34
+
35
+ ### Fixed
36
+
37
+ - Nil `source_satoshis` now raises instead of silently coercing to zero in fee distribution and verification
38
+ - Script chunk round-trips preserve original push encoding
39
+ - `OP_RETURN` inside conditionals correctly checked for conditional balance
40
+ - Point x-coordinate extraction preserves leading zeros via octet string
41
+ - `Integer#nobits?` replaced with Ruby 2.7-compatible bitwise check
42
+ - Defensive parsing with descriptive errors for truncated binary input
43
+
44
+ ### Testing
45
+
46
+ - BRC-42 conformance specs with 9 official specification test vectors
47
+ - ECDH conformance specs (commutativity, cross-method, pinned known-key vector)
48
+ - SPV verification conformance specs (merkle path, script execution, ancestry)
49
+ - Fee model conformance specs (formula validation, default rate, change distribution)
50
+ - Chain tracker conformance specs
51
+ - BEEF cross-SDK conformance vectors
52
+ - Schnorr (BRC-94) cross-SDK interoperability vectors
53
+ - 6 exact-match RFC 6979 vectors from Trezor/CoreBitcoin
54
+ - VarInt boundary tests at size-prefix transitions
55
+ - Script vectors converted to tracked known-failures system
56
+
8
57
  ## [0.1.0] - 2026-02-14
9
58
 
10
59
  Initial release of the BSV Ruby SDK.
@@ -65,9 +65,9 @@ module BSV
65
65
  # @param point [OpenSSL::PKey::EC::Point] the curve point
66
66
  # @return [OpenSSL::BN] the x-coordinate
67
67
  def point_x(point)
68
- x_hex = point.to_bn(:uncompressed).to_s(16)
69
- # Uncompressed format: 04 || X (64 hex) || Y (64 hex)
70
- OpenSSL::BN.new(x_hex[2, 64], 16)
68
+ # Uncompressed octet string: 0x04 || X (32 bytes) || Y (32 bytes)
69
+ # Slicing raw bytes avoids BN#to_s(16) stripping leading zeros.
70
+ OpenSSL::BN.new(point.to_octet_string(:uncompressed)[1, 32], 2)
71
71
  end
72
72
 
73
73
  # Reconstruct a curve point from its byte representation.
@@ -39,7 +39,7 @@ module BSV
39
39
  ephemeral = private_key || PrivateKey.generate
40
40
  ephemeral_pub = ephemeral.public_key
41
41
 
42
- iv, key_e, key_m = derive_keys(public_key.point, ephemeral.bn)
42
+ iv, key_e, key_m = derive_keys(ephemeral, public_key)
43
43
 
44
44
  cipher = OpenSSL::Cipher.new('aes-128-cbc')
45
45
  cipher.encrypt
@@ -76,7 +76,7 @@ module BSV
76
76
 
77
77
  ephemeral_pub = PublicKey.from_bytes(ephemeral_pub_bytes)
78
78
 
79
- iv, key_e, key_m = derive_keys(ephemeral_pub.point, private_key.bn)
79
+ iv, key_e, key_m = derive_keys(private_key, ephemeral_pub)
80
80
 
81
81
  # Verify HMAC before decryption (encrypt-then-MAC)
82
82
  payload = data[0...-32]
@@ -111,9 +111,9 @@ module BSV
111
111
  end
112
112
  end
113
113
 
114
- def derive_keys(point, scalar_bn)
115
- shared_point = Curve.multiply_point(point, scalar_bn)
116
- ecdh_key = shared_point.to_octet_string(:compressed)
114
+ def derive_keys(private_key, public_key)
115
+ shared = private_key.derive_shared_secret(public_key)
116
+ ecdh_key = shared.compressed
117
117
  derived = Digest.sha512(ecdh_key)
118
118
 
119
119
  iv = derived[0, 16]
@@ -127,6 +127,41 @@ module BSV
127
127
  @public_key ||= PublicKey.new(Curve.multiply_generator(@bn))
128
128
  end
129
129
 
130
+ # Derive an ECDH shared secret with another party's public key.
131
+ #
132
+ # Computes the shared point by multiplying the given public key by
133
+ # this private key's scalar. The result is commutative:
134
+ # alice_priv.derive_shared_secret(bob_pub) ==
135
+ # bob_priv.derive_shared_secret(alice_pub)
136
+ #
137
+ # This is the foundational primitive for BRC-42 key derivation,
138
+ # BRC-77/78 messaging, and ECIES encryption.
139
+ #
140
+ # @param public_key [PublicKey] the other party's public key
141
+ # @return [PublicKey] the shared secret as a public key (curve point)
142
+ def derive_shared_secret(public_key)
143
+ shared_point = Curve.multiply_point(public_key.point, @bn)
144
+ PublicKey.new(shared_point)
145
+ end
146
+
147
+ # Derive a child private key using BRC-42 key derivation.
148
+ #
149
+ # Computes HMAC-SHA256(key: ECDH_shared_secret, msg: invoice_number)
150
+ # and adds it to this private key's scalar mod n. The corresponding
151
+ # public key can be derived without the private key using
152
+ # {PublicKey#derive_child}.
153
+ #
154
+ # @param public_key [PublicKey] the counterparty's public key
155
+ # @param invoice_number [String] the invoice number (UTF-8)
156
+ # @return [PrivateKey] the derived child private key
157
+ def derive_child(public_key, invoice_number)
158
+ shared = derive_shared_secret(public_key)
159
+ hmac = Digest.hmac_sha256(shared.compressed, invoice_number.encode('UTF-8'))
160
+ hmac_bn = OpenSSL::BN.new(hmac.unpack1('H*'), 16)
161
+ child_bn = (@bn + hmac_bn).to_i % Curve::N.to_i
162
+ PrivateKey.new(OpenSSL::BN.new(child_bn.to_s))
163
+ end
164
+
130
165
  # Sign a 32-byte hash using deterministic ECDSA (RFC 6979).
131
166
  #
132
167
  # @param hash [String] 32-byte message digest to sign
@@ -99,6 +99,42 @@ module BSV
99
99
  Base58.check_encode(prefix + hash160)
100
100
  end
101
101
 
102
+ # Derive an ECDH shared secret with another party's private key.
103
+ #
104
+ # Computes the shared point by multiplying this public key by the
105
+ # given private key's scalar. The result is commutative:
106
+ # alice_pub.derive_shared_secret(bob_priv) ==
107
+ # bob_pub.derive_shared_secret(alice_priv)
108
+ #
109
+ # This is the foundational primitive for BRC-42 key derivation,
110
+ # BRC-77/78 messaging, and ECIES encryption.
111
+ #
112
+ # @param private_key [PrivateKey] the other party's private key
113
+ # @return [PublicKey] the shared secret as a public key (curve point)
114
+ def derive_shared_secret(private_key)
115
+ shared_point = Curve.multiply_point(@point, private_key.bn)
116
+ PublicKey.new(shared_point)
117
+ end
118
+
119
+ # Derive a child public key using BRC-42 key derivation.
120
+ #
121
+ # Computes HMAC-SHA256(key: ECDH_shared_secret, msg: invoice_number)
122
+ # and adds the corresponding curve point to this public key. The result
123
+ # matches the public key of {PrivateKey#derive_child} with the same
124
+ # inputs, enabling public-key-only derivation.
125
+ #
126
+ # @param private_key [PrivateKey] the counterparty's private key
127
+ # @param invoice_number [String] the invoice number (UTF-8)
128
+ # @return [PublicKey] the derived child public key
129
+ def derive_child(private_key, invoice_number)
130
+ shared = derive_shared_secret(private_key)
131
+ hmac = Digest.hmac_sha256(shared.compressed, invoice_number.encode('UTF-8'))
132
+ hmac_bn = OpenSSL::BN.new(hmac.unpack1('H*'), 16)
133
+ hmac_point = Curve.multiply_generator(hmac_bn)
134
+ child_point = Curve.add_points(@point, hmac_point)
135
+ PublicKey.new(child_point)
136
+ end
137
+
102
138
  # Verify an ECDSA signature against a message hash.
103
139
  #
104
140
  # @param hash [String] 32-byte message digest
@@ -50,7 +50,7 @@ module BSV
50
50
 
51
51
  r_bytes = bytes[4, r_len]
52
52
  raise ArgumentError, 'R has negative flag' if r_bytes[0] & 0x80 != 0
53
- raise ArgumentError, 'R has excessive padding' if r_len > 1 && r_bytes[0].zero? && r_bytes[1].nobits?(0x80)
53
+ raise ArgumentError, 'R has excessive padding' if r_len > 1 && r_bytes[0].zero? && (r_bytes[1] & 0x80).zero? # rubocop:disable Style/BitwisePredicate
54
54
 
55
55
  # Parse S
56
56
  s_offset = 4 + r_len
@@ -62,7 +62,7 @@ module BSV
62
62
 
63
63
  s_bytes = bytes[s_offset + 2, s_len]
64
64
  raise ArgumentError, 'S has negative flag' if s_bytes[0] & 0x80 != 0
65
- raise ArgumentError, 'S has excessive padding' if s_len > 1 && s_bytes[0].zero? && s_bytes[1].nobits?(0x80)
65
+ raise ArgumentError, 'S has excessive padding' if s_len > 1 && s_bytes[0].zero? && (s_bytes[1] & 0x80).zero? # rubocop:disable Style/BitwisePredicate
66
66
 
67
67
  raise ArgumentError, 'trailing bytes' unless s_offset + 2 + s_len == bytes.length
68
68
 
@@ -29,12 +29,24 @@ module BSV
29
29
 
30
30
  # Serialise this chunk back to raw script bytes.
31
31
  #
32
+ # Preserves the original push encoding (including non-minimal pushes)
33
+ # so that round-tripping through parse/serialise does not alter the
34
+ # script bytes. This is critical for sighash computation.
35
+ #
32
36
  # @return [String] binary script bytes for this chunk
33
37
  def to_binary
34
- if @data
35
- push_data_binary(@data)
38
+ return [@opcode].pack('C') unless @data
39
+
40
+ case @opcode
41
+ when Opcodes::OP_PUSHDATA1
42
+ [Opcodes::OP_PUSHDATA1, @data.bytesize].pack('CC') + @data
43
+ when Opcodes::OP_PUSHDATA2
44
+ [Opcodes::OP_PUSHDATA2].pack('C') + [@data.bytesize].pack('v') + @data
45
+ when Opcodes::OP_PUSHDATA4
46
+ [Opcodes::OP_PUSHDATA4].pack('C') + [@data.bytesize].pack('V') + @data
36
47
  else
37
- [@opcode].pack('C')
48
+ # Direct push: opcode IS the length (0x01..0x4b)
49
+ [@opcode].pack('C') + @data
38
50
  end
39
51
  end
40
52
 
@@ -56,22 +68,6 @@ module BSV
56
68
  def ==(other)
57
69
  other.is_a?(Chunk) && @opcode == other.opcode && @data == other.data
58
70
  end
59
-
60
- private
61
-
62
- def push_data_binary(data)
63
- len = data.bytesize
64
-
65
- if len <= 0x4b
66
- [len].pack('C') + data
67
- elsif len <= 0xff
68
- [Opcodes::OP_PUSHDATA1, len].pack('CC') + data
69
- elsif len <= 0xffff
70
- [Opcodes::OP_PUSHDATA2, len].pack('Cv') + data
71
- else
72
- [Opcodes::OP_PUSHDATA4, len].pack('CV') + data
73
- end
74
- end
75
71
  end
76
72
  end
77
73
  end
@@ -28,7 +28,7 @@ module BSV
28
28
  # unlock_script: input.script, lock_script: prev_output.script,
29
29
  # satoshis: prev_output.satoshis
30
30
  # )
31
- class Interpreter # rubocop:disable Metrics/ClassLength
31
+ class Interpreter
32
32
  include Operations::DataPush
33
33
  include Operations::StackOps
34
34
  include Operations::FlowControl
@@ -80,7 +80,7 @@ module BSV
80
80
  ).execute
81
81
  end
82
82
 
83
- def execute # rubocop:disable Naming/PredicateMethod
83
+ def execute
84
84
  scripts = [@unlock_script, @lock_script]
85
85
 
86
86
  scripts.each_with_index do |script, script_idx|
@@ -106,6 +106,7 @@ module BSV
106
106
  # Reset state for next script
107
107
  @last_code_sep = 0
108
108
  @early_return = false
109
+ @after_op_return = false
109
110
  end
110
111
 
111
112
  check_final_stack
@@ -127,6 +128,7 @@ module BSV
127
128
  @else_stack = []
128
129
  @last_code_sep = 0
129
130
  @early_return = false
131
+ @after_op_return = false
130
132
  @current_script = nil
131
133
  @current_chunk_idx = 0
132
134
  end
@@ -134,6 +136,14 @@ module BSV
134
136
  def execute_opcode(chunk)
135
137
  opcode = chunk.opcode
136
138
 
139
+ # After OP_RETURN inside a conditional: only process flow control opcodes
140
+ # and OP_RETURN itself (which may terminate at top level once conditionals
141
+ # are balanced), matching Go SDK's branchExecuting semantics.
142
+ if @after_op_return
143
+ dispatch_opcode(opcode, chunk) if conditional_opcode?(opcode) || opcode == Opcodes::OP_RETURN
144
+ return
145
+ end
146
+
137
147
  # In non-executing branch: only dispatch conditional opcodes (for nesting tracking).
138
148
  # All other opcodes are skipped.
139
149
  unless branch_executing?
@@ -261,7 +271,7 @@ module BSV
261
271
  # Is the current conditional branch executing?
262
272
  # Checks ALL entries — a :false anywhere means we're not executing.
263
273
  def branch_executing?
264
- @cond_stack.none? { |v| v == :false } # rubocop:disable Lint/BooleanSymbol
274
+ @cond_stack.none? { |v| v == :false }
265
275
  end
266
276
 
267
277
  def conditional_opcode?(opcode)
@@ -6,7 +6,7 @@ module BSV
6
6
  module Operations
7
7
  # Arithmetic and comparison operations including restored post-Genesis
8
8
  # opcodes: MUL, DIV, MOD, LSHIFT, RSHIFT.
9
- module Arithmetic # rubocop:disable Metrics/ModuleLength
9
+ module Arithmetic
10
10
  private
11
11
 
12
12
  # OP_1ADD: increment top by 1
@@ -5,7 +5,7 @@ module BSV
5
5
  class Interpreter
6
6
  module Operations
7
7
  # Cryptographic operations: hash functions, CHECKSIG, and CHECKMULTISIG.
8
- module Crypto # rubocop:disable Metrics/ModuleLength
8
+ module Crypto
9
9
  private
10
10
 
11
11
  # --- Hash operations ---
@@ -11,9 +11,9 @@ module BSV
11
11
  # OP_IF: conditional execution
12
12
  def op_if
13
13
  if branch_executing?
14
- @cond_stack.push(@dstack.pop_bool ? :true : :false) # rubocop:disable Lint/BooleanSymbol
14
+ @cond_stack.push(@dstack.pop_bool ? :true : :false)
15
15
  else
16
- @cond_stack.push(:false) # rubocop:disable Lint/BooleanSymbol
16
+ @cond_stack.push(:false)
17
17
  end
18
18
  @else_stack.push(false)
19
19
  end
@@ -21,9 +21,9 @@ module BSV
21
21
  # OP_NOTIF: inverse conditional execution
22
22
  def op_notif
23
23
  if branch_executing?
24
- @cond_stack.push(@dstack.pop_bool ? :false : :true) # rubocop:disable Lint/BooleanSymbol
24
+ @cond_stack.push(@dstack.pop_bool ? :false : :true)
25
25
  else
26
- @cond_stack.push(:false) # rubocop:disable Lint/BooleanSymbol
26
+ @cond_stack.push(:false)
27
27
  end
28
28
  @else_stack.push(false)
29
29
  end
@@ -38,8 +38,8 @@ module BSV
38
38
  raise ScriptError.new(ScriptErrorCode::UNBALANCED_CONDITIONAL, 'duplicate OP_ELSE') if @else_stack.pop
39
39
 
40
40
  case @cond_stack.last
41
- when :true then @cond_stack[-1] = :false # rubocop:disable Lint/BooleanSymbol
42
- when :false then @cond_stack[-1] = :true # rubocop:disable Lint/BooleanSymbol
41
+ when :true then @cond_stack[-1] = :false
42
+ when :false then @cond_stack[-1] = :true
43
43
  end
44
44
 
45
45
  @else_stack.push(true)
@@ -62,9 +62,16 @@ module BSV
62
62
  raise ScriptError.new(ScriptErrorCode::VERIFY_FAILED, 'OP_VERIFY failed')
63
63
  end
64
64
 
65
- # OP_RETURN: after-genesis early termination (success)
65
+ # OP_RETURN: after-genesis early termination.
66
+ # At top level (outside conditionals): immediate success.
67
+ # Inside a conditional: remaining opcodes are skipped but conditional
68
+ # balance is still checked at script end.
66
69
  def op_return
67
- @early_return = true
70
+ if @cond_stack.empty?
71
+ @early_return = true
72
+ else
73
+ @after_op_return = true
74
+ end
68
75
  end
69
76
 
70
77
  # OP_NOP and OP_NOP1..OP_NOP10 (including CLTV/CSV treated as NOP)
@@ -11,7 +11,7 @@ module BSV
11
11
  # the MSB of the last byte, and the magnitude is stored little-endian.
12
12
  # This class handles encoding/decoding, minimal encoding validation,
13
13
  # and arithmetic operations as required by the script interpreter.
14
- class ScriptNumber # rubocop:disable Metrics/ClassLength
14
+ class ScriptNumber
15
15
  include Comparable
16
16
 
17
17
  # Maximum byte length for script numbers (post-Genesis: 750 KB).
@@ -11,7 +11,7 @@ module BSV
11
11
  # Implements Forth-like stack manipulation operations (dup, drop, swap,
12
12
  # rot, over, pick, roll, tuck) parameterised by count for the multi-element
13
13
  # opcodes (OP_2DUP, OP_2SWAP, etc.).
14
- class Stack # rubocop:disable Metrics/ClassLength
14
+ class Stack
15
15
  def initialize
16
16
  @items = []
17
17
  end
@@ -168,7 +168,7 @@ module BSV
168
168
  # --- Boolean conversion ---
169
169
 
170
170
  # Bitcoin consensus boolean: false if empty, all-zero, or negative zero (0x80 last byte).
171
- def self.cast_bool(bytes) # rubocop:disable Naming/PredicateMethod
171
+ def self.cast_bool(bytes)
172
172
  return false if bytes.nil? || bytes.empty?
173
173
 
174
174
  bytes.each_byte.with_index do |byte, i|