bsv-sdk 0.1.0 → 0.2.1

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: d8f24ed29fd0a8beb572b3737c3bbfaba5bf939c5fe20d485306e5e4374587c1
4
+ data.tar.gz: 53746a1cdad1370718892805b76c763fbe58a73ef057bd65a1eaa4b1b120c7bd
5
5
  SHA512:
6
- metadata.gz: aa4673f314ac7dddcb94d04d8e9c6db75a2c635c298e46d9412ee65babb491fd514f1bc37e94996206a41ad7d35ff1c2f57554913201be67e532700793b187da
7
- data.tar.gz: ca38730828db7c78299a8eee34a682c5781f8cdae44186d7762b12030158f0f696d78204819f832e8f2bb8f913298100f9c55fb7a6f1f89cce4453c759d5e2c1
6
+ metadata.gz: 93590d116e66151f51af5167bb405b2d91cd2997f685fad0d684cd07f5231aa1387d57d71852e5464f3f222d1b471534d22ab6633c75d188f9080c1718f834a0
7
+ data.tar.gz: b26e9a26837af3ce9426311050c0e2fbbdaac9ce4d04a7987c123ef189ec6528e779c69bd7b77605e25df70c1510327a71b7843151f50eb710b2b63a788eeae2
data/CHANGELOG.md CHANGED
@@ -5,6 +5,71 @@ 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.1] - 2026-03-07
9
+
10
+ ### Fixed
11
+
12
+ - Truncated OP_PUSHDATA1/2/4 scripts now raise `ArgumentError` instead of crashing with `TypeError`
13
+ - `Transaction#to_beef` uses `merge_bump` to correctly handle multiple ancestors at the same block height
14
+ - `PrivateKey#derive_child` uses `BN.mod_add` instead of Integer roundtrip for modular addition
15
+ - Fixed txid byte-order documentation (display order, not internal order)
16
+
17
+ ### Testing
18
+
19
+ - FORKID enforcement spec verifying interpreter rejects signatures without SIGHASH_FORKID
20
+ - ExtendedKey fingerprint chain integrity across 3-generation derivation
21
+ - Mnemonic entropy round-trip across all 5 valid entropy lengths
22
+ - BEEF spec for multiple ancestors at the same block height
23
+
24
+ ## [0.2.0] - 2026-03-07
25
+
26
+ ### Added
27
+
28
+ #### Primitives
29
+
30
+ - ECDH shared secret derivation (`PrivateKey#derive_shared_secret`, `PublicKey#derive_shared_secret`)
31
+ - BRC-42 key derivation (`PrivateKey#derive_child`, `PublicKey#derive_child`) with official spec test vectors
32
+
33
+ #### Transaction
34
+
35
+ - Chain tracker interface (`ChainTracker` base class) with WhatsOnChain implementation
36
+ - Fee model interface (`FeeModel` base class) with `SatoshisPerKilobyte` implementation
37
+ - `Transaction#fee` with change output distribution across multiple change outputs
38
+ - `Transaction#verify` for full SPV verification (merkle path, script execution, recursive ancestry)
39
+ - `TransactionOutput#change` flag for identifying change outputs
40
+ - `MerklePath#verify` for SPV chain tracker integration
41
+ - BEEF completion: `Beef#merge`, `Beef#valid?`, lookup methods (`find_bump`, `find_transaction_for_signing`)
42
+ - `Transaction#to_beef` / `Transaction.from_beef` convenience methods
43
+ - Extended Format (EF) transaction serialisation (`to_ef`, `to_ef_hex`, `from_ef`, `from_ef_hex`)
44
+ - `VerificationError` with typed error codes for SPV verification failures
45
+
46
+ ### Changed
47
+
48
+ - ECIES refactored to use `PrivateKey#derive_shared_secret` internally (no API change)
49
+ - `Transaction#estimated_size` made public for fee model access
50
+
51
+ ### Fixed
52
+
53
+ - Nil `source_satoshis` now raises instead of silently coercing to zero in fee distribution and verification
54
+ - Script chunk round-trips preserve original push encoding
55
+ - `OP_RETURN` inside conditionals correctly checked for conditional balance
56
+ - Point x-coordinate extraction preserves leading zeros via octet string
57
+ - `Integer#nobits?` replaced with Ruby 2.7-compatible bitwise check
58
+ - Defensive parsing with descriptive errors for truncated binary input
59
+
60
+ ### Testing
61
+
62
+ - BRC-42 conformance specs with 9 official specification test vectors
63
+ - ECDH conformance specs (commutativity, cross-method, pinned known-key vector)
64
+ - SPV verification conformance specs (merkle path, script execution, ancestry)
65
+ - Fee model conformance specs (formula validation, default rate, change distribution)
66
+ - Chain tracker conformance specs
67
+ - BEEF cross-SDK conformance vectors
68
+ - Schnorr (BRC-94) cross-SDK interoperability vectors
69
+ - 6 exact-match RFC 6979 vectors from Trezor/CoreBitcoin
70
+ - VarInt boundary tests at size-prefix transitions
71
+ - Script vectors converted to tracked known-failures system
72
+
8
73
  ## [0.1.0] - 2026-02-14
9
74
 
10
75
  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,40 @@ 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
+ PrivateKey.new(@bn.mod_add(hmac_bn, Curve::N))
162
+ end
163
+
130
164
  # Sign a 32-byte hash using deterministic ECDSA (RFC 6979).
131
165
  #
132
166
  # @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|
@@ -391,22 +391,26 @@ module BSV
391
391
  pos += 1
392
392
 
393
393
  if opcode.positive? && opcode <= 0x4b
394
+ raise ArgumentError, "truncated script: need #{opcode} data bytes at offset #{pos}" if pos + opcode > raw.bytesize
394
395
  data = raw.byteslice(pos, opcode)
395
396
  pos += opcode
396
397
  result << Chunk.new(opcode: opcode, data: data)
397
398
  elsif opcode == Opcodes::OP_PUSHDATA1
399
+ raise ArgumentError, "truncated script: OP_PUSHDATA1 missing length byte at offset #{pos}" if pos >= raw.bytesize
398
400
  len = raw.getbyte(pos)
399
401
  pos += 1
400
402
  data = raw.byteslice(pos, len)
401
403
  pos += len
402
404
  result << Chunk.new(opcode: opcode, data: data)
403
405
  elsif opcode == Opcodes::OP_PUSHDATA2
406
+ raise ArgumentError, "truncated script: OP_PUSHDATA2 needs 2 length bytes at offset #{pos}" if pos + 2 > raw.bytesize
404
407
  len = raw.byteslice(pos, 2).unpack1('v')
405
408
  pos += 2
406
409
  data = raw.byteslice(pos, len)
407
410
  pos += len
408
411
  result << Chunk.new(opcode: opcode, data: data)
409
412
  elsif opcode == Opcodes::OP_PUSHDATA4
413
+ raise ArgumentError, "truncated script: OP_PUSHDATA4 needs 4 length bytes at offset #{pos}" if pos + 4 > raw.bytesize
410
414
  len = raw.byteslice(pos, 4).unpack1('V')
411
415
  pos += 4
412
416
  data = raw.byteslice(pos, len)